From b57cbd2521ed1d235a4ad0952edc06624a484d75 Mon Sep 17 00:00:00 2001 From: Addons <993434923@qq.com> Date: Fri, 16 Aug 2024 10:09:47 +0800 Subject: [PATCH] edit --- .editorconfig | 12 ++++++++++++ .env | 25 +++++++++++++++++++++++++ .env.dev | 33 +++++++++++++++++++++++++++++++++ .env.local | 33 +++++++++++++++++++++++++++++++++ .env.prod | 33 +++++++++++++++++++++++++++++++++ .env.stage | 33 +++++++++++++++++++++++++++++++++ .env.test | 33 +++++++++++++++++++++++++++++++++ .eslintignore | 8 ++++++++ .eslintrc-auto-import.json | 259 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .eslintrc.js | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 9 +++++++++ .prettierignore | 11 +++++++++++ .stylelintignore | 6 ++++++ LICENSE | 21 +++++++++++++++++++++ README.md | 262 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ build/vite/index.ts | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ build/vite/optimize.ts | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 11620 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ postcss.config.js | 5 +++++ prettier.config.js | 22 ++++++++++++++++++++++ public/favicon.ico | Bin 0 -> 4286 bytes public/home.png | Bin 0 -> 74352 bytes public/logo.gif | Bin 0 -> 6334 bytes src/App.vue | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/ai/chat/conversation/index.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/ai/chat/message/index.ts | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/ai/image/index.ts | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/ai/mindmap/index.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/ai/model/apiKey/index.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/api/ai/model/chatModel/index.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/ai/model/chatRole/index.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/ai/music/index.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/api/ai/write/index.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/bpm/activity/index.ts | 8 ++++++++ src/api/bpm/category/index.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/api/bpm/definition/index.ts | 22 ++++++++++++++++++++++ src/api/bpm/form/index.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/bpm/leave/index.ts | 27 +++++++++++++++++++++++++++ src/api/bpm/model/index.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/bpm/processExpression/index.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/api/bpm/processInstance/index.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/bpm/processListener/index.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/api/bpm/task/index.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/bpm/userGroup/index.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/business/index.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/business/status/index.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/clue/index.ts | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/contact/index.ts | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/contract/config/index.ts | 16 ++++++++++++++++ src/api/crm/contract/index.ts | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/customer/index.ts | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/customer/limitConfig/index.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/customer/poolConfig/index.ts | 19 +++++++++++++++++++ src/api/crm/followup/index.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/api/crm/operateLog/index.ts | 11 +++++++++++ src/api/crm/permission/index.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/product/category/index.ts | 33 +++++++++++++++++++++++++++++++++ src/api/crm/product/index.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/receivable/index.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/receivable/plan/index.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/statistics/customer.ts | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/statistics/funnel.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/statistics/performance.ts | 33 +++++++++++++++++++++++++++++++++ src/api/crm/statistics/portrait.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/crm/statistics/rank.ts | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/finance/account/index.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/finance/payment/index.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/finance/receipt/index.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/product/category/index.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/product/product/index.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/product/unit/index.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/purchase/in/index.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/purchase/order/index.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/purchase/return/index.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/purchase/supplier/index.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/sale/customer/index.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/sale/order/index.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/sale/out/index.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/sale/return/index.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/statistics/purchase/index.ts | 28 ++++++++++++++++++++++++++++ src/api/erp/statistics/sale/index.ts | 28 ++++++++++++++++++++++++++++ src/api/erp/stock/check/index.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/stock/in/index.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/stock/move/index.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/stock/out/index.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/erp/stock/record/index.ts | 32 ++++++++++++++++++++++++++++++++ src/api/erp/stock/stock/index.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/api/erp/stock/warehouse/index.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/infra/apiAccessLog/index.ts | 34 ++++++++++++++++++++++++++++++++++ src/api/infra/apiErrorLog/index.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/api/infra/codegen/index.ts | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/infra/config/index.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/api/infra/dataSourceConfig/index.ts | 35 +++++++++++++++++++++++++++++++++++ src/api/infra/demo/demo01/index.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/api/infra/demo/demo02/index.ts | 37 +++++++++++++++++++++++++++++++++++++ src/api/infra/demo/demo03/erp/index.ts | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/infra/demo/demo03/inner/index.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/infra/demo/demo03/normal/index.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/infra/file/index.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/api/infra/fileConfig/index.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/infra/job/index.ts | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/infra/jobLog/index.ts | 34 ++++++++++++++++++++++++++++++++++ src/api/infra/redis/index.ts | 8 ++++++++ src/api/infra/redis/types.ts | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/login/index.ts | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/login/oauth2/index.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/api/login/types.ts | 31 +++++++++++++++++++++++++++++++ src/api/mall/market/banner/index.ts | 37 +++++++++++++++++++++++++++++++++++++ src/api/mall/product/brand.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/product/category.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/product/comment.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/product/favorite.ts | 12 ++++++++++++ src/api/mall/product/history.ts | 10 ++++++++++ src/api/mall/product/property.ts | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/product/spu.ts | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/article/index.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/articleCategory/index.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/bargain/bargainActivity.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/bargain/bargainHelp.ts | 14 ++++++++++++++ src/api/mall/promotion/bargain/bargainRecord.ts | 19 +++++++++++++++++++ src/api/mall/promotion/combination/combinationActivity.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/combination/combinationRecord.ts | 28 ++++++++++++++++++++++++++++ src/api/mall/promotion/coupon/coupon.ts | 26 ++++++++++++++++++++++++++ src/api/mall/promotion/coupon/couponTemplate.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/discount/discountActivity.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/diy/page.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/diy/template.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/kefu/conversation/index.ts | 35 +++++++++++++++++++++++++++++++++++ src/api/mall/promotion/kefu/message/index.ts | 36 ++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/reward/rewardActivity.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/seckill/seckillActivity.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/promotion/seckill/seckillConfig.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/statistics/common.ts | 5 +++++ src/api/mall/statistics/member.ts | 123 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/statistics/pay.ts | 12 ++++++++++++ src/api/mall/statistics/product.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/statistics/trade.ts | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/trade/afterSale/index.ts | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/trade/brokerage/record/index.ts | 11 +++++++++++ src/api/mall/trade/brokerage/user/index.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/api/mall/trade/brokerage/withdraw/index.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/api/mall/trade/config/index.ts | 23 +++++++++++++++++++++++ src/api/mall/trade/delivery/express/index.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/trade/delivery/expressTemplate/index.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/trade/delivery/pickUpStore/index.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/api/mall/trade/order/index.ts | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/member/address/index.ts | 15 +++++++++++++++ src/api/member/config/index.ts | 19 +++++++++++++++++++ src/api/member/experience-record/index.ts | 22 ++++++++++++++++++++++ src/api/member/group/index.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/api/member/level/index.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/api/member/point/record/index.ts | 18 ++++++++++++++++++ src/api/member/signin/config/index.ts | 34 ++++++++++++++++++++++++++++++++++ src/api/member/signin/record/index.ts | 13 +++++++++++++ src/api/member/tag/index.ts | 36 ++++++++++++++++++++++++++++++++++++ src/api/member/user/index.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mp/account/index.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/api/mp/autoReply/index.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/api/mp/draft/index.ts | 35 +++++++++++++++++++++++++++++++++++ src/api/mp/freePublish/index.ts | 23 +++++++++++++++++++++++ src/api/mp/material/index.ts | 16 ++++++++++++++++ src/api/mp/menu/index.ts | 26 ++++++++++++++++++++++++++ src/api/mp/message/index.ts | 17 +++++++++++++++++ src/api/mp/statistics/index.ts | 33 +++++++++++++++++++++++++++++++++ src/api/mp/tag/index.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/mp/user/index.ts | 31 +++++++++++++++++++++++++++++++ src/api/pay/app/index.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/pay/channel/index.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/api/pay/demo/index.ts | 36 ++++++++++++++++++++++++++++++++++++ src/api/pay/demo/transfer/index.ts | 25 +++++++++++++++++++++++++ src/api/pay/notify/index.ts | 16 ++++++++++++++++ src/api/pay/order/index.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/pay/refund/index.ts | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/pay/transfer/index.ts | 27 +++++++++++++++++++++++++++ src/api/pay/wallet/balance/index.ts | 26 ++++++++++++++++++++++++++ src/api/pay/wallet/rechargePackage/index.ts | 34 ++++++++++++++++++++++++++++++++++ src/api/pay/wallet/transaction/index.ts | 14 ++++++++++++++ src/api/system/area/index.ts | 11 +++++++++++ src/api/system/dept/index.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/api/system/dict/dict.data.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/dict/dict.type.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/api/system/loginLog/index.ts | 25 +++++++++++++++++++++++++ src/api/system/mail/account/index.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/api/system/mail/log/index.ts | 30 ++++++++++++++++++++++++++++++ src/api/system/mail/template/index.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/menu/index.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/notice/index.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/api/system/notify/message/index.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/notify/template/index.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/oauth2/client.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/oauth2/token.ts | 22 ++++++++++++++++++++++ src/api/system/operatelog/index.ts | 30 ++++++++++++++++++++++++++++++ src/api/system/permission/index.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/api/system/post/index.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/role/index.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/sms/smsChannel/index.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/api/system/sms/smsLog/index.ts | 37 +++++++++++++++++++++++++++++++++++++ src/api/system/sms/smsTemplate/index.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/social/client/index.ts | 37 +++++++++++++++++++++++++++++++++++++ src/api/system/social/user/index.ts | 24 ++++++++++++++++++++++++ src/api/system/tenant/index.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/tenantPackage/index.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/api/system/user/index.ts | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/user/profile.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/api/system/user/socialUser.ts | 31 +++++++++++++++++++++++++++++++ src/assets/ai/copy-style2.svg | 1 + src/assets/ai/copy.svg | 1 + src/assets/ai/dall2.jpg | Bin 0 -> 110271 bytes src/assets/ai/dall3.jpg | Bin 0 -> 89280 bytes src/assets/ai/delete.svg | 1 + src/assets/ai/gpt.svg | 1 + src/assets/ai/qingxi.jpg | Bin 0 -> 88392 bytes src/assets/ai/ziran.jpg | Bin 0 -> 124202 bytes src/assets/audio/response.mp3 | Bin 0 -> 1736109 bytes src/assets/imgs/avatar.gif | Bin 0 -> 6334 bytes src/assets/imgs/avatar.jpg | Bin 0 -> 6264 bytes src/assets/imgs/diy/app-nav-bar-mp.png | Bin 0 -> 3595 bytes src/assets/imgs/diy/statusBar.png | Bin 0 -> 8917 bytes src/assets/imgs/logo.png | Bin 0 -> 2801 bytes src/assets/imgs/profile.jpg | Bin 0 -> 7885 bytes src/assets/imgs/wechat.png | Bin 0 -> 1881 bytes src/assets/map/json/china.json | 856 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/assets/svgs/403.svg | 1 + src/assets/svgs/404.svg | 1 + src/assets/svgs/500.svg | 1 + src/assets/svgs/icon.svg | 1 + src/assets/svgs/login-bg.svg | 1 + src/assets/svgs/login-box-bg.svg | 1 + src/assets/svgs/member_balance.svg | 1 + src/assets/svgs/member_expenditure_balance.svg | 1 + src/assets/svgs/member_level.svg | 1 + src/assets/svgs/member_point.svg | 1 + src/assets/svgs/member_recharge_balance.svg | 1 + src/assets/svgs/message.svg | 1 + src/assets/svgs/money.svg | 1 + src/assets/svgs/pay/icon/alipay_app.svg | 1 + src/assets/svgs/pay/icon/alipay_bar.svg | 2 ++ src/assets/svgs/pay/icon/alipay_pc.svg | 1 + src/assets/svgs/pay/icon/alipay_qr.svg | 2 ++ src/assets/svgs/pay/icon/alipay_wap.svg | 1 + src/assets/svgs/pay/icon/mock.svg | 1 + src/assets/svgs/pay/icon/wx_app.svg | 2 ++ src/assets/svgs/pay/icon/wx_bar.svg | 1 + src/assets/svgs/pay/icon/wx_lite.svg | 1 + src/assets/svgs/pay/icon/wx_native.svg | 1 + src/assets/svgs/pay/icon/wx_pub.svg | 2 ++ src/assets/svgs/peoples.svg | 1 + src/assets/svgs/shopping.svg | 1 + src/components/AppLinkInput/AppLinkSelectDialog.vue | 207 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/AppLinkInput/data.ts | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/AppLinkInput/index.vue | 43 +++++++++++++++++++++++++++++++++++++++++++ src/components/Backtop/index.ts | 3 +++ src/components/Backtop/src/Backtop.vue | 17 +++++++++++++++++ src/components/Card/index.ts | 3 +++ src/components/Card/src/CardTitle.vue | 37 +++++++++++++++++++++++++++++++++++++ src/components/ColorInput/index.vue | 34 ++++++++++++++++++++++++++++++++++ src/components/ConfigGlobal/index.ts | 3 +++ src/components/ConfigGlobal/src/ConfigGlobal.vue | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/ContentDetailWrap/index.ts | 3 +++ src/components/ContentDetailWrap/src/ContentDetailWrap.vue | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/ContentWrap/index.ts | 3 +++ src/components/ContentWrap/src/ContentWrap.vue | 36 ++++++++++++++++++++++++++++++++++++ src/components/CountTo/index.ts | 3 +++ src/components/CountTo/src/CountTo.vue | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Crontab/index.ts | 2 ++ src/components/Crontab/src/Crontab.vue | 1015 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Cropper/index.ts | 4 ++++ src/components/Cropper/src/CopperModal.vue | 261 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Cropper/src/Cropper.vue | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Cropper/src/CropperAvatar.vue | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Cropper/src/types.ts | 8 ++++++++ src/components/Descriptions/index.ts | 4 ++++ src/components/Descriptions/src/Descriptions.vue | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Descriptions/src/DescriptionsItemLabel.vue | 29 +++++++++++++++++++++++++++++ src/components/Dialog/index.ts | 3 +++ src/components/Dialog/src/Dialog.vue | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DictTag/index.ts | 3 +++ src/components/DictTag/src/DictTag.vue | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/ComponentContainer.vue | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/ComponentContainerProperty.vue | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/ComponentLibrary.vue | 211 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/Carousel/config.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/Carousel/index.vue | 43 +++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/Carousel/property.vue | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/CouponCard/component.tsx | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/CouponCard/config.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/CouponCard/index.vue | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/CouponCard/property.vue | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/Divider/config.ts | 29 +++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/Divider/index.vue | 29 +++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/Divider/property.vue | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/FloatingActionButton/config.ts | 36 ++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/FloatingActionButton/index.vue | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/FloatingActionButton/property.vue | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/controller.ts | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/index.vue | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/HotZone/config.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/HotZone/index.vue | 42 ++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/HotZone/property.vue | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/ImageBar/config.ts | 27 +++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/ImageBar/index.vue | 24 ++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/ImageBar/property.vue | 34 ++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MagicCube/config.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MagicCube/index.vue | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MagicCube/property.vue | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MenuGrid/config.ts | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MenuGrid/index.vue | 35 +++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MenuGrid/property.vue | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MenuList/config.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MenuList/index.vue | 31 +++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MenuList/property.vue | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MenuSwiper/config.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MenuSwiper/index.vue | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/MenuSwiper/property.vue | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/NavigationBar/config.ts | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/NavigationBar/index.vue | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/NavigationBar/property.vue | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/NoticeBar/config.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/NoticeBar/index.vue | 26 ++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/NoticeBar/property.vue | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PageConfig/config.ts | 23 +++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PageConfig/property.vue | 34 ++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/Popover/config.ts | 26 ++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/Popover/index.vue | 38 ++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/Popover/property.vue | 38 ++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/ProductCard/config.ts | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/ProductCard/index.vue | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/ProductCard/property.vue | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/ProductList/config.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/ProductList/index.vue | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/ProductList/property.vue | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PromotionArticle/config.ts | 25 +++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PromotionArticle/index.vue | 27 +++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PromotionArticle/property.vue | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PromotionCombination/config.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PromotionCombination/index.vue | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PromotionCombination/property.vue | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PromotionSeckill/index.vue | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/PromotionSeckill/property.vue | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/SearchBar/config.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/SearchBar/index.vue | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/SearchBar/property.vue | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/TabBar/config.ts | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/TabBar/index.vue | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/TabBar/property.vue | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/TitleBar/config.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/TitleBar/index.vue | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/TitleBar/property.vue | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/UserCard/config.ts | 21 +++++++++++++++++++++ src/components/DiyEditor/components/mobile/UserCard/index.vue | 29 +++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/UserCard/property.vue | 17 +++++++++++++++++ src/components/DiyEditor/components/mobile/UserCoupon/config.ts | 23 +++++++++++++++++++++++ src/components/DiyEditor/components/mobile/UserCoupon/index.vue | 15 +++++++++++++++ src/components/DiyEditor/components/mobile/UserCoupon/property.vue | 17 +++++++++++++++++ src/components/DiyEditor/components/mobile/UserOrder/config.ts | 23 +++++++++++++++++++++++ src/components/DiyEditor/components/mobile/UserOrder/index.vue | 13 +++++++++++++ src/components/DiyEditor/components/mobile/UserOrder/property.vue | 17 +++++++++++++++++ src/components/DiyEditor/components/mobile/UserWallet/config.ts | 23 +++++++++++++++++++++++ src/components/DiyEditor/components/mobile/UserWallet/index.vue | 15 +++++++++++++++ src/components/DiyEditor/components/mobile/UserWallet/property.vue | 17 +++++++++++++++++ src/components/DiyEditor/components/mobile/VideoPlayer/config.ts | 37 +++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/VideoPlayer/index.vue | 30 ++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/VideoPlayer/property.vue | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/components/mobile/index.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/index.vue | 565 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DiyEditor/util.ts | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/DocAlert/index.vue | 34 ++++++++++++++++++++++++++++++++++ src/components/Draggable/index.vue | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Echart/index.ts | 3 +++ src/components/Echart/src/Echart.vue | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Editor/index.ts | 8 ++++++++ src/components/Editor/src/Editor.vue | 242 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Error/index.ts | 3 +++ src/components/Error/src/Error.vue | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Form/index.ts | 15 +++++++++++++++ src/components/Form/src/Form.vue | 307 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Form/src/componentMap.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Form/src/components/useRenderCheckbox.tsx | 26 ++++++++++++++++++++++++++ src/components/Form/src/components/useRenderRadio.tsx | 26 ++++++++++++++++++++++++++ src/components/Form/src/components/useRenderSelect.tsx | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Form/src/helper.ts | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Form/src/types.ts | 17 +++++++++++++++++ src/components/FormCreate/index.ts | 4 ++++ src/components/FormCreate/src/components/DictSelect.vue | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/FormCreate/src/components/useApiSelect.tsx | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/FormCreate/src/config/index.ts | 15 +++++++++++++++ src/components/FormCreate/src/config/selectRule.ts | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/FormCreate/src/config/useDictSelectRule.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/FormCreate/src/config/useEditorRule.ts | 32 ++++++++++++++++++++++++++++++++ src/components/FormCreate/src/config/useSelectRule.ts | 36 ++++++++++++++++++++++++++++++++++++ src/components/FormCreate/src/config/useUploadFileRule.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/FormCreate/src/config/useUploadImgRule.ts | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/FormCreate/src/config/useUploadImgsRule.ts | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/FormCreate/src/type/index.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/FormCreate/src/useFormCreateDesigner.ts | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/FormCreate/src/utils/index.ts | 18 ++++++++++++++++++ src/components/Highlight/index.ts | 3 +++ src/components/Highlight/src/Highlight.vue | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/IFrame/index.ts | 3 +++ src/components/IFrame/src/IFrame.vue | 32 ++++++++++++++++++++++++++++++++ src/components/Icon/index.ts | 4 ++++ src/components/Icon/src/Icon.vue | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Icon/src/IconSelect.vue | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Icon/src/data.ts | 1961 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/ImageViewer/index.ts | 33 +++++++++++++++++++++++++++++++++ src/components/ImageViewer/src/ImageViewer.vue | 35 +++++++++++++++++++++++++++++++++++ src/components/ImageViewer/src/types.ts | 9 +++++++++ src/components/Infotip/index.ts | 3 +++ src/components/Infotip/src/Infotip.vue | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/InputPassword/index.ts | 3 +++ src/components/InputPassword/src/InputPassword.vue | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/InputWithColor/index.vue | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/MagicCubeEditor/index.vue | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/MagicCubeEditor/util.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/MarkdownView/index.vue | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/OperateLogV2/index.ts | 3 +++ src/components/OperateLogV2/src/OperateLogV2.vue | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Pagination/index.vue | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Qrcode/index.ts | 3 +++ src/components/Qrcode/src/Qrcode.vue | 253 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/RouterSearch/index.vue | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Search/index.ts | 3 +++ src/components/Search/src/Search.vue | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/ShortcutDateRangePicker/index.vue | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/SimpleProcessDesigner/src/addNode.vue | 237 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/SimpleProcessDesigner/src/nodeWrap.vue | 297 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/SimpleProcessDesigner/src/util.ts | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/SimpleProcessDesigner/theme/workflow.css | 1292 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Sticky/index.ts | 3 +++ src/components/Sticky/src/Sticky.vue | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/SummaryCard/index.vue | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Table/index.ts | 13 +++++++++++++ src/components/Table/src/Table.vue | 311 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Table/src/TableSelectForm.vue | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Table/src/helper.ts | 8 ++++++++ src/components/Table/src/types.ts | 26 ++++++++++++++++++++++++++ src/components/Tooltip/index.ts | 3 +++ src/components/Tooltip/src/Tooltip.vue | 17 +++++++++++++++++ src/components/UploadFile/index.ts | 5 +++++ src/components/UploadFile/src/UploadFile.vue | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/UploadFile/src/UploadImg.vue | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/UploadFile/src/UploadImgs.vue | 323 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/UploadFile/src/useUpload.ts | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Verifition/index.ts | 3 +++ src/components/Verifition/src/Verify.vue | 441 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Verifition/src/Verify/VerifyPoints.vue | 250 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Verifition/src/Verify/VerifySlide.vue | 376 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/Verifition/src/Verify/index.ts | 4 ++++ src/components/Verifition/src/utils/ase.ts | 14 ++++++++++++++ src/components/Verifition/src/utils/util.ts | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/VerticalButtonGroup/index.vue | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/components/XButton/index.ts | 4 ++++ src/components/XButton/src/XButton.vue | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/XButton/src/XTextButton.vue | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/ProcessDesigner.vue | 704 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/ProcessViewer.vue | 664 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/index.ts | 8 ++++++++ src/components/bpmnProcessDesigner/package/designer/index2.ts | 8 ++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/contentPadProvider.js | 423 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/index.js | 6 ++++++ src/components/bpmnProcessDesigner/package/designer/plugins/defaultEmpty.js | 24 ++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/activitiDescriptor.json | 1004 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/camundaDescriptor.json | 1020 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/flowableDescriptor.json | 1217 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/activiti/activitiExtension.js | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/activiti/index.js | 11 +++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/camunda/extension.js | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/camunda/index.js | 8 ++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/flowable/flowableExtension.js | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/flowable/index.js | 10 ++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/palette/CustomPalette.js | 221 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/palette/index.js | 22 ++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/palette/paletteProvider.js | 213 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/translate/customTranslate.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/designer/plugins/translate/zh.js | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/index.ts | 11 +++++++++++ src/components/bpmnProcessDesigner/package/palette/ProcessPalette.vue | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/PropertiesPanel.vue | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/base/ElementBaseInfo.vue | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/form/ElementForm.vue | 478 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/index.js | 7 +++++++ src/components/bpmnProcessDesigner/package/penal/listeners/ElementListeners.vue | 448 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/listeners/ProcessListenerDialog.vue | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/listeners/UserTaskListeners.vue | 491 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/listeners/template.js | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/listeners/utilSelf.ts | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/multi-instance/ElementMultiInstance.vue | 280 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/other/ElementOtherConfig.vue | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/properties/ElementProperties.vue | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/signal-message/SignalAndMessage.vue | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/task/ElementTask.vue | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/task/task-components/ProcessExpressionDialog.vue | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/task/task-components/ReceiveTask.vue | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/task/task-components/ScriptTask.vue | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/penal/task/task-components/UserTask.vue | 234 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/theme/element-variables.scss | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/theme/index.scss | 2 ++ src/components/bpmnProcessDesigner/package/theme/process-designer.scss | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/theme/process-panel.scss | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/package/utils.ts | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/src/highlight/index.js | 5 +++++ src/components/bpmnProcessDesigner/src/modules/custom-renderer/CustomRenderer.js | 14 ++++++++++++++ src/components/bpmnProcessDesigner/src/modules/custom-renderer/index.js | 6 ++++++ src/components/bpmnProcessDesigner/src/modules/rules/CustomRules.js | 16 ++++++++++++++++ src/components/bpmnProcessDesigner/src/modules/rules/index.js | 6 ++++++ src/components/bpmnProcessDesigner/src/translations.ts | 25 +++++++++++++++++++++++++ src/components/bpmnProcessDesigner/src/utils/directive/clickOutSide.js | 39 +++++++++++++++++++++++++++++++++++++++ src/components/bpmnProcessDesigner/src/utils/index.js | 10 ++++++++++ src/components/bpmnProcessDesigner/src/utils/xml2json.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/index.ts | 6 ++++++ src/config/axios/config.ts | 28 ++++++++++++++++++++++++++++ src/config/axios/errorCode.ts | 6 ++++++ src/config/axios/index.ts | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/config/axios/service.ts | 231 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/directives/index.ts | 13 +++++++++++++ src/directives/permission/hasPermi.ts | 27 +++++++++++++++++++++++++++ src/directives/permission/hasRole.ts | 27 +++++++++++++++++++++++++++ src/hooks/event/useScrollTo.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/useCache.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/hooks/web/useConfigGlobal.ts | 9 +++++++++ src/hooks/web/useCrudSchemas.ts | 326 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/useDesign.ts | 18 ++++++++++++++++++ src/hooks/web/useEmitt.ts | 22 ++++++++++++++++++++++ src/hooks/web/useForm.ts | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/useGuide.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/useI18n.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/useIcon.ts | 8 ++++++++ src/hooks/web/useLocale.ts | 35 +++++++++++++++++++++++++++++++++++ src/hooks/web/useMessage.ts | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/useNProgress.ts | 33 +++++++++++++++++++++++++++++++++ src/hooks/web/useNetwork.ts | 21 +++++++++++++++++++++ src/hooks/web/useNow.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/usePageLoading.ts | 18 ++++++++++++++++++ src/hooks/web/useTable.ts | 223 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/useTagsView.ts | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/useTimeAgo.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/useTitle.ts | 24 ++++++++++++++++++++++++ src/hooks/web/useValidator.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/hooks/web/useWatermark.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/Layout.vue | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/AppView.vue | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Breadcrumb/index.ts | 3 +++ src/layout/components/Breadcrumb/src/Breadcrumb.vue | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Breadcrumb/src/helper.ts | 31 +++++++++++++++++++++++++++++++ src/layout/components/Collapse/index.ts | 3 +++ src/layout/components/Collapse/src/Collapse.vue | 35 +++++++++++++++++++++++++++++++++++ src/layout/components/ContextMenu/index.ts | 10 ++++++++++ src/layout/components/ContextMenu/src/ContextMenu.vue | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Footer/index.ts | 3 +++ src/layout/components/Footer/src/Footer.vue | 24 ++++++++++++++++++++++++ src/layout/components/LocaleDropdown/index.ts | 3 +++ src/layout/components/LocaleDropdown/src/LocaleDropdown.vue | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Logo/index.ts | 3 +++ src/layout/components/Logo/src/Logo.vue | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Menu/index.ts | 3 +++ src/layout/components/Menu/src/Menu.vue | 262 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Menu/src/components/useRenderMenuItem.tsx | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Menu/src/components/useRenderMenuTitle.tsx | 27 +++++++++++++++++++++++++++ src/layout/components/Menu/src/helper.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Message/index.ts | 3 +++ src/layout/components/Message/src/Message.vue | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Screenfull/index.ts | 3 +++ src/layout/components/Screenfull/src/Screenfull.vue | 32 ++++++++++++++++++++++++++++++++ src/layout/components/Setting/index.ts | 3 +++ src/layout/components/Setting/src/Setting.vue | 299 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Setting/src/components/ColorRadioPicker.vue | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Setting/src/components/InterfaceDisplay.vue | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/Setting/src/components/LayoutRadioPicker.vue | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/SizeDropdown/index.ts | 3 +++ src/layout/components/SizeDropdown/src/SizeDropdown.vue | 40 ++++++++++++++++++++++++++++++++++++++++ src/layout/components/TabMenu/index.ts | 3 +++ src/layout/components/TabMenu/src/TabMenu.vue | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/TabMenu/src/helper.ts | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/TagsView/index.ts | 3 +++ src/layout/components/TagsView/src/TagsView.vue | 586 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/TagsView/src/helper.ts | 21 +++++++++++++++++++++ src/layout/components/ThemeSwitch/index.ts | 3 +++ src/layout/components/ThemeSwitch/src/ThemeSwitch.vue | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/ToolHeader.vue | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/UserInfo/index.ts | 3 +++ src/layout/components/UserInfo/src/UserInfo.vue | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/UserInfo/src/components/LockDialog.vue | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/UserInfo/src/components/LockPage.vue | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/layout/components/useRenderLayout.tsx | 306 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/locales/en.ts | 457 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/locales/zh-CN.ts | 452 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/permission.ts | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/plugins/animate.css/index.ts | 1 + src/plugins/echarts/index.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/plugins/elementPlus/index.ts | 17 +++++++++++++++++ src/plugins/formCreate/index.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/plugins/svgIcon/index.ts | 3 +++ src/plugins/tongji/index.ts | 23 +++++++++++++++++++++++ src/plugins/unocss/index.ts | 1 + src/plugins/vueI18n/helper.ts | 3 +++ src/plugins/vueI18n/index.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/router/index.ts | 28 ++++++++++++++++++++++++++++ src/router/modules/remaining.ts | 609 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/store/index.ts | 12 ++++++++++++ src/store/modules/app.ts | 277 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/store/modules/dict.ts | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/store/modules/locale.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/store/modules/lock.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/store/modules/permission.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/store/modules/simpleWorkflow.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/store/modules/tagsView.ts | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/store/modules/user.ts | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/styles/FormCreate/fonts/fontello.woff | Bin 0 -> 4252 bytes src/styles/FormCreate/index.scss | 22 ++++++++++++++++++++++ src/styles/global.module.scss | 6 ++++++ src/styles/index.scss | 37 +++++++++++++++++++++++++++++++++++++ src/styles/theme.scss | 17 +++++++++++++++++ src/styles/var.css | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/styles/variables.scss | 4 ++++ src/types/components.d.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/types/configGlobal.d.ts | 4 ++++ src/types/contextMenu.d.ts | 7 +++++++ src/types/descriptions.d.ts | 14 ++++++++++++++ src/types/elementPlus.d.ts | 3 +++ src/types/form.d.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/types/icon.d.ts | 5 +++++ src/types/infoTip.d.ts | 4 ++++ src/types/layout.d.ts | 1 + src/types/localeDropdown.d.ts | 10 ++++++++++ src/types/qrcode.d.ts | 9 +++++++++ src/types/table.d.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/types/theme.d.ts | 16 ++++++++++++++++ src/utils/Logger.ts | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/auth.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/color.ts | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/constants.ts | 439 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/dateUtil.ts | 18 ++++++++++++++++++ src/utils/dict.ts | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/domUtils.ts | 289 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/download.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/filt.ts | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/formCreate.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/formRules.ts | 7 +++++++ src/utils/formatTime.ts | 332 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/formatter.ts | 7 +++++++ src/utils/index.ts | 451 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/is.ts | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/jsencrypt.ts | 31 +++++++++++++++++++++++++++++++ src/utils/permission.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/utils/propTypes.ts | 24 ++++++++++++++++++++++++ src/utils/routerHelper.ts | 253 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/tree.ts | 400 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils/tsxHelper.ts | 16 ++++++++++++++++ src/views/Error/403.vue | 8 ++++++++ src/views/Error/404.vue | 7 +++++++ src/views/Error/500.vue | 7 +++++++ src/views/Home/Index.vue | 391 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Home/Index2.vue | 319 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Home/echarts-data.ts | 308 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Home/types.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Login/Login.vue | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Login/SocialLogin.vue | 345 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Login/components/LoginForm.vue | 354 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Login/components/LoginFormTitle.vue | 26 ++++++++++++++++++++++++++ src/views/Login/components/MobileForm.vue | 226 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Login/components/QrCodeForm.vue | 30 ++++++++++++++++++++++++++++++ src/views/Login/components/RegisterForm.vue | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Login/components/SSOLogin.vue | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Login/components/index.ts | 8 ++++++++ src/views/Login/components/useLogin.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/views/Profile/Index.vue | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Profile/components/BasicInfo.vue | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Profile/components/ProfileUser.vue | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Profile/components/ResetPwd.vue | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Profile/components/UserAvatar.vue | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/views/Profile/components/UserSocial.vue | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/Profile/components/index.ts | 7 +++++++ src/views/Redirect/Redirect.vue | 28 ++++++++++++++++++++++++++++ src/views/ai/chat/index/components/conversation/ConversationList.vue | 472 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/index/components/conversation/ConversationUpdateForm.vue | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/index/components/message/MessageList.vue | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/index/components/message/MessageListEmpty.vue | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/index/components/message/MessageLoading.vue | 15 +++++++++++++++ src/views/ai/chat/index/components/message/MessageNewConversation.vue | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/index/components/role/RoleCategoryList.vue | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/index/components/role/RoleHeader.vue | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/index/components/role/RoleList.vue | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/index/components/role/RoleRepository.vue | 289 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/index/index.vue | 772 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/manager/ChatConversationList.vue | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/manager/ChatMessageList.vue | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/chat/manager/index.vue | 20 ++++++++++++++++++++ src/views/ai/image/index/components/ImageCard.vue | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/image/index/components/ImageDetail.vue | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/image/index/components/ImageList.vue | 245 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/image/index/components/dall3/index.vue | 320 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/image/index/components/midjourney/index.vue | 326 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/image/index/components/other/index.vue | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/image/index/components/stableDiffusion/index.vue | 272 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/image/index/index.vue | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/image/manager/index.vue | 251 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/image/square/index.vue | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/mindmap/index/components/Left.vue | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/mindmap/index/components/Right.vue | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/mindmap/index/index.vue | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/mindmap/manager/index.vue | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/model/apiKey/ApiKeyForm.vue | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/model/apiKey/index.vue | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/model/chatModel/ChatModelForm.vue | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/model/chatModel/index.vue | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/model/chatRole/ChatRoleForm.vue | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/model/chatRole/index.vue | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/music/index/index.vue | 26 ++++++++++++++++++++++++++ src/views/ai/music/index/list/audioBar/index.vue | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/music/index/list/index.vue | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/music/index/list/songCard/index.vue | 36 ++++++++++++++++++++++++++++++++++++ src/views/ai/music/index/list/songInfo/index.vue | 22 ++++++++++++++++++++++ src/views/ai/music/index/mode/desc.vue | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/music/index/mode/index.vue | 41 +++++++++++++++++++++++++++++++++++++++++ src/views/ai/music/index/mode/lyric.vue | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/music/index/title/index.vue | 25 +++++++++++++++++++++++++ src/views/ai/music/manager/index.vue | 292 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/utils/constants.ts | 481 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/utils/utils.ts | 13 +++++++++++++ src/views/ai/write/index/components/Left.vue | 213 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/write/index/components/Right.vue | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/write/index/components/Tag.vue | 32 ++++++++++++++++++++++++++++++++ src/views/ai/write/index/index.vue | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/ai/write/manager/index.vue | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/category/CategoryForm.vue | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/category/index.vue | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/definition/index.vue | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/form/editor/index.vue | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/form/index.vue | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/group/UserGroupForm.vue | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/group/index.vue | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/model/ModelForm.vue | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/model/ModelImportForm.vue | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/model/editor/index.vue | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/model/index.vue | 415 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/oa/leave/create.vue | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/oa/leave/detail.vue | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/oa/leave/index.vue | 257 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processExpression/ProcessExpressionForm.vue | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processExpression/index.vue | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/create/index.vue | 257 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/detail/ProcessInstanceBpmnViewer.vue | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/detail/ProcessInstanceTaskList.vue | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/detail/dialog/TaskDelegateForm.vue | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/detail/dialog/TaskReturnForm.vue | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/detail/dialog/TaskSignCreateForm.vue | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/detail/dialog/TaskSignDeleteForm.vue | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/detail/dialog/TaskSignList.vue | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/detail/dialog/TaskTransferForm.vue | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/detail/index.vue | 381 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/index.vue | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processInstance/manager/index.vue | 255 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processListener/ProcessListenerForm.vue | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/processListener/index.vue | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/simpleWorkflow/index.vue | 28 ++++++++++++++++++++++++++++ src/views/bpm/task/copy/index.vue | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/task/done/index.vue | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/task/manager/index.vue | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/bpm/task/todo/index.vue | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/backlog/components/ClueFollowList.vue | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/backlog/components/ContractAuditList.vue | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/backlog/components/ContractRemindList.vue | 246 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/backlog/components/CustomerFollowList.vue | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/backlog/components/CustomerPutPoolRemindList.vue | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/backlog/components/CustomerTodayContactList.vue | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/backlog/components/ReceivableAuditList.vue | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/backlog/components/ReceivablePlanRemindList.vue | 220 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/backlog/components/common.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/views/crm/backlog/index.vue | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/BusinessForm.vue | 287 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/BusinessUpdateStatusForm.vue | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/components/BusinessList.vue | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/components/BusinessListModal.vue | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/components/BusinessProductForm.vue | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/detail/BusinessDetailsHeader.vue | 37 +++++++++++++++++++++++++++++++++++++ src/views/crm/business/detail/BusinessDetailsInfo.vue | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/detail/BusinessProductList.vue | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/detail/index.vue | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/index.vue | 275 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/status/BusinessStatusForm.vue | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/business/status/index.vue | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/clue/ClueForm.vue | 259 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/clue/detail/ClueDetailsHeader.vue | 43 +++++++++++++++++++++++++++++++++++++++++++ src/views/crm/clue/detail/ClueDetailsInfo.vue | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/clue/detail/index.vue | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/clue/index.vue | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contact/ContactForm.vue | 310 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contact/components/ContactList.vue | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contact/components/ContactListModal.vue | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contact/detail/ContactDetailsHeader.vue | 33 +++++++++++++++++++++++++++++++++ src/views/crm/contact/detail/ContactDetailsInfo.vue | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contact/detail/index.vue | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contact/index.vue | 332 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contract/ContractForm.vue | 369 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contract/components/ContractList.vue | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contract/components/ContractProductForm.vue | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contract/config/index.vue | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contract/detail/ContractDetailsHeader.vue | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contract/detail/ContractDetailsInfo.vue | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contract/detail/ContractProductList.vue | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contract/detail/index.vue | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/contract/index.vue | 398 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/CustomerForm.vue | 259 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/CustomerImportForm.vue | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/detail/CustomerDetailsHeader.vue | 43 +++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/detail/CustomerDetailsInfo.vue | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/detail/index.vue | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/index.vue | 343 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/limitConfig/CustomerLimitConfigForm.vue | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/limitConfig/CustomerLimitConfigList.vue | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/limitConfig/index.vue | 22 ++++++++++++++++++++++ src/views/crm/customer/pool/CustomerDistributeForm.vue | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/pool/index.vue | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/customer/poolConfig/index.vue | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/followup/FollowUpRecordForm.vue | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/followup/components/FollowUpRecordBusinessForm.vue | 42 ++++++++++++++++++++++++++++++++++++++++++ src/views/crm/followup/components/FollowUpRecordContactForm.vue | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/followup/index.vue | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/permission/components/PermissionForm.vue | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/permission/components/PermissionList.vue | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/permission/components/TransferForm.vue | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/product/ProductForm.vue | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/product/category/ProductCategoryForm.vue | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/product/category/index.vue | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/product/detail/ProductDetailsHeader.vue | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/product/detail/ProductDetailsInfo.vue | 38 ++++++++++++++++++++++++++++++++++++++ src/views/crm/product/detail/index.vue | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/product/index.vue | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/ReceivableForm.vue | 293 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/components/ReceivableList.vue | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/detail/ReceivableDetailsHeader.vue | 43 +++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/detail/ReceivableDetailsInfo.vue | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/detail/index.vue | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/index.vue | 335 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/plan/ReceivablePlanForm.vue | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/plan/components/ReceivablePlanList.vue | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/plan/detail/ReceivablePlanDetailsHeader.vue | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/plan/detail/ReceivablePlanDetailsInfo.vue | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/plan/detail/index.vue | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/receivable/plan/index.vue | 335 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/customer/components/CustomerConversionStat.vue | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/customer/components/CustomerDealCycleByArea.vue | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/customer/components/CustomerDealCycleByProduct.vue | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/customer/components/CustomerDealCycleByUser.vue | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/customer/components/CustomerFollowUpSummary.vue | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/customer/components/CustomerFollowUpType.vue | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/customer/components/CustomerPoolSummary.vue | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/customer/components/CustomerSummary.vue | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/customer/index.vue | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/funnel/components/BusinessInversionRateSummary.vue | 307 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/funnel/components/BusinessSummary.vue | 259 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/funnel/components/FunnelBusiness.vue | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/funnel/index.vue | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/performance/components/ContractCountPerformance.vue | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/performance/components/ContractPricePerformance.vue | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/performance/index.vue | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/portrait/components/PortraitCustomerArea.vue | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/portrait/components/PortraitCustomerIndustry.vue | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/portrait/components/PortraitCustomerLevel.vue | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/portrait/components/PortraitCustomerSource.vue | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/portrait/index.vue | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/rank/components/ContactCountRank.vue | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/rank/components/ContractCountRank.vue | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/rank/components/ContractPriceRank.vue | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/rank/components/CustomerCountRank.vue | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/rank/components/FollowCountRank.vue | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/rank/components/FollowCustomerCountRank.vue | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/rank/components/ProductSalesRank.vue | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/rank/components/ReceivablePriceRank.vue | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/crm/statistics/rank/index.vue | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/finance/account/AccountForm.vue | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/finance/account/index.vue | 235 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/finance/payment/FinancePaymentForm.vue | 278 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/finance/payment/components/FinancePaymentItemForm.vue | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/finance/payment/index.vue | 394 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/finance/receipt/FinanceReceiptForm.vue | 278 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/finance/receipt/components/FinanceReceiptItemForm.vue | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/finance/receipt/index.vue | 394 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/home/components/SummaryCard.vue | 21 +++++++++++++++++++++ src/views/erp/home/components/TimeSummaryChart.vue | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/home/index.vue | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/product/category/ProductCategoryForm.vue | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/product/category/index.vue | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/product/product/ProductForm.vue | 242 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/product/product/index.vue | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/product/unit/ProductUnitForm.vue | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/product/unit/index.vue | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/in/PurchaseInForm.vue | 325 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/in/components/PurchaseInItemForm.vue | 300 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/in/components/PurchaseInPaymentEnableList.vue | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/in/index.vue | 443 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/order/PurchaseOrderForm.vue | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/order/components/PurchaseOrderInEnableList.vue | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/order/components/PurchaseOrderItemForm.vue | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/order/components/PurchaseOrderReturnEnableList.vue | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/order/index.vue | 407 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/return/PurchaseReturnForm.vue | 328 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/return/components/PurchaseReturnItemForm.vue | 300 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/return/components/PurchaseReturnRefundEnableList.vue | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/return/index.vue | 443 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/supplier/SupplierForm.vue | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/purchase/supplier/index.vue | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/customer/CustomerForm.vue | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/customer/index.vue | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/order/SaleOrderForm.vue | 289 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/order/components/SaleOrderItemForm.vue | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/order/components/SaleOrderOutEnableList.vue | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/order/components/SaleOrderReturnEnableList.vue | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/order/index.vue | 407 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/out/SaleOutForm.vue | 343 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/out/components/SaleOutItemForm.vue | 300 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/out/components/SaleOutReceiptEnableList.vue | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/out/index.vue | 438 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/return/SaleReturnForm.vue | 341 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/return/components/SaleReturnItemForm.vue | 300 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/return/components/SaleReturnRefundEnableList.vue | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/sale/return/index.vue | 443 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/check/StockCheckForm.vue | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/check/components/StockCheckItemForm.vue | 289 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/check/index.vue | 359 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/in/StockInForm.vue | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/in/components/StockInItemForm.vue | 267 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/in/index.vue | 376 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/move/StockMoveForm.vue | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/move/components/StockMoveItemForm.vue | 292 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/move/index.vue | 359 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/out/StockOutForm.vue | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/out/components/StockOutItemForm.vue | 267 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/out/index.vue | 378 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/record/index.vue | 250 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/stock/index.vue | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/warehouse/WarehouseForm.vue | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/erp/stock/warehouse/index.vue | 242 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/apiAccessLog/ApiAccessLogDetail.vue | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/apiAccessLog/index.vue | 226 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/apiErrorLog/ApiErrorLogDetail.vue | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/apiErrorLog/index.vue | 252 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/build/index.vue | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/codegen/EditTable.vue | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/codegen/ImportTable.vue | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/codegen/PreviewCode.vue | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/codegen/components/BasicInfoForm.vue | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/codegen/components/ColumInfoForm.vue | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/codegen/components/GenerateInfoForm.vue | 385 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/codegen/components/index.ts | 4 ++++ src/views/infra/codegen/index.vue | 258 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/config/ConfigForm.vue | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/config/index.vue | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/dataSourceConfig/DataSourceConfigForm.vue | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/dataSourceConfig/index.vue | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo01/Demo01ContactForm.vue | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo01/index.vue | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo02/Demo02CategoryForm.vue | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo02/index.vue | 207 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/erp/Demo03StudentForm.vue | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/erp/components/Demo03CourseForm.vue | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/erp/components/Demo03CourseList.vue | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/erp/components/Demo03GradeForm.vue | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/erp/components/Demo03GradeList.vue | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/erp/index.vue | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/inner/Demo03StudentForm.vue | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/inner/components/Demo03CourseForm.vue | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/inner/components/Demo03CourseList.vue | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/inner/components/Demo03GradeForm.vue | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/inner/components/Demo03GradeList.vue | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/inner/index.vue | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/normal/Demo03StudentForm.vue | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/normal/components/Demo03CourseForm.vue | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/normal/components/Demo03GradeForm.vue | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/demo/demo03/normal/index.vue | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/druid/index.vue | 28 ++++++++++++++++++++++++++++ src/views/infra/file/FileForm.vue | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/file/index.vue | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/fileConfig/FileConfigForm.vue | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/fileConfig/index.vue | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/job/JobDetail.vue | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/job/JobForm.vue | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/job/index.vue | 303 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/job/logger/JobLogDetail.vue | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/job/logger/index.vue | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/redis/index.vue | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/infra/server/index.vue | 30 ++++++++++++++++++++++++++++++ src/views/infra/skywalking/index.vue | 27 +++++++++++++++++++++++++++ src/views/infra/swagger/index.vue | 28 ++++++++++++++++++++++++++++ src/views/infra/webSocket/index.vue | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/home/components/ComparisonCard.vue | 42 ++++++++++++++++++++++++++++++++++++++++++ src/views/mall/home/components/MemberStatisticsCard.vue | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/home/components/OperationDataCard.vue | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/home/components/ShortcutCard.vue | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/home/components/TradeTrendCard.vue | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/home/index.vue | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/brand/BrandForm.vue | 123 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/brand/index.vue | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/category/CategoryForm.vue | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/category/components/ProductCategorySelect.vue | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/category/index.vue | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/comment/CommentForm.vue | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/comment/ReplyForm.vue | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/comment/index.vue | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/property/PropertyForm.vue | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/property/index.vue | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/property/value/ValueForm.vue | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/property/value/index.vue | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/components/SkuList.vue | 576 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/components/SkuTableSelect.vue | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/components/SpuShowcase.vue | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/components/SpuTableSelect.vue | 303 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/components/index.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/form/DeliveryForm.vue | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/form/DescriptionForm.vue | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/form/InfoForm.vue | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/form/OtherForm.vue | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/form/ProductAttributes.vue | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/form/ProductPropertyAddForm.vue | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/form/SkuForm.vue | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/form/index.vue | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/product/spu/index.vue | 457 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/article/ArticleForm.vue | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/article/category/ArticleCategoryForm.vue | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/article/category/index.vue | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/article/index.vue | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/banner/BannerForm.vue | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/banner/index.vue | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/bargain/activity/BargainActivityForm.vue | 233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/bargain/activity/bargainActivity.data.ts | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/bargain/activity/index.vue | 234 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/bargain/record/BargainRecordListDialog.vue | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/bargain/record/index.vue | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/combination/activity/CombinationActivityForm.vue | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/combination/activity/combinationActivity.data.ts | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/combination/activity/index.vue | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/combination/record/CombinationRecordListDialog.vue | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/combination/record/index.vue | 276 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/components/SpuAndSkuList.vue | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/components/SpuSelect.vue | 317 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/components/index.ts | 14 ++++++++++++++ src/views/mall/promotion/coupon/components/CouponSelect.vue | 219 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/coupon/components/CouponSendForm.vue | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/coupon/components/index.ts | 4 ++++ src/views/mall/promotion/coupon/formatter.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/coupon/index.vue | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/coupon/template/CouponTemplateForm.vue | 388 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/coupon/template/index.vue | 278 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/discountActivity/DiscountActivityForm.vue | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/discountActivity/discountActivity.data.ts | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/discountActivity/index.vue | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/diy/page/DiyPageForm.vue | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/diy/page/decorate.vue | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/diy/page/index.vue | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/diy/template/DiyTemplateForm.vue | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/diy/template/decorate.vue | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/diy/template/index.vue | 227 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/KeFuConversationList.vue | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/KeFuMessageList.vue | 465 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/asserts/a.png | Bin 0 -> 4237 bytes src/views/mall/promotion/kefu/components/asserts/aini.png | Bin 0 -> 2309 bytes src/views/mall/promotion/kefu/components/asserts/aixin.png | Bin 0 -> 4431 bytes src/views/mall/promotion/kefu/components/asserts/baiyan.png | Bin 0 -> 3792 bytes src/views/mall/promotion/kefu/components/asserts/bizui.png | Bin 0 -> 3768 bytes src/views/mall/promotion/kefu/components/asserts/buhaoyisi.png | Bin 0 -> 4443 bytes src/views/mall/promotion/kefu/components/asserts/bukesiyi.png | Bin 0 -> 3979 bytes src/views/mall/promotion/kefu/components/asserts/dajing.png | Bin 0 -> 4298 bytes src/views/mall/promotion/kefu/components/asserts/danao.png | Bin 0 -> 4568 bytes src/views/mall/promotion/kefu/components/asserts/daxiao.png | Bin 0 -> 4382 bytes src/views/mall/promotion/kefu/components/asserts/dianzan.png | Bin 0 -> 1878 bytes src/views/mall/promotion/kefu/components/asserts/emo.png | Bin 0 -> 4956 bytes src/views/mall/promotion/kefu/components/asserts/esi.png | Bin 0 -> 3873 bytes src/views/mall/promotion/kefu/components/asserts/fadai.png | Bin 0 -> 3823 bytes src/views/mall/promotion/kefu/components/asserts/fankun.png | Bin 0 -> 4236 bytes src/views/mall/promotion/kefu/components/asserts/feiwen.png | Bin 0 -> 6873 bytes src/views/mall/promotion/kefu/components/asserts/fennu.png | Bin 0 -> 4590 bytes src/views/mall/promotion/kefu/components/asserts/ganga.png | Bin 0 -> 4396 bytes src/views/mall/promotion/kefu/components/asserts/ganmao.png | Bin 0 -> 4727 bytes src/views/mall/promotion/kefu/components/asserts/hanyan.png | Bin 0 -> 2966 bytes src/views/mall/promotion/kefu/components/asserts/haochi.png | Bin 0 -> 3794 bytes src/views/mall/promotion/kefu/components/asserts/hongxin.png | Bin 0 -> 3844 bytes src/views/mall/promotion/kefu/components/asserts/huaixiao.png | Bin 0 -> 4234 bytes src/views/mall/promotion/kefu/components/asserts/jingkong.png | Bin 0 -> 4272 bytes src/views/mall/promotion/kefu/components/asserts/jingshu.png | Bin 0 -> 4702 bytes src/views/mall/promotion/kefu/components/asserts/jingya.png | Bin 0 -> 4167 bytes src/views/mall/promotion/kefu/components/asserts/kaixin.png | Bin 0 -> 4008 bytes src/views/mall/promotion/kefu/components/asserts/keai.png | Bin 0 -> 4060 bytes src/views/mall/promotion/kefu/components/asserts/keshui.png | Bin 0 -> 3975 bytes src/views/mall/promotion/kefu/components/asserts/kun.png | Bin 0 -> 4460 bytes src/views/mall/promotion/kefu/components/asserts/lengku.png | Bin 0 -> 4630 bytes src/views/mall/promotion/kefu/components/asserts/liuhan.png | Bin 0 -> 3823 bytes src/views/mall/promotion/kefu/components/asserts/liukoushui.png | Bin 0 -> 4072 bytes src/views/mall/promotion/kefu/components/asserts/liulei.png | Bin 0 -> 4246 bytes src/views/mall/promotion/kefu/components/asserts/mengbi.png | Bin 0 -> 3345 bytes src/views/mall/promotion/kefu/components/asserts/mianwubiaoqing.png | Bin 0 -> 2928 bytes src/views/mall/promotion/kefu/components/asserts/nanguo.png | Bin 0 -> 3882 bytes src/views/mall/promotion/kefu/components/asserts/outu.png | Bin 0 -> 4403 bytes src/views/mall/promotion/kefu/components/asserts/picture.svg | 10 ++++++++++ src/views/mall/promotion/kefu/components/asserts/shengqi.png | Bin 0 -> 4629 bytes src/views/mall/promotion/kefu/components/asserts/shuizhuo.png | Bin 0 -> 4641 bytes src/views/mall/promotion/kefu/components/asserts/tianshi.png | Bin 0 -> 4192 bytes src/views/mall/promotion/kefu/components/asserts/xiaodiaoya.png | Bin 0 -> 4326 bytes src/views/mall/promotion/kefu/components/asserts/xiaoku.png | Bin 0 -> 4725 bytes src/views/mall/promotion/kefu/components/asserts/xinsui.png | Bin 0 -> 4377 bytes src/views/mall/promotion/kefu/components/asserts/xiong.png | Bin 0 -> 4525 bytes src/views/mall/promotion/kefu/components/asserts/yiwen.png | Bin 0 -> 4615 bytes src/views/mall/promotion/kefu/components/asserts/yun.png | Bin 0 -> 5991 bytes src/views/mall/promotion/kefu/components/asserts/ziya.png | Bin 0 -> 4126 bytes src/views/mall/promotion/kefu/components/history/MemberBrowsingHistory.vue | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/history/OrderBrowsingHistory.vue | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/history/ProductBrowsingHistory.vue | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/index.ts | 5 +++++ src/views/mall/promotion/kefu/components/message/MessageItem.vue | 24 ++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/message/OrderItem.vue | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/message/ProductItem.vue | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/tools/EmojiSelectPopover.vue | 42 ++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/tools/PictureSelectUpload.vue | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/components/tools/constants.ts | 17 +++++++++++++++++ src/views/mall/promotion/kefu/components/tools/emoji.ts | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/kefu/index.vue | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/rewardActivity/RewardForm.vue | 325 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/rewardActivity/index.vue | 193 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/seckill/activity/index.vue | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/seckill/activity/seckillActivity.data.ts | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/seckill/config/SeckillConfigForm.vue | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/promotion/seckill/config/index.vue | 211 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/statistics/member/components/MemberFunnelCard.vue | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/statistics/member/components/MemberTerminalCard.vue | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/statistics/member/index.vue | 313 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/statistics/product/components/ProductRank.vue | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/statistics/product/components/ProductSummary.vue | 304 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/statistics/product/index.vue | 16 ++++++++++++++++ src/views/mall/statistics/trade/components/TradeStatisticValue.vue | 36 ++++++++++++++++++++++++++++++++++++ src/views/mall/statistics/trade/index.vue | 363 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/afterSale/detail/index.vue | 354 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/afterSale/form/AfterSaleDisagreeForm.vue | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/afterSale/index.vue | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/brokerage/record/index.vue | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/brokerage/user/BrokerageOrderListDialog.vue | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/brokerage/user/BrokerageUserListDialog.vue | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/brokerage/user/UpdateBindUserForm.vue | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/brokerage/user/index.vue | 307 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/brokerage/withdraw/BrokerageWithdrawRejectForm.vue | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/brokerage/withdraw/index.vue | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/config/index.vue | 291 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/delivery/express/ExpressForm.vue | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/delivery/express/index.vue | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue | 321 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/delivery/expressTemplate/index.vue | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/delivery/pickUpOrder/index.vue | 328 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/delivery/pickUpStore/PickUpStoreForm.vue | 273 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/delivery/pickUpStore/index.vue | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/order/components/OrderTableColumn.vue | 263 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/order/components/index.ts | 3 +++ src/views/mall/trade/order/detail/index.vue | 426 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/order/form/OrderDeliveryForm.vue | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/order/form/OrderPickUpForm.vue | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/order/form/OrderUpdateAddressForm.vue | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/order/form/OrderUpdatePriceForm.vue | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/order/form/OrderUpdateRemarkForm.vue | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mall/trade/order/index.vue | 357 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/config/index.vue | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/group/GroupForm.vue | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/group/components/MemberGroupSelect.vue | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/views/member/group/index.vue | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/level/LevelForm.vue | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/level/components/MemberLevelSelect.vue | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/views/member/level/index.vue | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/point/record/index.vue | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/signin/config/SignInConfigForm.vue | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/signin/config/index.vue | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/signin/record/index.vue | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/tag/TagForm.vue | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/tag/components/MemberTagSelect.vue | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/tag/index.vue | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/UserForm.vue | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/UserLevelUpdateForm.vue | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/UserPointUpdateForm.vue | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/components/balance-list.vue | 14 ++++++++++++++ src/views/member/user/detail/UserAccountInfo.vue | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/detail/UserAddressList.vue | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/detail/UserBasicInfo.vue | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/detail/UserBrokerageList.vue | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/detail/UserCouponList.vue | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/detail/UserExperienceRecordList.vue | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/detail/UserFavoriteList.vue | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/detail/UserOrderList.vue | 279 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/detail/UserPointList.vue | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/detail/UserSignList.vue | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/detail/index.vue | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/member/user/index.vue | 313 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/account/AccountForm.vue | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/account/index.vue | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/autoReply/components/ReplyForm.vue | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/autoReply/components/ReplyTable.vue | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/autoReply/components/types.ts | 7 +++++++ src/views/mp/autoReply/index.vue | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-account-select/index.ts | 3 +++ src/views/mp/components/wx-account-select/main.vue | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-location/index.ts | 3 +++ src/views/mp/components/wx-location/main.vue | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-material-select/index.ts | 6 ++++++ src/views/mp/components/wx-material-select/main.vue | 279 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-material-select/types.ts | 11 +++++++++++ src/views/mp/components/wx-msg/card.scss | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-msg/comment.scss | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-msg/components/Msg.vue | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-msg/components/MsgEvent.vue | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-msg/components/MsgList.vue | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-msg/index.ts | 6 ++++++ src/views/mp/components/wx-msg/main.vue | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-msg/types.ts | 17 +++++++++++++++++ src/views/mp/components/wx-music/index.ts | 3 +++ src/views/mp/components/wx-music/main.vue | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-news/index.ts | 3 +++ src/views/mp/components/wx-news/main.vue | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-reply/components/TabImage.vue | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-reply/components/TabMusic.vue | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-reply/components/TabNews.vue | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-reply/components/TabText.vue | 22 ++++++++++++++++++++++ src/views/mp/components/wx-reply/components/TabVideo.vue | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-reply/components/TabVoice.vue | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-reply/components/types.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-reply/index.ts | 7 +++++++ src/views/mp/components/wx-reply/main.vue | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-video-play/index.ts | 3 +++ src/views/mp/components/wx-video-play/main.vue | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/components/wx-voice-play/index.ts | 3 +++ src/views/mp/components/wx-voice-play/main.vue | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/draft/components/CoverSelect.vue | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/draft/components/DraftTable.vue | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/draft/components/NewsForm.vue | 304 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/draft/components/index.ts | 7 +++++++ src/views/mp/draft/components/types.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/views/mp/draft/editor-config.ts | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/draft/index.vue | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/draft/mock.js | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/freePublish/index.vue | 336 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/hooks/useUpload.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/material/components/ImageTable.vue | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/material/components/UploadFile.vue | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/material/components/UploadVideo.vue | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/material/components/VideoTable.vue | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/material/components/VoiceTable.vue | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/material/components/upload.ts | 32 ++++++++++++++++++++++++++++++++ src/views/mp/material/index.vue | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/menu/assets/iphone_backImg.png | Bin 0 -> 34272 bytes src/views/mp/menu/assets/menu_foot.png | Bin 0 -> 1348 bytes src/views/mp/menu/assets/menu_head.png | Bin 0 -> 12673 bytes src/views/mp/menu/components/MenuEditor.vue | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/menu/components/MenuPreviewer.vue | 226 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/menu/components/menuOptions.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/views/mp/menu/components/types.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/menu/index.vue | 401 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/message/MessageTable.vue | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/message/index.vue | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/statistics/index.vue | 368 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/tag/TagForm.vue | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/tag/index.vue | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/user/UserForm.vue | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/mp/user/index.vue | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/app/components/AppForm.vue | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/app/components/channel/AlipayChannelForm.vue | 326 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/app/components/channel/MockChannelForm.vue | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/app/components/channel/WalletChannelForm.vue | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/app/components/channel/WeixinChannelForm.vue | 306 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/app/index.vue | 364 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/cashier/index.vue | 482 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/demo/order/index.vue | 240 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/demo/transfer/DemoTransferForm.vue | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/demo/transfer/index.vue | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/notify/NotifyDetail.vue | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/notify/index.vue | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/order/OrderDetail.vue | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/order/index.vue | 273 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/refund/RefundDetail.vue | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/refund/index.vue | 298 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/transfer/CreatePayTransfer.vue | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/transfer/TransferDetail.vue | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/transfer/index.vue | 267 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/wallet/balance/WalletForm.vue | 22 ++++++++++++++++++++++ src/views/pay/wallet/balance/index.vue | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/wallet/rechargePackage/WalletRechargePackageForm.vue | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/wallet/rechargePackage/index.vue | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/pay/wallet/transaction/WalletTransactionList.vue | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/report/goview/index.vue | 12 ++++++++++++ src/views/report/jmreport/index.vue | 15 +++++++++++++++ src/views/system/area/AreaForm.vue | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/area/index.vue | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/dept/DeptForm.vue | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/dept/index.vue | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/dict/DictTypeForm.vue | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/dict/data/DictDataForm.vue | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/dict/data/index.vue | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/dict/index.vue | 231 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/loginlog/LoginLogDetail.vue | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/loginlog/index.vue | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/mail/account/MailAccountDetail.vue | 28 ++++++++++++++++++++++++++++ src/views/system/mail/account/MailAccountForm.vue | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/mail/account/account.data.ts | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/mail/account/index.vue | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/mail/log/MailLogDetail.vue | 33 +++++++++++++++++++++++++++++++++ src/views/system/mail/log/index.vue | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/mail/log/log.data.ts | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/mail/template/MailTemplateForm.vue | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/mail/template/MailTemplateSendForm.vue | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/mail/template/index.vue | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/mail/template/template.data.ts | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/menu/MenuForm.vue | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/menu/index.vue | 215 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/notice/NoticeForm.vue | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/notice/index.vue | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/notify/message/NotifyMessageDetail.vue | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/notify/message/index.vue | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/notify/my/MyNotifyMessageDetail.vue | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/notify/my/index.vue | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/notify/template/NotifyTemplateForm.vue | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/notify/template/NotifyTemplateSendForm.vue | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/notify/template/index.vue | 235 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/oauth2/client/ClientForm.vue | 261 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/oauth2/client/index.vue | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/oauth2/token/index.vue | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/operatelog/OperateLogDetail.vue | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/operatelog/index.vue | 213 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/post/PostForm.vue | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/post/index.vue | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/role/RoleAssignMenuForm.vue | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/role/RoleDataPermissionForm.vue | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/role/RoleForm.vue | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/role/index.vue | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/sms/channel/SmsChannelForm.vue | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/sms/channel/index.vue | 207 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/sms/log/SmsLogDetail.vue | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/sms/log/index.vue | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/sms/template/SmsTemplateForm.vue | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/sms/template/SmsTemplateSendForm.vue | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/sms/template/index.vue | 316 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/social/client/SocialClientForm.vue | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/social/client/index.vue | 227 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/social/user/SocialUserDetail.vue | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/social/user/index.vue | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/tenant/TenantForm.vue | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/tenant/index.vue | 266 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/tenantPackage/TenantPackageForm.vue | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/tenantPackage/index.vue | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/user/DeptTree.vue | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/user/UserAssignRoleForm.vue | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/user/UserForm.vue | 219 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/user/UserImportForm.vue | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/views/system/user/index.vue | 362 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ stylelint.config.js | 235 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 43 +++++++++++++++++++++++++++++++++++++++++++ types/components.d.ts | 8 ++++++++ types/custom-types.d.ts | 27 +++++++++++++++++++++++++++ types/env.d.ts | 35 +++++++++++++++++++++++++++++++++++ types/global.d.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ types/router.d.ts | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ uno.config.ts | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ vite.config.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1362 files changed, 172810 insertions(+) create mode 100644 .editorconfig create mode 100644 .env create mode 100644 .env.dev create mode 100644 .env.local create mode 100644 .env.prod create mode 100644 .env.stage create mode 100644 .env.test create mode 100644 .eslintignore create mode 100644 .eslintrc-auto-import.json create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .stylelintignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build/vite/index.ts create mode 100644 build/vite/optimize.ts create mode 100644 index.html create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.js create mode 100644 prettier.config.js create mode 100644 public/favicon.ico create mode 100644 public/home.png create mode 100644 public/logo.gif create mode 100644 src/App.vue create mode 100644 src/api/ai/chat/conversation/index.ts create mode 100644 src/api/ai/chat/message/index.ts create mode 100644 src/api/ai/image/index.ts create mode 100644 src/api/ai/mindmap/index.ts create mode 100644 src/api/ai/model/apiKey/index.ts create mode 100644 src/api/ai/model/chatModel/index.ts create mode 100644 src/api/ai/model/chatRole/index.ts create mode 100644 src/api/ai/music/index.ts create mode 100644 src/api/ai/write/index.ts create mode 100644 src/api/bpm/activity/index.ts create mode 100644 src/api/bpm/category/index.ts create mode 100644 src/api/bpm/definition/index.ts create mode 100644 src/api/bpm/form/index.ts create mode 100644 src/api/bpm/leave/index.ts create mode 100644 src/api/bpm/model/index.ts create mode 100644 src/api/bpm/processExpression/index.ts create mode 100644 src/api/bpm/processInstance/index.ts create mode 100644 src/api/bpm/processListener/index.ts create mode 100644 src/api/bpm/task/index.ts create mode 100644 src/api/bpm/userGroup/index.ts create mode 100644 src/api/crm/business/index.ts create mode 100644 src/api/crm/business/status/index.ts create mode 100644 src/api/crm/clue/index.ts create mode 100644 src/api/crm/contact/index.ts create mode 100644 src/api/crm/contract/config/index.ts create mode 100644 src/api/crm/contract/index.ts create mode 100644 src/api/crm/customer/index.ts create mode 100644 src/api/crm/customer/limitConfig/index.ts create mode 100644 src/api/crm/customer/poolConfig/index.ts create mode 100644 src/api/crm/followup/index.ts create mode 100644 src/api/crm/operateLog/index.ts create mode 100644 src/api/crm/permission/index.ts create mode 100644 src/api/crm/product/category/index.ts create mode 100644 src/api/crm/product/index.ts create mode 100644 src/api/crm/receivable/index.ts create mode 100644 src/api/crm/receivable/plan/index.ts create mode 100644 src/api/crm/statistics/customer.ts create mode 100644 src/api/crm/statistics/funnel.ts create mode 100644 src/api/crm/statistics/performance.ts create mode 100644 src/api/crm/statistics/portrait.ts create mode 100644 src/api/crm/statistics/rank.ts create mode 100644 src/api/erp/finance/account/index.ts create mode 100644 src/api/erp/finance/payment/index.ts create mode 100644 src/api/erp/finance/receipt/index.ts create mode 100644 src/api/erp/product/category/index.ts create mode 100644 src/api/erp/product/product/index.ts create mode 100644 src/api/erp/product/unit/index.ts create mode 100644 src/api/erp/purchase/in/index.ts create mode 100644 src/api/erp/purchase/order/index.ts create mode 100644 src/api/erp/purchase/return/index.ts create mode 100644 src/api/erp/purchase/supplier/index.ts create mode 100644 src/api/erp/sale/customer/index.ts create mode 100644 src/api/erp/sale/order/index.ts create mode 100644 src/api/erp/sale/out/index.ts create mode 100644 src/api/erp/sale/return/index.ts create mode 100644 src/api/erp/statistics/purchase/index.ts create mode 100644 src/api/erp/statistics/sale/index.ts create mode 100644 src/api/erp/stock/check/index.ts create mode 100644 src/api/erp/stock/in/index.ts create mode 100644 src/api/erp/stock/move/index.ts create mode 100644 src/api/erp/stock/out/index.ts create mode 100644 src/api/erp/stock/record/index.ts create mode 100644 src/api/erp/stock/stock/index.ts create mode 100644 src/api/erp/stock/warehouse/index.ts create mode 100644 src/api/infra/apiAccessLog/index.ts create mode 100644 src/api/infra/apiErrorLog/index.ts create mode 100644 src/api/infra/codegen/index.ts create mode 100644 src/api/infra/config/index.ts create mode 100644 src/api/infra/dataSourceConfig/index.ts create mode 100644 src/api/infra/demo/demo01/index.ts create mode 100644 src/api/infra/demo/demo02/index.ts create mode 100644 src/api/infra/demo/demo03/erp/index.ts create mode 100644 src/api/infra/demo/demo03/inner/index.ts create mode 100644 src/api/infra/demo/demo03/normal/index.ts create mode 100644 src/api/infra/file/index.ts create mode 100644 src/api/infra/fileConfig/index.ts create mode 100644 src/api/infra/job/index.ts create mode 100644 src/api/infra/jobLog/index.ts create mode 100644 src/api/infra/redis/index.ts create mode 100644 src/api/infra/redis/types.ts create mode 100644 src/api/login/index.ts create mode 100644 src/api/login/oauth2/index.ts create mode 100644 src/api/login/types.ts create mode 100644 src/api/mall/market/banner/index.ts create mode 100644 src/api/mall/product/brand.ts create mode 100644 src/api/mall/product/category.ts create mode 100644 src/api/mall/product/comment.ts create mode 100644 src/api/mall/product/favorite.ts create mode 100644 src/api/mall/product/history.ts create mode 100644 src/api/mall/product/property.ts create mode 100644 src/api/mall/product/spu.ts create mode 100644 src/api/mall/promotion/article/index.ts create mode 100644 src/api/mall/promotion/articleCategory/index.ts create mode 100644 src/api/mall/promotion/bargain/bargainActivity.ts create mode 100644 src/api/mall/promotion/bargain/bargainHelp.ts create mode 100644 src/api/mall/promotion/bargain/bargainRecord.ts create mode 100644 src/api/mall/promotion/combination/combinationActivity.ts create mode 100644 src/api/mall/promotion/combination/combinationRecord.ts create mode 100755 src/api/mall/promotion/coupon/coupon.ts create mode 100755 src/api/mall/promotion/coupon/couponTemplate.ts create mode 100644 src/api/mall/promotion/discount/discountActivity.ts create mode 100644 src/api/mall/promotion/diy/page.ts create mode 100644 src/api/mall/promotion/diy/template.ts create mode 100644 src/api/mall/promotion/kefu/conversation/index.ts create mode 100644 src/api/mall/promotion/kefu/message/index.ts create mode 100644 src/api/mall/promotion/reward/rewardActivity.ts create mode 100644 src/api/mall/promotion/seckill/seckillActivity.ts create mode 100644 src/api/mall/promotion/seckill/seckillConfig.ts create mode 100644 src/api/mall/statistics/common.ts create mode 100644 src/api/mall/statistics/member.ts create mode 100644 src/api/mall/statistics/pay.ts create mode 100644 src/api/mall/statistics/product.ts create mode 100644 src/api/mall/statistics/trade.ts create mode 100644 src/api/mall/trade/afterSale/index.ts create mode 100644 src/api/mall/trade/brokerage/record/index.ts create mode 100644 src/api/mall/trade/brokerage/user/index.ts create mode 100644 src/api/mall/trade/brokerage/withdraw/index.ts create mode 100644 src/api/mall/trade/config/index.ts create mode 100644 src/api/mall/trade/delivery/express/index.ts create mode 100644 src/api/mall/trade/delivery/expressTemplate/index.ts create mode 100644 src/api/mall/trade/delivery/pickUpStore/index.ts create mode 100644 src/api/mall/trade/order/index.ts create mode 100644 src/api/member/address/index.ts create mode 100644 src/api/member/config/index.ts create mode 100644 src/api/member/experience-record/index.ts create mode 100644 src/api/member/group/index.ts create mode 100644 src/api/member/level/index.ts create mode 100644 src/api/member/point/record/index.ts create mode 100644 src/api/member/signin/config/index.ts create mode 100644 src/api/member/signin/record/index.ts create mode 100644 src/api/member/tag/index.ts create mode 100644 src/api/member/user/index.ts create mode 100644 src/api/mp/account/index.ts create mode 100644 src/api/mp/autoReply/index.ts create mode 100644 src/api/mp/draft/index.ts create mode 100644 src/api/mp/freePublish/index.ts create mode 100644 src/api/mp/material/index.ts create mode 100644 src/api/mp/menu/index.ts create mode 100644 src/api/mp/message/index.ts create mode 100644 src/api/mp/statistics/index.ts create mode 100644 src/api/mp/tag/index.ts create mode 100644 src/api/mp/user/index.ts create mode 100644 src/api/pay/app/index.ts create mode 100644 src/api/pay/channel/index.ts create mode 100644 src/api/pay/demo/index.ts create mode 100644 src/api/pay/demo/transfer/index.ts create mode 100644 src/api/pay/notify/index.ts create mode 100644 src/api/pay/order/index.ts create mode 100644 src/api/pay/refund/index.ts create mode 100644 src/api/pay/transfer/index.ts create mode 100644 src/api/pay/wallet/balance/index.ts create mode 100644 src/api/pay/wallet/rechargePackage/index.ts create mode 100644 src/api/pay/wallet/transaction/index.ts create mode 100644 src/api/system/area/index.ts create mode 100644 src/api/system/dept/index.ts create mode 100644 src/api/system/dict/dict.data.ts create mode 100644 src/api/system/dict/dict.type.ts create mode 100644 src/api/system/loginLog/index.ts create mode 100644 src/api/system/mail/account/index.ts create mode 100644 src/api/system/mail/log/index.ts create mode 100644 src/api/system/mail/template/index.ts create mode 100644 src/api/system/menu/index.ts create mode 100644 src/api/system/notice/index.ts create mode 100644 src/api/system/notify/message/index.ts create mode 100644 src/api/system/notify/template/index.ts create mode 100644 src/api/system/oauth2/client.ts create mode 100644 src/api/system/oauth2/token.ts create mode 100644 src/api/system/operatelog/index.ts create mode 100644 src/api/system/permission/index.ts create mode 100644 src/api/system/post/index.ts create mode 100644 src/api/system/role/index.ts create mode 100644 src/api/system/sms/smsChannel/index.ts create mode 100644 src/api/system/sms/smsLog/index.ts create mode 100644 src/api/system/sms/smsTemplate/index.ts create mode 100644 src/api/system/social/client/index.ts create mode 100644 src/api/system/social/user/index.ts create mode 100644 src/api/system/tenant/index.ts create mode 100644 src/api/system/tenantPackage/index.ts create mode 100644 src/api/system/user/index.ts create mode 100644 src/api/system/user/profile.ts create mode 100644 src/api/system/user/socialUser.ts create mode 100644 src/assets/ai/copy-style2.svg create mode 100644 src/assets/ai/copy.svg create mode 100644 src/assets/ai/dall2.jpg create mode 100644 src/assets/ai/dall3.jpg create mode 100644 src/assets/ai/delete.svg create mode 100644 src/assets/ai/gpt.svg create mode 100644 src/assets/ai/qingxi.jpg create mode 100644 src/assets/ai/ziran.jpg create mode 100644 src/assets/audio/response.mp3 create mode 100644 src/assets/imgs/avatar.gif create mode 100644 src/assets/imgs/avatar.jpg create mode 100644 src/assets/imgs/diy/app-nav-bar-mp.png create mode 100644 src/assets/imgs/diy/statusBar.png create mode 100644 src/assets/imgs/logo.png create mode 100644 src/assets/imgs/profile.jpg create mode 100644 src/assets/imgs/wechat.png create mode 100644 src/assets/map/json/china.json create mode 100644 src/assets/svgs/403.svg create mode 100644 src/assets/svgs/404.svg create mode 100644 src/assets/svgs/500.svg create mode 100644 src/assets/svgs/icon.svg create mode 100644 src/assets/svgs/login-bg.svg create mode 100644 src/assets/svgs/login-box-bg.svg create mode 100644 src/assets/svgs/member_balance.svg create mode 100644 src/assets/svgs/member_expenditure_balance.svg create mode 100644 src/assets/svgs/member_level.svg create mode 100644 src/assets/svgs/member_point.svg create mode 100644 src/assets/svgs/member_recharge_balance.svg create mode 100644 src/assets/svgs/message.svg create mode 100644 src/assets/svgs/money.svg create mode 100644 src/assets/svgs/pay/icon/alipay_app.svg create mode 100644 src/assets/svgs/pay/icon/alipay_bar.svg create mode 100644 src/assets/svgs/pay/icon/alipay_pc.svg create mode 100644 src/assets/svgs/pay/icon/alipay_qr.svg create mode 100644 src/assets/svgs/pay/icon/alipay_wap.svg create mode 100644 src/assets/svgs/pay/icon/mock.svg create mode 100644 src/assets/svgs/pay/icon/wx_app.svg create mode 100644 src/assets/svgs/pay/icon/wx_bar.svg create mode 100644 src/assets/svgs/pay/icon/wx_lite.svg create mode 100644 src/assets/svgs/pay/icon/wx_native.svg create mode 100644 src/assets/svgs/pay/icon/wx_pub.svg create mode 100644 src/assets/svgs/peoples.svg create mode 100644 src/assets/svgs/shopping.svg create mode 100644 src/components/AppLinkInput/AppLinkSelectDialog.vue create mode 100644 src/components/AppLinkInput/data.ts create mode 100644 src/components/AppLinkInput/index.vue create mode 100644 src/components/Backtop/index.ts create mode 100644 src/components/Backtop/src/Backtop.vue create mode 100644 src/components/Card/index.ts create mode 100644 src/components/Card/src/CardTitle.vue create mode 100644 src/components/ColorInput/index.vue create mode 100644 src/components/ConfigGlobal/index.ts create mode 100644 src/components/ConfigGlobal/src/ConfigGlobal.vue create mode 100644 src/components/ContentDetailWrap/index.ts create mode 100644 src/components/ContentDetailWrap/src/ContentDetailWrap.vue create mode 100644 src/components/ContentWrap/index.ts create mode 100644 src/components/ContentWrap/src/ContentWrap.vue create mode 100644 src/components/CountTo/index.ts create mode 100644 src/components/CountTo/src/CountTo.vue create mode 100644 src/components/Crontab/index.ts create mode 100644 src/components/Crontab/src/Crontab.vue create mode 100644 src/components/Cropper/index.ts create mode 100644 src/components/Cropper/src/CopperModal.vue create mode 100644 src/components/Cropper/src/Cropper.vue create mode 100644 src/components/Cropper/src/CropperAvatar.vue create mode 100644 src/components/Cropper/src/types.ts create mode 100644 src/components/Descriptions/index.ts create mode 100644 src/components/Descriptions/src/Descriptions.vue create mode 100644 src/components/Descriptions/src/DescriptionsItemLabel.vue create mode 100644 src/components/Dialog/index.ts create mode 100644 src/components/Dialog/src/Dialog.vue create mode 100644 src/components/DictTag/index.ts create mode 100644 src/components/DictTag/src/DictTag.vue create mode 100644 src/components/DiyEditor/components/ComponentContainer.vue create mode 100644 src/components/DiyEditor/components/ComponentContainerProperty.vue create mode 100644 src/components/DiyEditor/components/ComponentLibrary.vue create mode 100644 src/components/DiyEditor/components/mobile/Carousel/config.ts create mode 100644 src/components/DiyEditor/components/mobile/Carousel/index.vue create mode 100644 src/components/DiyEditor/components/mobile/Carousel/property.vue create mode 100644 src/components/DiyEditor/components/mobile/CouponCard/component.tsx create mode 100644 src/components/DiyEditor/components/mobile/CouponCard/config.ts create mode 100644 src/components/DiyEditor/components/mobile/CouponCard/index.vue create mode 100644 src/components/DiyEditor/components/mobile/CouponCard/property.vue create mode 100644 src/components/DiyEditor/components/mobile/Divider/config.ts create mode 100644 src/components/DiyEditor/components/mobile/Divider/index.vue create mode 100644 src/components/DiyEditor/components/mobile/Divider/property.vue create mode 100644 src/components/DiyEditor/components/mobile/FloatingActionButton/config.ts create mode 100644 src/components/DiyEditor/components/mobile/FloatingActionButton/index.vue create mode 100644 src/components/DiyEditor/components/mobile/FloatingActionButton/property.vue create mode 100644 src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/controller.ts create mode 100644 src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/index.vue create mode 100644 src/components/DiyEditor/components/mobile/HotZone/config.ts create mode 100644 src/components/DiyEditor/components/mobile/HotZone/index.vue create mode 100644 src/components/DiyEditor/components/mobile/HotZone/property.vue create mode 100644 src/components/DiyEditor/components/mobile/ImageBar/config.ts create mode 100644 src/components/DiyEditor/components/mobile/ImageBar/index.vue create mode 100644 src/components/DiyEditor/components/mobile/ImageBar/property.vue create mode 100644 src/components/DiyEditor/components/mobile/MagicCube/config.ts create mode 100644 src/components/DiyEditor/components/mobile/MagicCube/index.vue create mode 100644 src/components/DiyEditor/components/mobile/MagicCube/property.vue create mode 100644 src/components/DiyEditor/components/mobile/MenuGrid/config.ts create mode 100644 src/components/DiyEditor/components/mobile/MenuGrid/index.vue create mode 100644 src/components/DiyEditor/components/mobile/MenuGrid/property.vue create mode 100644 src/components/DiyEditor/components/mobile/MenuList/config.ts create mode 100644 src/components/DiyEditor/components/mobile/MenuList/index.vue create mode 100644 src/components/DiyEditor/components/mobile/MenuList/property.vue create mode 100644 src/components/DiyEditor/components/mobile/MenuSwiper/config.ts create mode 100644 src/components/DiyEditor/components/mobile/MenuSwiper/index.vue create mode 100644 src/components/DiyEditor/components/mobile/MenuSwiper/property.vue create mode 100644 src/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue create mode 100644 src/components/DiyEditor/components/mobile/NavigationBar/config.ts create mode 100644 src/components/DiyEditor/components/mobile/NavigationBar/index.vue create mode 100644 src/components/DiyEditor/components/mobile/NavigationBar/property.vue create mode 100644 src/components/DiyEditor/components/mobile/NoticeBar/config.ts create mode 100644 src/components/DiyEditor/components/mobile/NoticeBar/index.vue create mode 100644 src/components/DiyEditor/components/mobile/NoticeBar/property.vue create mode 100644 src/components/DiyEditor/components/mobile/PageConfig/config.ts create mode 100644 src/components/DiyEditor/components/mobile/PageConfig/property.vue create mode 100644 src/components/DiyEditor/components/mobile/Popover/config.ts create mode 100644 src/components/DiyEditor/components/mobile/Popover/index.vue create mode 100644 src/components/DiyEditor/components/mobile/Popover/property.vue create mode 100644 src/components/DiyEditor/components/mobile/ProductCard/config.ts create mode 100644 src/components/DiyEditor/components/mobile/ProductCard/index.vue create mode 100644 src/components/DiyEditor/components/mobile/ProductCard/property.vue create mode 100644 src/components/DiyEditor/components/mobile/ProductList/config.ts create mode 100644 src/components/DiyEditor/components/mobile/ProductList/index.vue create mode 100644 src/components/DiyEditor/components/mobile/ProductList/property.vue create mode 100644 src/components/DiyEditor/components/mobile/PromotionArticle/config.ts create mode 100644 src/components/DiyEditor/components/mobile/PromotionArticle/index.vue create mode 100644 src/components/DiyEditor/components/mobile/PromotionArticle/property.vue create mode 100644 src/components/DiyEditor/components/mobile/PromotionCombination/config.ts create mode 100644 src/components/DiyEditor/components/mobile/PromotionCombination/index.vue create mode 100644 src/components/DiyEditor/components/mobile/PromotionCombination/property.vue create mode 100644 src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts create mode 100644 src/components/DiyEditor/components/mobile/PromotionSeckill/index.vue create mode 100644 src/components/DiyEditor/components/mobile/PromotionSeckill/property.vue create mode 100644 src/components/DiyEditor/components/mobile/SearchBar/config.ts create mode 100644 src/components/DiyEditor/components/mobile/SearchBar/index.vue create mode 100644 src/components/DiyEditor/components/mobile/SearchBar/property.vue create mode 100644 src/components/DiyEditor/components/mobile/TabBar/config.ts create mode 100644 src/components/DiyEditor/components/mobile/TabBar/index.vue create mode 100644 src/components/DiyEditor/components/mobile/TabBar/property.vue create mode 100644 src/components/DiyEditor/components/mobile/TitleBar/config.ts create mode 100644 src/components/DiyEditor/components/mobile/TitleBar/index.vue create mode 100644 src/components/DiyEditor/components/mobile/TitleBar/property.vue create mode 100644 src/components/DiyEditor/components/mobile/UserCard/config.ts create mode 100644 src/components/DiyEditor/components/mobile/UserCard/index.vue create mode 100644 src/components/DiyEditor/components/mobile/UserCard/property.vue create mode 100644 src/components/DiyEditor/components/mobile/UserCoupon/config.ts create mode 100644 src/components/DiyEditor/components/mobile/UserCoupon/index.vue create mode 100644 src/components/DiyEditor/components/mobile/UserCoupon/property.vue create mode 100644 src/components/DiyEditor/components/mobile/UserOrder/config.ts create mode 100644 src/components/DiyEditor/components/mobile/UserOrder/index.vue create mode 100644 src/components/DiyEditor/components/mobile/UserOrder/property.vue create mode 100644 src/components/DiyEditor/components/mobile/UserWallet/config.ts create mode 100644 src/components/DiyEditor/components/mobile/UserWallet/index.vue create mode 100644 src/components/DiyEditor/components/mobile/UserWallet/property.vue create mode 100644 src/components/DiyEditor/components/mobile/VideoPlayer/config.ts create mode 100644 src/components/DiyEditor/components/mobile/VideoPlayer/index.vue create mode 100644 src/components/DiyEditor/components/mobile/VideoPlayer/property.vue create mode 100644 src/components/DiyEditor/components/mobile/index.ts create mode 100644 src/components/DiyEditor/index.vue create mode 100644 src/components/DiyEditor/util.ts create mode 100644 src/components/DocAlert/index.vue create mode 100644 src/components/Draggable/index.vue create mode 100644 src/components/Echart/index.ts create mode 100644 src/components/Echart/src/Echart.vue create mode 100644 src/components/Editor/index.ts create mode 100644 src/components/Editor/src/Editor.vue create mode 100644 src/components/Error/index.ts create mode 100644 src/components/Error/src/Error.vue create mode 100644 src/components/Form/index.ts create mode 100644 src/components/Form/src/Form.vue create mode 100644 src/components/Form/src/componentMap.ts create mode 100644 src/components/Form/src/components/useRenderCheckbox.tsx create mode 100644 src/components/Form/src/components/useRenderRadio.tsx create mode 100644 src/components/Form/src/components/useRenderSelect.tsx create mode 100644 src/components/Form/src/helper.ts create mode 100644 src/components/Form/src/types.ts create mode 100644 src/components/FormCreate/index.ts create mode 100644 src/components/FormCreate/src/components/DictSelect.vue create mode 100644 src/components/FormCreate/src/components/useApiSelect.tsx create mode 100644 src/components/FormCreate/src/config/index.ts create mode 100644 src/components/FormCreate/src/config/selectRule.ts create mode 100644 src/components/FormCreate/src/config/useDictSelectRule.ts create mode 100644 src/components/FormCreate/src/config/useEditorRule.ts create mode 100644 src/components/FormCreate/src/config/useSelectRule.ts create mode 100644 src/components/FormCreate/src/config/useUploadFileRule.ts create mode 100644 src/components/FormCreate/src/config/useUploadImgRule.ts create mode 100644 src/components/FormCreate/src/config/useUploadImgsRule.ts create mode 100644 src/components/FormCreate/src/type/index.ts create mode 100644 src/components/FormCreate/src/useFormCreateDesigner.ts create mode 100644 src/components/FormCreate/src/utils/index.ts create mode 100644 src/components/Highlight/index.ts create mode 100644 src/components/Highlight/src/Highlight.vue create mode 100644 src/components/IFrame/index.ts create mode 100644 src/components/IFrame/src/IFrame.vue create mode 100644 src/components/Icon/index.ts create mode 100644 src/components/Icon/src/Icon.vue create mode 100644 src/components/Icon/src/IconSelect.vue create mode 100644 src/components/Icon/src/data.ts create mode 100644 src/components/ImageViewer/index.ts create mode 100644 src/components/ImageViewer/src/ImageViewer.vue create mode 100644 src/components/ImageViewer/src/types.ts create mode 100644 src/components/Infotip/index.ts create mode 100644 src/components/Infotip/src/Infotip.vue create mode 100644 src/components/InputPassword/index.ts create mode 100644 src/components/InputPassword/src/InputPassword.vue create mode 100644 src/components/InputWithColor/index.vue create mode 100644 src/components/MagicCubeEditor/index.vue create mode 100644 src/components/MagicCubeEditor/util.ts create mode 100644 src/components/MarkdownView/index.vue create mode 100644 src/components/OperateLogV2/index.ts create mode 100644 src/components/OperateLogV2/src/OperateLogV2.vue create mode 100644 src/components/Pagination/index.vue create mode 100644 src/components/Qrcode/index.ts create mode 100644 src/components/Qrcode/src/Qrcode.vue create mode 100644 src/components/RouterSearch/index.vue create mode 100644 src/components/Search/index.ts create mode 100644 src/components/Search/src/Search.vue create mode 100644 src/components/ShortcutDateRangePicker/index.vue create mode 100644 src/components/SimpleProcessDesigner/src/addNode.vue create mode 100644 src/components/SimpleProcessDesigner/src/nodeWrap.vue create mode 100644 src/components/SimpleProcessDesigner/src/util.ts create mode 100644 src/components/SimpleProcessDesigner/theme/workflow.css create mode 100644 src/components/Sticky/index.ts create mode 100644 src/components/Sticky/src/Sticky.vue create mode 100644 src/components/SummaryCard/index.vue create mode 100644 src/components/Table/index.ts create mode 100644 src/components/Table/src/Table.vue create mode 100644 src/components/Table/src/TableSelectForm.vue create mode 100644 src/components/Table/src/helper.ts create mode 100644 src/components/Table/src/types.ts create mode 100644 src/components/Tooltip/index.ts create mode 100644 src/components/Tooltip/src/Tooltip.vue create mode 100644 src/components/UploadFile/index.ts create mode 100644 src/components/UploadFile/src/UploadFile.vue create mode 100644 src/components/UploadFile/src/UploadImg.vue create mode 100644 src/components/UploadFile/src/UploadImgs.vue create mode 100644 src/components/UploadFile/src/useUpload.ts create mode 100644 src/components/Verifition/index.ts create mode 100644 src/components/Verifition/src/Verify.vue create mode 100644 src/components/Verifition/src/Verify/VerifyPoints.vue create mode 100644 src/components/Verifition/src/Verify/VerifySlide.vue create mode 100644 src/components/Verifition/src/Verify/index.ts create mode 100644 src/components/Verifition/src/utils/ase.ts create mode 100644 src/components/Verifition/src/utils/util.ts create mode 100644 src/components/VerticalButtonGroup/index.vue create mode 100644 src/components/XButton/index.ts create mode 100644 src/components/XButton/src/XButton.vue create mode 100644 src/components/XButton/src/XTextButton.vue create mode 100644 src/components/bpmnProcessDesigner/package/designer/ProcessDesigner.vue create mode 100644 src/components/bpmnProcessDesigner/package/designer/ProcessViewer.vue create mode 100644 src/components/bpmnProcessDesigner/package/designer/index.ts create mode 100644 src/components/bpmnProcessDesigner/package/designer/index2.ts create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/contentPadProvider.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/index.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/defaultEmpty.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/activitiDescriptor.json create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/camundaDescriptor.json create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/flowableDescriptor.json create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/activiti/activitiExtension.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/activiti/index.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/camunda/extension.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/camunda/index.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/flowable/flowableExtension.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/flowable/index.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/palette/CustomPalette.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/palette/index.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/palette/paletteProvider.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/translate/customTranslate.js create mode 100644 src/components/bpmnProcessDesigner/package/designer/plugins/translate/zh.js create mode 100644 src/components/bpmnProcessDesigner/package/index.ts create mode 100644 src/components/bpmnProcessDesigner/package/palette/ProcessPalette.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/PropertiesPanel.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/base/ElementBaseInfo.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/form/ElementForm.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/index.js create mode 100644 src/components/bpmnProcessDesigner/package/penal/listeners/ElementListeners.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/listeners/ProcessListenerDialog.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/listeners/UserTaskListeners.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/listeners/template.js create mode 100644 src/components/bpmnProcessDesigner/package/penal/listeners/utilSelf.ts create mode 100644 src/components/bpmnProcessDesigner/package/penal/multi-instance/ElementMultiInstance.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/other/ElementOtherConfig.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/properties/ElementProperties.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/signal-message/SignalAndMessage.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/task/ElementTask.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/task/task-components/ProcessExpressionDialog.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/task/task-components/ReceiveTask.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/task/task-components/ScriptTask.vue create mode 100644 src/components/bpmnProcessDesigner/package/penal/task/task-components/UserTask.vue create mode 100644 src/components/bpmnProcessDesigner/package/theme/element-variables.scss create mode 100644 src/components/bpmnProcessDesigner/package/theme/index.scss create mode 100644 src/components/bpmnProcessDesigner/package/theme/process-designer.scss create mode 100644 src/components/bpmnProcessDesigner/package/theme/process-panel.scss create mode 100644 src/components/bpmnProcessDesigner/package/utils.ts create mode 100644 src/components/bpmnProcessDesigner/src/highlight/index.js create mode 100644 src/components/bpmnProcessDesigner/src/modules/custom-renderer/CustomRenderer.js create mode 100644 src/components/bpmnProcessDesigner/src/modules/custom-renderer/index.js create mode 100644 src/components/bpmnProcessDesigner/src/modules/rules/CustomRules.js create mode 100644 src/components/bpmnProcessDesigner/src/modules/rules/index.js create mode 100644 src/components/bpmnProcessDesigner/src/translations.ts create mode 100644 src/components/bpmnProcessDesigner/src/utils/directive/clickOutSide.js create mode 100644 src/components/bpmnProcessDesigner/src/utils/index.js create mode 100644 src/components/bpmnProcessDesigner/src/utils/xml2json.js create mode 100644 src/components/index.ts create mode 100644 src/config/axios/config.ts create mode 100644 src/config/axios/errorCode.ts create mode 100644 src/config/axios/index.ts create mode 100644 src/config/axios/service.ts create mode 100644 src/directives/index.ts create mode 100644 src/directives/permission/hasPermi.ts create mode 100644 src/directives/permission/hasRole.ts create mode 100644 src/hooks/event/useScrollTo.ts create mode 100644 src/hooks/web/useCache.ts create mode 100644 src/hooks/web/useConfigGlobal.ts create mode 100644 src/hooks/web/useCrudSchemas.ts create mode 100644 src/hooks/web/useDesign.ts create mode 100644 src/hooks/web/useEmitt.ts create mode 100644 src/hooks/web/useForm.ts create mode 100644 src/hooks/web/useGuide.ts create mode 100644 src/hooks/web/useI18n.ts create mode 100644 src/hooks/web/useIcon.ts create mode 100644 src/hooks/web/useLocale.ts create mode 100644 src/hooks/web/useMessage.ts create mode 100644 src/hooks/web/useNProgress.ts create mode 100644 src/hooks/web/useNetwork.ts create mode 100644 src/hooks/web/useNow.ts create mode 100644 src/hooks/web/usePageLoading.ts create mode 100644 src/hooks/web/useTable.ts create mode 100644 src/hooks/web/useTagsView.ts create mode 100644 src/hooks/web/useTimeAgo.ts create mode 100644 src/hooks/web/useTitle.ts create mode 100644 src/hooks/web/useValidator.ts create mode 100644 src/hooks/web/useWatermark.ts create mode 100644 src/layout/Layout.vue create mode 100644 src/layout/components/AppView.vue create mode 100644 src/layout/components/Breadcrumb/index.ts create mode 100644 src/layout/components/Breadcrumb/src/Breadcrumb.vue create mode 100644 src/layout/components/Breadcrumb/src/helper.ts create mode 100644 src/layout/components/Collapse/index.ts create mode 100644 src/layout/components/Collapse/src/Collapse.vue create mode 100644 src/layout/components/ContextMenu/index.ts create mode 100644 src/layout/components/ContextMenu/src/ContextMenu.vue create mode 100644 src/layout/components/Footer/index.ts create mode 100644 src/layout/components/Footer/src/Footer.vue create mode 100644 src/layout/components/LocaleDropdown/index.ts create mode 100644 src/layout/components/LocaleDropdown/src/LocaleDropdown.vue create mode 100644 src/layout/components/Logo/index.ts create mode 100644 src/layout/components/Logo/src/Logo.vue create mode 100644 src/layout/components/Menu/index.ts create mode 100644 src/layout/components/Menu/src/Menu.vue create mode 100644 src/layout/components/Menu/src/components/useRenderMenuItem.tsx create mode 100644 src/layout/components/Menu/src/components/useRenderMenuTitle.tsx create mode 100644 src/layout/components/Menu/src/helper.ts create mode 100644 src/layout/components/Message/index.ts create mode 100644 src/layout/components/Message/src/Message.vue create mode 100644 src/layout/components/Screenfull/index.ts create mode 100644 src/layout/components/Screenfull/src/Screenfull.vue create mode 100644 src/layout/components/Setting/index.ts create mode 100644 src/layout/components/Setting/src/Setting.vue create mode 100644 src/layout/components/Setting/src/components/ColorRadioPicker.vue create mode 100644 src/layout/components/Setting/src/components/InterfaceDisplay.vue create mode 100644 src/layout/components/Setting/src/components/LayoutRadioPicker.vue create mode 100644 src/layout/components/SizeDropdown/index.ts create mode 100644 src/layout/components/SizeDropdown/src/SizeDropdown.vue create mode 100644 src/layout/components/TabMenu/index.ts create mode 100644 src/layout/components/TabMenu/src/TabMenu.vue create mode 100644 src/layout/components/TabMenu/src/helper.ts create mode 100644 src/layout/components/TagsView/index.ts create mode 100644 src/layout/components/TagsView/src/TagsView.vue create mode 100644 src/layout/components/TagsView/src/helper.ts create mode 100644 src/layout/components/ThemeSwitch/index.ts create mode 100644 src/layout/components/ThemeSwitch/src/ThemeSwitch.vue create mode 100644 src/layout/components/ToolHeader.vue create mode 100644 src/layout/components/UserInfo/index.ts create mode 100644 src/layout/components/UserInfo/src/UserInfo.vue create mode 100644 src/layout/components/UserInfo/src/components/LockDialog.vue create mode 100644 src/layout/components/UserInfo/src/components/LockPage.vue create mode 100644 src/layout/components/useRenderLayout.tsx create mode 100644 src/locales/en.ts create mode 100644 src/locales/zh-CN.ts create mode 100644 src/main.ts create mode 100644 src/permission.ts create mode 100644 src/plugins/animate.css/index.ts create mode 100644 src/plugins/echarts/index.ts create mode 100644 src/plugins/elementPlus/index.ts create mode 100644 src/plugins/formCreate/index.ts create mode 100644 src/plugins/svgIcon/index.ts create mode 100644 src/plugins/tongji/index.ts create mode 100644 src/plugins/unocss/index.ts create mode 100644 src/plugins/vueI18n/helper.ts create mode 100644 src/plugins/vueI18n/index.ts create mode 100644 src/router/index.ts create mode 100644 src/router/modules/remaining.ts create mode 100644 src/store/index.ts create mode 100644 src/store/modules/app.ts create mode 100644 src/store/modules/dict.ts create mode 100644 src/store/modules/locale.ts create mode 100644 src/store/modules/lock.ts create mode 100644 src/store/modules/permission.ts create mode 100644 src/store/modules/simpleWorkflow.ts create mode 100644 src/store/modules/tagsView.ts create mode 100644 src/store/modules/user.ts create mode 100644 src/styles/FormCreate/fonts/fontello.woff create mode 100644 src/styles/FormCreate/index.scss create mode 100644 src/styles/global.module.scss create mode 100644 src/styles/index.scss create mode 100644 src/styles/theme.scss create mode 100644 src/styles/var.css create mode 100644 src/styles/variables.scss create mode 100644 src/types/components.d.ts create mode 100644 src/types/configGlobal.d.ts create mode 100644 src/types/contextMenu.d.ts create mode 100644 src/types/descriptions.d.ts create mode 100644 src/types/elementPlus.d.ts create mode 100644 src/types/form.d.ts create mode 100644 src/types/icon.d.ts create mode 100644 src/types/infoTip.d.ts create mode 100644 src/types/layout.d.ts create mode 100644 src/types/localeDropdown.d.ts create mode 100644 src/types/qrcode.d.ts create mode 100644 src/types/table.d.ts create mode 100644 src/types/theme.d.ts create mode 100644 src/utils/Logger.ts create mode 100644 src/utils/auth.ts create mode 100644 src/utils/color.ts create mode 100644 src/utils/constants.ts create mode 100644 src/utils/dateUtil.ts create mode 100644 src/utils/dict.ts create mode 100644 src/utils/domUtils.ts create mode 100644 src/utils/download.ts create mode 100644 src/utils/filt.ts create mode 100644 src/utils/formCreate.ts create mode 100644 src/utils/formRules.ts create mode 100644 src/utils/formatTime.ts create mode 100644 src/utils/formatter.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/is.ts create mode 100644 src/utils/jsencrypt.ts create mode 100644 src/utils/permission.ts create mode 100644 src/utils/propTypes.ts create mode 100644 src/utils/routerHelper.ts create mode 100644 src/utils/tree.ts create mode 100644 src/utils/tsxHelper.ts create mode 100644 src/views/Error/403.vue create mode 100644 src/views/Error/404.vue create mode 100644 src/views/Error/500.vue create mode 100644 src/views/Home/Index.vue create mode 100644 src/views/Home/Index2.vue create mode 100644 src/views/Home/echarts-data.ts create mode 100644 src/views/Home/types.ts create mode 100644 src/views/Login/Login.vue create mode 100644 src/views/Login/SocialLogin.vue create mode 100644 src/views/Login/components/LoginForm.vue create mode 100644 src/views/Login/components/LoginFormTitle.vue create mode 100644 src/views/Login/components/MobileForm.vue create mode 100644 src/views/Login/components/QrCodeForm.vue create mode 100644 src/views/Login/components/RegisterForm.vue create mode 100644 src/views/Login/components/SSOLogin.vue create mode 100644 src/views/Login/components/index.ts create mode 100644 src/views/Login/components/useLogin.ts create mode 100644 src/views/Profile/Index.vue create mode 100644 src/views/Profile/components/BasicInfo.vue create mode 100644 src/views/Profile/components/ProfileUser.vue create mode 100644 src/views/Profile/components/ResetPwd.vue create mode 100644 src/views/Profile/components/UserAvatar.vue create mode 100644 src/views/Profile/components/UserSocial.vue create mode 100644 src/views/Profile/components/index.ts create mode 100644 src/views/Redirect/Redirect.vue create mode 100644 src/views/ai/chat/index/components/conversation/ConversationList.vue create mode 100644 src/views/ai/chat/index/components/conversation/ConversationUpdateForm.vue create mode 100644 src/views/ai/chat/index/components/message/MessageList.vue create mode 100644 src/views/ai/chat/index/components/message/MessageListEmpty.vue create mode 100644 src/views/ai/chat/index/components/message/MessageLoading.vue create mode 100644 src/views/ai/chat/index/components/message/MessageNewConversation.vue create mode 100644 src/views/ai/chat/index/components/role/RoleCategoryList.vue create mode 100644 src/views/ai/chat/index/components/role/RoleHeader.vue create mode 100644 src/views/ai/chat/index/components/role/RoleList.vue create mode 100644 src/views/ai/chat/index/components/role/RoleRepository.vue create mode 100644 src/views/ai/chat/index/index.vue create mode 100644 src/views/ai/chat/manager/ChatConversationList.vue create mode 100644 src/views/ai/chat/manager/ChatMessageList.vue create mode 100644 src/views/ai/chat/manager/index.vue create mode 100644 src/views/ai/image/index/components/ImageCard.vue create mode 100644 src/views/ai/image/index/components/ImageDetail.vue create mode 100644 src/views/ai/image/index/components/ImageList.vue create mode 100644 src/views/ai/image/index/components/dall3/index.vue create mode 100644 src/views/ai/image/index/components/midjourney/index.vue create mode 100644 src/views/ai/image/index/components/other/index.vue create mode 100644 src/views/ai/image/index/components/stableDiffusion/index.vue create mode 100644 src/views/ai/image/index/index.vue create mode 100644 src/views/ai/image/manager/index.vue create mode 100644 src/views/ai/image/square/index.vue create mode 100644 src/views/ai/mindmap/index/components/Left.vue create mode 100644 src/views/ai/mindmap/index/components/Right.vue create mode 100644 src/views/ai/mindmap/index/index.vue create mode 100644 src/views/ai/mindmap/manager/index.vue create mode 100644 src/views/ai/model/apiKey/ApiKeyForm.vue create mode 100644 src/views/ai/model/apiKey/index.vue create mode 100644 src/views/ai/model/chatModel/ChatModelForm.vue create mode 100644 src/views/ai/model/chatModel/index.vue create mode 100644 src/views/ai/model/chatRole/ChatRoleForm.vue create mode 100644 src/views/ai/model/chatRole/index.vue create mode 100644 src/views/ai/music/index/index.vue create mode 100644 src/views/ai/music/index/list/audioBar/index.vue create mode 100644 src/views/ai/music/index/list/index.vue create mode 100644 src/views/ai/music/index/list/songCard/index.vue create mode 100644 src/views/ai/music/index/list/songInfo/index.vue create mode 100644 src/views/ai/music/index/mode/desc.vue create mode 100644 src/views/ai/music/index/mode/index.vue create mode 100644 src/views/ai/music/index/mode/lyric.vue create mode 100644 src/views/ai/music/index/title/index.vue create mode 100644 src/views/ai/music/manager/index.vue create mode 100644 src/views/ai/utils/constants.ts create mode 100644 src/views/ai/utils/utils.ts create mode 100644 src/views/ai/write/index/components/Left.vue create mode 100644 src/views/ai/write/index/components/Right.vue create mode 100644 src/views/ai/write/index/components/Tag.vue create mode 100644 src/views/ai/write/index/index.vue create mode 100644 src/views/ai/write/manager/index.vue create mode 100644 src/views/bpm/category/CategoryForm.vue create mode 100644 src/views/bpm/category/index.vue create mode 100644 src/views/bpm/definition/index.vue create mode 100644 src/views/bpm/form/editor/index.vue create mode 100644 src/views/bpm/form/index.vue create mode 100644 src/views/bpm/group/UserGroupForm.vue create mode 100644 src/views/bpm/group/index.vue create mode 100644 src/views/bpm/model/ModelForm.vue create mode 100644 src/views/bpm/model/ModelImportForm.vue create mode 100644 src/views/bpm/model/editor/index.vue create mode 100644 src/views/bpm/model/index.vue create mode 100644 src/views/bpm/oa/leave/create.vue create mode 100644 src/views/bpm/oa/leave/detail.vue create mode 100644 src/views/bpm/oa/leave/index.vue create mode 100644 src/views/bpm/processExpression/ProcessExpressionForm.vue create mode 100644 src/views/bpm/processExpression/index.vue create mode 100644 src/views/bpm/processInstance/create/index.vue create mode 100644 src/views/bpm/processInstance/detail/ProcessInstanceBpmnViewer.vue create mode 100644 src/views/bpm/processInstance/detail/ProcessInstanceTaskList.vue create mode 100644 src/views/bpm/processInstance/detail/dialog/TaskDelegateForm.vue create mode 100644 src/views/bpm/processInstance/detail/dialog/TaskReturnForm.vue create mode 100644 src/views/bpm/processInstance/detail/dialog/TaskSignCreateForm.vue create mode 100644 src/views/bpm/processInstance/detail/dialog/TaskSignDeleteForm.vue create mode 100644 src/views/bpm/processInstance/detail/dialog/TaskSignList.vue create mode 100644 src/views/bpm/processInstance/detail/dialog/TaskTransferForm.vue create mode 100644 src/views/bpm/processInstance/detail/index.vue create mode 100644 src/views/bpm/processInstance/index.vue create mode 100644 src/views/bpm/processInstance/manager/index.vue create mode 100644 src/views/bpm/processListener/ProcessListenerForm.vue create mode 100644 src/views/bpm/processListener/index.vue create mode 100644 src/views/bpm/simpleWorkflow/index.vue create mode 100644 src/views/bpm/task/copy/index.vue create mode 100644 src/views/bpm/task/done/index.vue create mode 100644 src/views/bpm/task/manager/index.vue create mode 100644 src/views/bpm/task/todo/index.vue create mode 100644 src/views/crm/backlog/components/ClueFollowList.vue create mode 100644 src/views/crm/backlog/components/ContractAuditList.vue create mode 100644 src/views/crm/backlog/components/ContractRemindList.vue create mode 100644 src/views/crm/backlog/components/CustomerFollowList.vue create mode 100644 src/views/crm/backlog/components/CustomerPutPoolRemindList.vue create mode 100644 src/views/crm/backlog/components/CustomerTodayContactList.vue create mode 100644 src/views/crm/backlog/components/ReceivableAuditList.vue create mode 100644 src/views/crm/backlog/components/ReceivablePlanRemindList.vue create mode 100644 src/views/crm/backlog/components/common.ts create mode 100644 src/views/crm/backlog/index.vue create mode 100644 src/views/crm/business/BusinessForm.vue create mode 100644 src/views/crm/business/BusinessUpdateStatusForm.vue create mode 100644 src/views/crm/business/components/BusinessList.vue create mode 100644 src/views/crm/business/components/BusinessListModal.vue create mode 100644 src/views/crm/business/components/BusinessProductForm.vue create mode 100644 src/views/crm/business/detail/BusinessDetailsHeader.vue create mode 100644 src/views/crm/business/detail/BusinessDetailsInfo.vue create mode 100644 src/views/crm/business/detail/BusinessProductList.vue create mode 100644 src/views/crm/business/detail/index.vue create mode 100644 src/views/crm/business/index.vue create mode 100644 src/views/crm/business/status/BusinessStatusForm.vue create mode 100644 src/views/crm/business/status/index.vue create mode 100644 src/views/crm/clue/ClueForm.vue create mode 100644 src/views/crm/clue/detail/ClueDetailsHeader.vue create mode 100644 src/views/crm/clue/detail/ClueDetailsInfo.vue create mode 100644 src/views/crm/clue/detail/index.vue create mode 100644 src/views/crm/clue/index.vue create mode 100644 src/views/crm/contact/ContactForm.vue create mode 100644 src/views/crm/contact/components/ContactList.vue create mode 100644 src/views/crm/contact/components/ContactListModal.vue create mode 100644 src/views/crm/contact/detail/ContactDetailsHeader.vue create mode 100644 src/views/crm/contact/detail/ContactDetailsInfo.vue create mode 100644 src/views/crm/contact/detail/index.vue create mode 100644 src/views/crm/contact/index.vue create mode 100644 src/views/crm/contract/ContractForm.vue create mode 100644 src/views/crm/contract/components/ContractList.vue create mode 100644 src/views/crm/contract/components/ContractProductForm.vue create mode 100644 src/views/crm/contract/config/index.vue create mode 100644 src/views/crm/contract/detail/ContractDetailsHeader.vue create mode 100644 src/views/crm/contract/detail/ContractDetailsInfo.vue create mode 100644 src/views/crm/contract/detail/ContractProductList.vue create mode 100644 src/views/crm/contract/detail/index.vue create mode 100644 src/views/crm/contract/index.vue create mode 100644 src/views/crm/customer/CustomerForm.vue create mode 100644 src/views/crm/customer/CustomerImportForm.vue create mode 100644 src/views/crm/customer/detail/CustomerDetailsHeader.vue create mode 100644 src/views/crm/customer/detail/CustomerDetailsInfo.vue create mode 100644 src/views/crm/customer/detail/index.vue create mode 100644 src/views/crm/customer/index.vue create mode 100644 src/views/crm/customer/limitConfig/CustomerLimitConfigForm.vue create mode 100644 src/views/crm/customer/limitConfig/CustomerLimitConfigList.vue create mode 100644 src/views/crm/customer/limitConfig/index.vue create mode 100644 src/views/crm/customer/pool/CustomerDistributeForm.vue create mode 100644 src/views/crm/customer/pool/index.vue create mode 100644 src/views/crm/customer/poolConfig/index.vue create mode 100644 src/views/crm/followup/FollowUpRecordForm.vue create mode 100644 src/views/crm/followup/components/FollowUpRecordBusinessForm.vue create mode 100644 src/views/crm/followup/components/FollowUpRecordContactForm.vue create mode 100644 src/views/crm/followup/index.vue create mode 100644 src/views/crm/permission/components/PermissionForm.vue create mode 100644 src/views/crm/permission/components/PermissionList.vue create mode 100644 src/views/crm/permission/components/TransferForm.vue create mode 100644 src/views/crm/product/ProductForm.vue create mode 100644 src/views/crm/product/category/ProductCategoryForm.vue create mode 100644 src/views/crm/product/category/index.vue create mode 100644 src/views/crm/product/detail/ProductDetailsHeader.vue create mode 100644 src/views/crm/product/detail/ProductDetailsInfo.vue create mode 100644 src/views/crm/product/detail/index.vue create mode 100644 src/views/crm/product/index.vue create mode 100644 src/views/crm/receivable/ReceivableForm.vue create mode 100644 src/views/crm/receivable/components/ReceivableList.vue create mode 100644 src/views/crm/receivable/detail/ReceivableDetailsHeader.vue create mode 100644 src/views/crm/receivable/detail/ReceivableDetailsInfo.vue create mode 100644 src/views/crm/receivable/detail/index.vue create mode 100644 src/views/crm/receivable/index.vue create mode 100644 src/views/crm/receivable/plan/ReceivablePlanForm.vue create mode 100644 src/views/crm/receivable/plan/components/ReceivablePlanList.vue create mode 100644 src/views/crm/receivable/plan/detail/ReceivablePlanDetailsHeader.vue create mode 100644 src/views/crm/receivable/plan/detail/ReceivablePlanDetailsInfo.vue create mode 100644 src/views/crm/receivable/plan/detail/index.vue create mode 100644 src/views/crm/receivable/plan/index.vue create mode 100644 src/views/crm/statistics/customer/components/CustomerConversionStat.vue create mode 100644 src/views/crm/statistics/customer/components/CustomerDealCycleByArea.vue create mode 100644 src/views/crm/statistics/customer/components/CustomerDealCycleByProduct.vue create mode 100644 src/views/crm/statistics/customer/components/CustomerDealCycleByUser.vue create mode 100644 src/views/crm/statistics/customer/components/CustomerFollowUpSummary.vue create mode 100644 src/views/crm/statistics/customer/components/CustomerFollowUpType.vue create mode 100644 src/views/crm/statistics/customer/components/CustomerPoolSummary.vue create mode 100644 src/views/crm/statistics/customer/components/CustomerSummary.vue create mode 100644 src/views/crm/statistics/customer/index.vue create mode 100644 src/views/crm/statistics/funnel/components/BusinessInversionRateSummary.vue create mode 100644 src/views/crm/statistics/funnel/components/BusinessSummary.vue create mode 100644 src/views/crm/statistics/funnel/components/FunnelBusiness.vue create mode 100644 src/views/crm/statistics/funnel/index.vue create mode 100644 src/views/crm/statistics/performance/components/ContractCountPerformance.vue create mode 100644 src/views/crm/statistics/performance/components/ContractPricePerformance.vue create mode 100644 src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue create mode 100644 src/views/crm/statistics/performance/index.vue create mode 100644 src/views/crm/statistics/portrait/components/PortraitCustomerArea.vue create mode 100644 src/views/crm/statistics/portrait/components/PortraitCustomerIndustry.vue create mode 100644 src/views/crm/statistics/portrait/components/PortraitCustomerLevel.vue create mode 100644 src/views/crm/statistics/portrait/components/PortraitCustomerSource.vue create mode 100644 src/views/crm/statistics/portrait/index.vue create mode 100644 src/views/crm/statistics/rank/components/ContactCountRank.vue create mode 100644 src/views/crm/statistics/rank/components/ContractCountRank.vue create mode 100644 src/views/crm/statistics/rank/components/ContractPriceRank.vue create mode 100644 src/views/crm/statistics/rank/components/CustomerCountRank.vue create mode 100644 src/views/crm/statistics/rank/components/FollowCountRank.vue create mode 100644 src/views/crm/statistics/rank/components/FollowCustomerCountRank.vue create mode 100644 src/views/crm/statistics/rank/components/ProductSalesRank.vue create mode 100644 src/views/crm/statistics/rank/components/ReceivablePriceRank.vue create mode 100644 src/views/crm/statistics/rank/index.vue create mode 100644 src/views/erp/finance/account/AccountForm.vue create mode 100644 src/views/erp/finance/account/index.vue create mode 100644 src/views/erp/finance/payment/FinancePaymentForm.vue create mode 100644 src/views/erp/finance/payment/components/FinancePaymentItemForm.vue create mode 100644 src/views/erp/finance/payment/index.vue create mode 100644 src/views/erp/finance/receipt/FinanceReceiptForm.vue create mode 100644 src/views/erp/finance/receipt/components/FinanceReceiptItemForm.vue create mode 100644 src/views/erp/finance/receipt/index.vue create mode 100644 src/views/erp/home/components/SummaryCard.vue create mode 100644 src/views/erp/home/components/TimeSummaryChart.vue create mode 100644 src/views/erp/home/index.vue create mode 100644 src/views/erp/product/category/ProductCategoryForm.vue create mode 100644 src/views/erp/product/category/index.vue create mode 100644 src/views/erp/product/product/ProductForm.vue create mode 100644 src/views/erp/product/product/index.vue create mode 100644 src/views/erp/product/unit/ProductUnitForm.vue create mode 100644 src/views/erp/product/unit/index.vue create mode 100644 src/views/erp/purchase/in/PurchaseInForm.vue create mode 100644 src/views/erp/purchase/in/components/PurchaseInItemForm.vue create mode 100644 src/views/erp/purchase/in/components/PurchaseInPaymentEnableList.vue create mode 100644 src/views/erp/purchase/in/index.vue create mode 100644 src/views/erp/purchase/order/PurchaseOrderForm.vue create mode 100644 src/views/erp/purchase/order/components/PurchaseOrderInEnableList.vue create mode 100644 src/views/erp/purchase/order/components/PurchaseOrderItemForm.vue create mode 100644 src/views/erp/purchase/order/components/PurchaseOrderReturnEnableList.vue create mode 100644 src/views/erp/purchase/order/index.vue create mode 100644 src/views/erp/purchase/return/PurchaseReturnForm.vue create mode 100644 src/views/erp/purchase/return/components/PurchaseReturnItemForm.vue create mode 100644 src/views/erp/purchase/return/components/PurchaseReturnRefundEnableList.vue create mode 100644 src/views/erp/purchase/return/index.vue create mode 100644 src/views/erp/purchase/supplier/SupplierForm.vue create mode 100644 src/views/erp/purchase/supplier/index.vue create mode 100644 src/views/erp/sale/customer/CustomerForm.vue create mode 100644 src/views/erp/sale/customer/index.vue create mode 100644 src/views/erp/sale/order/SaleOrderForm.vue create mode 100644 src/views/erp/sale/order/components/SaleOrderItemForm.vue create mode 100644 src/views/erp/sale/order/components/SaleOrderOutEnableList.vue create mode 100644 src/views/erp/sale/order/components/SaleOrderReturnEnableList.vue create mode 100644 src/views/erp/sale/order/index.vue create mode 100644 src/views/erp/sale/out/SaleOutForm.vue create mode 100644 src/views/erp/sale/out/components/SaleOutItemForm.vue create mode 100644 src/views/erp/sale/out/components/SaleOutReceiptEnableList.vue create mode 100644 src/views/erp/sale/out/index.vue create mode 100644 src/views/erp/sale/return/SaleReturnForm.vue create mode 100644 src/views/erp/sale/return/components/SaleReturnItemForm.vue create mode 100644 src/views/erp/sale/return/components/SaleReturnRefundEnableList.vue create mode 100644 src/views/erp/sale/return/index.vue create mode 100644 src/views/erp/stock/check/StockCheckForm.vue create mode 100644 src/views/erp/stock/check/components/StockCheckItemForm.vue create mode 100644 src/views/erp/stock/check/index.vue create mode 100644 src/views/erp/stock/in/StockInForm.vue create mode 100644 src/views/erp/stock/in/components/StockInItemForm.vue create mode 100644 src/views/erp/stock/in/index.vue create mode 100644 src/views/erp/stock/move/StockMoveForm.vue create mode 100644 src/views/erp/stock/move/components/StockMoveItemForm.vue create mode 100644 src/views/erp/stock/move/index.vue create mode 100644 src/views/erp/stock/out/StockOutForm.vue create mode 100644 src/views/erp/stock/out/components/StockOutItemForm.vue create mode 100644 src/views/erp/stock/out/index.vue create mode 100644 src/views/erp/stock/record/index.vue create mode 100644 src/views/erp/stock/stock/index.vue create mode 100644 src/views/erp/stock/warehouse/WarehouseForm.vue create mode 100644 src/views/erp/stock/warehouse/index.vue create mode 100644 src/views/infra/apiAccessLog/ApiAccessLogDetail.vue create mode 100644 src/views/infra/apiAccessLog/index.vue create mode 100644 src/views/infra/apiErrorLog/ApiErrorLogDetail.vue create mode 100644 src/views/infra/apiErrorLog/index.vue create mode 100644 src/views/infra/build/index.vue create mode 100644 src/views/infra/codegen/EditTable.vue create mode 100644 src/views/infra/codegen/ImportTable.vue create mode 100644 src/views/infra/codegen/PreviewCode.vue create mode 100644 src/views/infra/codegen/components/BasicInfoForm.vue create mode 100644 src/views/infra/codegen/components/ColumInfoForm.vue create mode 100644 src/views/infra/codegen/components/GenerateInfoForm.vue create mode 100644 src/views/infra/codegen/components/index.ts create mode 100644 src/views/infra/codegen/index.vue create mode 100644 src/views/infra/config/ConfigForm.vue create mode 100644 src/views/infra/config/index.vue create mode 100644 src/views/infra/dataSourceConfig/DataSourceConfigForm.vue create mode 100644 src/views/infra/dataSourceConfig/index.vue create mode 100644 src/views/infra/demo/demo01/Demo01ContactForm.vue create mode 100644 src/views/infra/demo/demo01/index.vue create mode 100644 src/views/infra/demo/demo02/Demo02CategoryForm.vue create mode 100644 src/views/infra/demo/demo02/index.vue create mode 100644 src/views/infra/demo/demo03/erp/Demo03StudentForm.vue create mode 100644 src/views/infra/demo/demo03/erp/components/Demo03CourseForm.vue create mode 100644 src/views/infra/demo/demo03/erp/components/Demo03CourseList.vue create mode 100644 src/views/infra/demo/demo03/erp/components/Demo03GradeForm.vue create mode 100644 src/views/infra/demo/demo03/erp/components/Demo03GradeList.vue create mode 100644 src/views/infra/demo/demo03/erp/index.vue create mode 100644 src/views/infra/demo/demo03/inner/Demo03StudentForm.vue create mode 100644 src/views/infra/demo/demo03/inner/components/Demo03CourseForm.vue create mode 100644 src/views/infra/demo/demo03/inner/components/Demo03CourseList.vue create mode 100644 src/views/infra/demo/demo03/inner/components/Demo03GradeForm.vue create mode 100644 src/views/infra/demo/demo03/inner/components/Demo03GradeList.vue create mode 100644 src/views/infra/demo/demo03/inner/index.vue create mode 100644 src/views/infra/demo/demo03/normal/Demo03StudentForm.vue create mode 100644 src/views/infra/demo/demo03/normal/components/Demo03CourseForm.vue create mode 100644 src/views/infra/demo/demo03/normal/components/Demo03GradeForm.vue create mode 100644 src/views/infra/demo/demo03/normal/index.vue create mode 100644 src/views/infra/druid/index.vue create mode 100644 src/views/infra/file/FileForm.vue create mode 100644 src/views/infra/file/index.vue create mode 100644 src/views/infra/fileConfig/FileConfigForm.vue create mode 100644 src/views/infra/fileConfig/index.vue create mode 100644 src/views/infra/job/JobDetail.vue create mode 100644 src/views/infra/job/JobForm.vue create mode 100644 src/views/infra/job/index.vue create mode 100644 src/views/infra/job/logger/JobLogDetail.vue create mode 100644 src/views/infra/job/logger/index.vue create mode 100644 src/views/infra/redis/index.vue create mode 100644 src/views/infra/server/index.vue create mode 100644 src/views/infra/skywalking/index.vue create mode 100644 src/views/infra/swagger/index.vue create mode 100644 src/views/infra/webSocket/index.vue create mode 100644 src/views/mall/home/components/ComparisonCard.vue create mode 100644 src/views/mall/home/components/MemberStatisticsCard.vue create mode 100644 src/views/mall/home/components/OperationDataCard.vue create mode 100644 src/views/mall/home/components/ShortcutCard.vue create mode 100644 src/views/mall/home/components/TradeTrendCard.vue create mode 100644 src/views/mall/home/index.vue create mode 100644 src/views/mall/product/brand/BrandForm.vue create mode 100644 src/views/mall/product/brand/index.vue create mode 100644 src/views/mall/product/category/CategoryForm.vue create mode 100644 src/views/mall/product/category/components/ProductCategorySelect.vue create mode 100644 src/views/mall/product/category/index.vue create mode 100644 src/views/mall/product/comment/CommentForm.vue create mode 100644 src/views/mall/product/comment/ReplyForm.vue create mode 100644 src/views/mall/product/comment/index.vue create mode 100644 src/views/mall/product/property/PropertyForm.vue create mode 100644 src/views/mall/product/property/index.vue create mode 100644 src/views/mall/product/property/value/ValueForm.vue create mode 100644 src/views/mall/product/property/value/index.vue create mode 100644 src/views/mall/product/spu/components/SkuList.vue create mode 100644 src/views/mall/product/spu/components/SkuTableSelect.vue create mode 100644 src/views/mall/product/spu/components/SpuShowcase.vue create mode 100644 src/views/mall/product/spu/components/SpuTableSelect.vue create mode 100644 src/views/mall/product/spu/components/index.ts create mode 100644 src/views/mall/product/spu/form/DeliveryForm.vue create mode 100644 src/views/mall/product/spu/form/DescriptionForm.vue create mode 100644 src/views/mall/product/spu/form/InfoForm.vue create mode 100644 src/views/mall/product/spu/form/OtherForm.vue create mode 100644 src/views/mall/product/spu/form/ProductAttributes.vue create mode 100644 src/views/mall/product/spu/form/ProductPropertyAddForm.vue create mode 100644 src/views/mall/product/spu/form/SkuForm.vue create mode 100644 src/views/mall/product/spu/form/index.vue create mode 100644 src/views/mall/product/spu/index.vue create mode 100644 src/views/mall/promotion/article/ArticleForm.vue create mode 100644 src/views/mall/promotion/article/category/ArticleCategoryForm.vue create mode 100644 src/views/mall/promotion/article/category/index.vue create mode 100644 src/views/mall/promotion/article/index.vue create mode 100644 src/views/mall/promotion/banner/BannerForm.vue create mode 100644 src/views/mall/promotion/banner/index.vue create mode 100644 src/views/mall/promotion/bargain/activity/BargainActivityForm.vue create mode 100644 src/views/mall/promotion/bargain/activity/bargainActivity.data.ts create mode 100644 src/views/mall/promotion/bargain/activity/index.vue create mode 100644 src/views/mall/promotion/bargain/record/BargainRecordListDialog.vue create mode 100644 src/views/mall/promotion/bargain/record/index.vue create mode 100644 src/views/mall/promotion/combination/activity/CombinationActivityForm.vue create mode 100644 src/views/mall/promotion/combination/activity/combinationActivity.data.ts create mode 100644 src/views/mall/promotion/combination/activity/index.vue create mode 100644 src/views/mall/promotion/combination/record/CombinationRecordListDialog.vue create mode 100644 src/views/mall/promotion/combination/record/index.vue create mode 100644 src/views/mall/promotion/components/SpuAndSkuList.vue create mode 100644 src/views/mall/promotion/components/SpuSelect.vue create mode 100644 src/views/mall/promotion/components/index.ts create mode 100644 src/views/mall/promotion/coupon/components/CouponSelect.vue create mode 100644 src/views/mall/promotion/coupon/components/CouponSendForm.vue create mode 100644 src/views/mall/promotion/coupon/components/index.ts create mode 100644 src/views/mall/promotion/coupon/formatter.ts create mode 100755 src/views/mall/promotion/coupon/index.vue create mode 100644 src/views/mall/promotion/coupon/template/CouponTemplateForm.vue create mode 100755 src/views/mall/promotion/coupon/template/index.vue create mode 100644 src/views/mall/promotion/discountActivity/DiscountActivityForm.vue create mode 100644 src/views/mall/promotion/discountActivity/discountActivity.data.ts create mode 100644 src/views/mall/promotion/discountActivity/index.vue create mode 100644 src/views/mall/promotion/diy/page/DiyPageForm.vue create mode 100644 src/views/mall/promotion/diy/page/decorate.vue create mode 100644 src/views/mall/promotion/diy/page/index.vue create mode 100644 src/views/mall/promotion/diy/template/DiyTemplateForm.vue create mode 100644 src/views/mall/promotion/diy/template/decorate.vue create mode 100644 src/views/mall/promotion/diy/template/index.vue create mode 100644 src/views/mall/promotion/kefu/components/KeFuConversationList.vue create mode 100644 src/views/mall/promotion/kefu/components/KeFuMessageList.vue create mode 100644 src/views/mall/promotion/kefu/components/asserts/a.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/aini.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/aixin.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/baiyan.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/bizui.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/buhaoyisi.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/bukesiyi.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/dajing.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/danao.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/daxiao.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/dianzan.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/emo.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/esi.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/fadai.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/fankun.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/feiwen.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/fennu.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/ganga.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/ganmao.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/hanyan.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/haochi.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/hongxin.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/huaixiao.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/jingkong.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/jingshu.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/jingya.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/kaixin.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/keai.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/keshui.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/kun.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/lengku.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/liuhan.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/liukoushui.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/liulei.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/mengbi.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/mianwubiaoqing.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/nanguo.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/outu.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/picture.svg create mode 100644 src/views/mall/promotion/kefu/components/asserts/shengqi.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/shuizhuo.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/tianshi.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/xiaodiaoya.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/xiaoku.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/xinsui.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/xiong.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/yiwen.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/yun.png create mode 100644 src/views/mall/promotion/kefu/components/asserts/ziya.png create mode 100644 src/views/mall/promotion/kefu/components/history/MemberBrowsingHistory.vue create mode 100644 src/views/mall/promotion/kefu/components/history/OrderBrowsingHistory.vue create mode 100644 src/views/mall/promotion/kefu/components/history/ProductBrowsingHistory.vue create mode 100644 src/views/mall/promotion/kefu/components/index.ts create mode 100644 src/views/mall/promotion/kefu/components/message/MessageItem.vue create mode 100644 src/views/mall/promotion/kefu/components/message/OrderItem.vue create mode 100644 src/views/mall/promotion/kefu/components/message/ProductItem.vue create mode 100644 src/views/mall/promotion/kefu/components/tools/EmojiSelectPopover.vue create mode 100644 src/views/mall/promotion/kefu/components/tools/PictureSelectUpload.vue create mode 100644 src/views/mall/promotion/kefu/components/tools/constants.ts create mode 100644 src/views/mall/promotion/kefu/components/tools/emoji.ts create mode 100644 src/views/mall/promotion/kefu/index.vue create mode 100644 src/views/mall/promotion/rewardActivity/RewardForm.vue create mode 100644 src/views/mall/promotion/rewardActivity/index.vue create mode 100644 src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue create mode 100644 src/views/mall/promotion/seckill/activity/index.vue create mode 100644 src/views/mall/promotion/seckill/activity/seckillActivity.data.ts create mode 100644 src/views/mall/promotion/seckill/config/SeckillConfigForm.vue create mode 100644 src/views/mall/promotion/seckill/config/index.vue create mode 100644 src/views/mall/statistics/member/components/MemberFunnelCard.vue create mode 100644 src/views/mall/statistics/member/components/MemberTerminalCard.vue create mode 100644 src/views/mall/statistics/member/index.vue create mode 100644 src/views/mall/statistics/product/components/ProductRank.vue create mode 100644 src/views/mall/statistics/product/components/ProductSummary.vue create mode 100644 src/views/mall/statistics/product/index.vue create mode 100644 src/views/mall/statistics/trade/components/TradeStatisticValue.vue create mode 100644 src/views/mall/statistics/trade/index.vue create mode 100644 src/views/mall/trade/afterSale/detail/index.vue create mode 100644 src/views/mall/trade/afterSale/form/AfterSaleDisagreeForm.vue create mode 100644 src/views/mall/trade/afterSale/index.vue create mode 100644 src/views/mall/trade/brokerage/record/index.vue create mode 100644 src/views/mall/trade/brokerage/user/BrokerageOrderListDialog.vue create mode 100644 src/views/mall/trade/brokerage/user/BrokerageUserListDialog.vue create mode 100644 src/views/mall/trade/brokerage/user/UpdateBindUserForm.vue create mode 100644 src/views/mall/trade/brokerage/user/index.vue create mode 100644 src/views/mall/trade/brokerage/withdraw/BrokerageWithdrawRejectForm.vue create mode 100644 src/views/mall/trade/brokerage/withdraw/index.vue create mode 100644 src/views/mall/trade/config/index.vue create mode 100644 src/views/mall/trade/delivery/express/ExpressForm.vue create mode 100644 src/views/mall/trade/delivery/express/index.vue create mode 100644 src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue create mode 100644 src/views/mall/trade/delivery/expressTemplate/index.vue create mode 100644 src/views/mall/trade/delivery/pickUpOrder/index.vue create mode 100644 src/views/mall/trade/delivery/pickUpStore/PickUpStoreForm.vue create mode 100644 src/views/mall/trade/delivery/pickUpStore/index.vue create mode 100644 src/views/mall/trade/order/components/OrderTableColumn.vue create mode 100644 src/views/mall/trade/order/components/index.ts create mode 100644 src/views/mall/trade/order/detail/index.vue create mode 100644 src/views/mall/trade/order/form/OrderDeliveryForm.vue create mode 100644 src/views/mall/trade/order/form/OrderPickUpForm.vue create mode 100644 src/views/mall/trade/order/form/OrderUpdateAddressForm.vue create mode 100644 src/views/mall/trade/order/form/OrderUpdatePriceForm.vue create mode 100644 src/views/mall/trade/order/form/OrderUpdateRemarkForm.vue create mode 100644 src/views/mall/trade/order/index.vue create mode 100644 src/views/member/config/index.vue create mode 100644 src/views/member/group/GroupForm.vue create mode 100644 src/views/member/group/components/MemberGroupSelect.vue create mode 100644 src/views/member/group/index.vue create mode 100644 src/views/member/level/LevelForm.vue create mode 100644 src/views/member/level/components/MemberLevelSelect.vue create mode 100644 src/views/member/level/index.vue create mode 100644 src/views/member/point/record/index.vue create mode 100644 src/views/member/signin/config/SignInConfigForm.vue create mode 100644 src/views/member/signin/config/index.vue create mode 100644 src/views/member/signin/record/index.vue create mode 100644 src/views/member/tag/TagForm.vue create mode 100644 src/views/member/tag/components/MemberTagSelect.vue create mode 100644 src/views/member/tag/index.vue create mode 100644 src/views/member/user/UserForm.vue create mode 100644 src/views/member/user/UserLevelUpdateForm.vue create mode 100644 src/views/member/user/UserPointUpdateForm.vue create mode 100644 src/views/member/user/components/balance-list.vue create mode 100644 src/views/member/user/detail/UserAccountInfo.vue create mode 100644 src/views/member/user/detail/UserAddressList.vue create mode 100644 src/views/member/user/detail/UserBasicInfo.vue create mode 100644 src/views/member/user/detail/UserBrokerageList.vue create mode 100644 src/views/member/user/detail/UserCouponList.vue create mode 100644 src/views/member/user/detail/UserExperienceRecordList.vue create mode 100644 src/views/member/user/detail/UserFavoriteList.vue create mode 100644 src/views/member/user/detail/UserOrderList.vue create mode 100644 src/views/member/user/detail/UserPointList.vue create mode 100644 src/views/member/user/detail/UserSignList.vue create mode 100644 src/views/member/user/detail/index.vue create mode 100644 src/views/member/user/index.vue create mode 100644 src/views/mp/account/AccountForm.vue create mode 100644 src/views/mp/account/index.vue create mode 100644 src/views/mp/autoReply/components/ReplyForm.vue create mode 100644 src/views/mp/autoReply/components/ReplyTable.vue create mode 100644 src/views/mp/autoReply/components/types.ts create mode 100644 src/views/mp/autoReply/index.vue create mode 100644 src/views/mp/components/wx-account-select/index.ts create mode 100644 src/views/mp/components/wx-account-select/main.vue create mode 100644 src/views/mp/components/wx-location/index.ts create mode 100644 src/views/mp/components/wx-location/main.vue create mode 100644 src/views/mp/components/wx-material-select/index.ts create mode 100644 src/views/mp/components/wx-material-select/main.vue create mode 100644 src/views/mp/components/wx-material-select/types.ts create mode 100644 src/views/mp/components/wx-msg/card.scss create mode 100644 src/views/mp/components/wx-msg/comment.scss create mode 100644 src/views/mp/components/wx-msg/components/Msg.vue create mode 100644 src/views/mp/components/wx-msg/components/MsgEvent.vue create mode 100644 src/views/mp/components/wx-msg/components/MsgList.vue create mode 100644 src/views/mp/components/wx-msg/index.ts create mode 100644 src/views/mp/components/wx-msg/main.vue create mode 100644 src/views/mp/components/wx-msg/types.ts create mode 100644 src/views/mp/components/wx-music/index.ts create mode 100644 src/views/mp/components/wx-music/main.vue create mode 100644 src/views/mp/components/wx-news/index.ts create mode 100644 src/views/mp/components/wx-news/main.vue create mode 100644 src/views/mp/components/wx-reply/components/TabImage.vue create mode 100644 src/views/mp/components/wx-reply/components/TabMusic.vue create mode 100644 src/views/mp/components/wx-reply/components/TabNews.vue create mode 100644 src/views/mp/components/wx-reply/components/TabText.vue create mode 100644 src/views/mp/components/wx-reply/components/TabVideo.vue create mode 100644 src/views/mp/components/wx-reply/components/TabVoice.vue create mode 100644 src/views/mp/components/wx-reply/components/types.ts create mode 100644 src/views/mp/components/wx-reply/index.ts create mode 100644 src/views/mp/components/wx-reply/main.vue create mode 100644 src/views/mp/components/wx-video-play/index.ts create mode 100644 src/views/mp/components/wx-video-play/main.vue create mode 100644 src/views/mp/components/wx-voice-play/index.ts create mode 100644 src/views/mp/components/wx-voice-play/main.vue create mode 100644 src/views/mp/draft/components/CoverSelect.vue create mode 100644 src/views/mp/draft/components/DraftTable.vue create mode 100644 src/views/mp/draft/components/NewsForm.vue create mode 100644 src/views/mp/draft/components/index.ts create mode 100644 src/views/mp/draft/components/types.ts create mode 100644 src/views/mp/draft/editor-config.ts create mode 100644 src/views/mp/draft/index.vue create mode 100644 src/views/mp/draft/mock.js create mode 100644 src/views/mp/freePublish/index.vue create mode 100644 src/views/mp/hooks/useUpload.ts create mode 100644 src/views/mp/material/components/ImageTable.vue create mode 100644 src/views/mp/material/components/UploadFile.vue create mode 100644 src/views/mp/material/components/UploadVideo.vue create mode 100644 src/views/mp/material/components/VideoTable.vue create mode 100644 src/views/mp/material/components/VoiceTable.vue create mode 100644 src/views/mp/material/components/upload.ts create mode 100644 src/views/mp/material/index.vue create mode 100644 src/views/mp/menu/assets/iphone_backImg.png create mode 100644 src/views/mp/menu/assets/menu_foot.png create mode 100644 src/views/mp/menu/assets/menu_head.png create mode 100644 src/views/mp/menu/components/MenuEditor.vue create mode 100644 src/views/mp/menu/components/MenuPreviewer.vue create mode 100644 src/views/mp/menu/components/menuOptions.ts create mode 100644 src/views/mp/menu/components/types.ts create mode 100644 src/views/mp/menu/index.vue create mode 100644 src/views/mp/message/MessageTable.vue create mode 100644 src/views/mp/message/index.vue create mode 100644 src/views/mp/statistics/index.vue create mode 100644 src/views/mp/tag/TagForm.vue create mode 100644 src/views/mp/tag/index.vue create mode 100644 src/views/mp/user/UserForm.vue create mode 100644 src/views/mp/user/index.vue create mode 100644 src/views/pay/app/components/AppForm.vue create mode 100644 src/views/pay/app/components/channel/AlipayChannelForm.vue create mode 100644 src/views/pay/app/components/channel/MockChannelForm.vue create mode 100644 src/views/pay/app/components/channel/WalletChannelForm.vue create mode 100644 src/views/pay/app/components/channel/WeixinChannelForm.vue create mode 100644 src/views/pay/app/index.vue create mode 100644 src/views/pay/cashier/index.vue create mode 100644 src/views/pay/demo/order/index.vue create mode 100644 src/views/pay/demo/transfer/DemoTransferForm.vue create mode 100644 src/views/pay/demo/transfer/index.vue create mode 100644 src/views/pay/notify/NotifyDetail.vue create mode 100644 src/views/pay/notify/index.vue create mode 100644 src/views/pay/order/OrderDetail.vue create mode 100644 src/views/pay/order/index.vue create mode 100644 src/views/pay/refund/RefundDetail.vue create mode 100644 src/views/pay/refund/index.vue create mode 100644 src/views/pay/transfer/CreatePayTransfer.vue create mode 100644 src/views/pay/transfer/TransferDetail.vue create mode 100644 src/views/pay/transfer/index.vue create mode 100644 src/views/pay/wallet/balance/WalletForm.vue create mode 100644 src/views/pay/wallet/balance/index.vue create mode 100644 src/views/pay/wallet/rechargePackage/WalletRechargePackageForm.vue create mode 100644 src/views/pay/wallet/rechargePackage/index.vue create mode 100644 src/views/pay/wallet/transaction/WalletTransactionList.vue create mode 100644 src/views/report/goview/index.vue create mode 100644 src/views/report/jmreport/index.vue create mode 100644 src/views/system/area/AreaForm.vue create mode 100644 src/views/system/area/index.vue create mode 100644 src/views/system/dept/DeptForm.vue create mode 100644 src/views/system/dept/index.vue create mode 100644 src/views/system/dict/DictTypeForm.vue create mode 100644 src/views/system/dict/data/DictDataForm.vue create mode 100644 src/views/system/dict/data/index.vue create mode 100644 src/views/system/dict/index.vue create mode 100644 src/views/system/loginlog/LoginLogDetail.vue create mode 100644 src/views/system/loginlog/index.vue create mode 100644 src/views/system/mail/account/MailAccountDetail.vue create mode 100644 src/views/system/mail/account/MailAccountForm.vue create mode 100644 src/views/system/mail/account/account.data.ts create mode 100644 src/views/system/mail/account/index.vue create mode 100644 src/views/system/mail/log/MailLogDetail.vue create mode 100644 src/views/system/mail/log/index.vue create mode 100644 src/views/system/mail/log/log.data.ts create mode 100644 src/views/system/mail/template/MailTemplateForm.vue create mode 100644 src/views/system/mail/template/MailTemplateSendForm.vue create mode 100644 src/views/system/mail/template/index.vue create mode 100644 src/views/system/mail/template/template.data.ts create mode 100644 src/views/system/menu/MenuForm.vue create mode 100644 src/views/system/menu/index.vue create mode 100644 src/views/system/notice/NoticeForm.vue create mode 100644 src/views/system/notice/index.vue create mode 100644 src/views/system/notify/message/NotifyMessageDetail.vue create mode 100644 src/views/system/notify/message/index.vue create mode 100644 src/views/system/notify/my/MyNotifyMessageDetail.vue create mode 100644 src/views/system/notify/my/index.vue create mode 100644 src/views/system/notify/template/NotifyTemplateForm.vue create mode 100644 src/views/system/notify/template/NotifyTemplateSendForm.vue create mode 100644 src/views/system/notify/template/index.vue create mode 100644 src/views/system/oauth2/client/ClientForm.vue create mode 100644 src/views/system/oauth2/client/index.vue create mode 100644 src/views/system/oauth2/token/index.vue create mode 100644 src/views/system/operatelog/OperateLogDetail.vue create mode 100644 src/views/system/operatelog/index.vue create mode 100644 src/views/system/post/PostForm.vue create mode 100644 src/views/system/post/index.vue create mode 100644 src/views/system/role/RoleAssignMenuForm.vue create mode 100644 src/views/system/role/RoleDataPermissionForm.vue create mode 100644 src/views/system/role/RoleForm.vue create mode 100644 src/views/system/role/index.vue create mode 100644 src/views/system/sms/channel/SmsChannelForm.vue create mode 100644 src/views/system/sms/channel/index.vue create mode 100644 src/views/system/sms/log/SmsLogDetail.vue create mode 100644 src/views/system/sms/log/index.vue create mode 100644 src/views/system/sms/template/SmsTemplateForm.vue create mode 100644 src/views/system/sms/template/SmsTemplateSendForm.vue create mode 100644 src/views/system/sms/template/index.vue create mode 100644 src/views/system/social/client/SocialClientForm.vue create mode 100644 src/views/system/social/client/index.vue create mode 100644 src/views/system/social/user/SocialUserDetail.vue create mode 100644 src/views/system/social/user/index.vue create mode 100644 src/views/system/tenant/TenantForm.vue create mode 100644 src/views/system/tenant/index.vue create mode 100644 src/views/system/tenantPackage/TenantPackageForm.vue create mode 100644 src/views/system/tenantPackage/index.vue create mode 100644 src/views/system/user/DeptTree.vue create mode 100644 src/views/system/user/UserAssignRoleForm.vue create mode 100644 src/views/system/user/UserForm.vue create mode 100644 src/views/system/user/UserImportForm.vue create mode 100644 src/views/system/user/index.vue create mode 100644 stylelint.config.js create mode 100644 tsconfig.json create mode 100644 types/components.d.ts create mode 100644 types/custom-types.d.ts create mode 100644 types/env.d.ts create mode 100644 types/global.d.ts create mode 100644 types/router.d.ts create mode 100644 uno.config.ts create mode 100644 vite.config.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..79a12ff --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true +[*.{js,ts,vue}] +charset = utf-8 # 设置文件字符集为 utf-8 +end_of_line = lf # 控制换行类型(lf | cr | crlf) +insert_final_newline = true # 始终在文件末尾插入一个新行 +indent_style = space # 缩进风格(tab | space) +indent_size = 2 # 缩进大小 +max_line_length = 100 # 最大行长度 + +[*.md] # 仅 md 文件适用以下规则 +max_line_length = off # 关闭最大行长度限制 +trim_trailing_whitespace = false # 关闭末尾空格修剪 diff --git a/.env b/.env new file mode 100644 index 0000000..4b0f5bf --- /dev/null +++ b/.env @@ -0,0 +1,25 @@ +# 标题 +VITE_APP_TITLE=芋道管理系统 + +# 项目本地运行端口号 +VITE_PORT=80 + +# open 运行 npm run dev 时自动打开浏览器 +VITE_OPEN=true + +# 租户开关 +VITE_APP_TENANT_ENABLE=true + +# 验证码的开关 +VITE_APP_CAPTCHA_ENABLE=true + +# 文档地址的开关 +VITE_APP_DOCALERT_ENABLE=true + +# 百度统计 +VITE_APP_BAIDU_CODE = a1ff8825baa73c3a78eb96aa40325abc + +# 默认账户密码 +VITE_APP_DEFAULT_LOGIN_TENANT = 芋道源码 +VITE_APP_DEFAULT_LOGIN_USERNAME = admin +VITE_APP_DEFAULT_LOGIN_PASSWORD = admin123 diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..45098c5 --- /dev/null +++ b/.env.dev @@ -0,0 +1,33 @@ +# 本地开发环境:本地启动所有项目(前端、后端、APP)时使用,不依赖外部环境 +NODE_ENV=development + +VITE_DEV=true + +# 请求路径 +VITE_BASE_URL='http://crm-api.sz-chaohui.cn' + +# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务 +VITE_UPLOAD_TYPE=server +# 上传路径 +VITE_UPLOAD_URL='http://crm-api.sz-chaohui.cn/admin-api/infra/file/upload' + +# 接口地址 +VITE_API_URL=/admin-api + +# 是否删除debugger +VITE_DROP_DEBUGGER=false + +# 是否删除console.log +VITE_DROP_CONSOLE=false + +# 是否sourcemap +VITE_SOURCEMAP=false + +# 打包路径 +VITE_BASE_PATH=/ + +# 商城H5会员端域名 +VITE_MALL_H5_DOMAIN='http://localhost:3000' + +# 验证码的开关 +VITE_APP_CAPTCHA_ENABLE=false diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..45098c5 --- /dev/null +++ b/.env.local @@ -0,0 +1,33 @@ +# 本地开发环境:本地启动所有项目(前端、后端、APP)时使用,不依赖外部环境 +NODE_ENV=development + +VITE_DEV=true + +# 请求路径 +VITE_BASE_URL='http://crm-api.sz-chaohui.cn' + +# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务 +VITE_UPLOAD_TYPE=server +# 上传路径 +VITE_UPLOAD_URL='http://crm-api.sz-chaohui.cn/admin-api/infra/file/upload' + +# 接口地址 +VITE_API_URL=/admin-api + +# 是否删除debugger +VITE_DROP_DEBUGGER=false + +# 是否删除console.log +VITE_DROP_CONSOLE=false + +# 是否sourcemap +VITE_SOURCEMAP=false + +# 打包路径 +VITE_BASE_PATH=/ + +# 商城H5会员端域名 +VITE_MALL_H5_DOMAIN='http://localhost:3000' + +# 验证码的开关 +VITE_APP_CAPTCHA_ENABLE=false diff --git a/.env.prod b/.env.prod new file mode 100644 index 0000000..842ba61 --- /dev/null +++ b/.env.prod @@ -0,0 +1,33 @@ +# 生产环境:只在打包时使用 +NODE_ENV=production + +VITE_DEV=false + +# 请求路径 +VITE_BASE_URL='http://localhost:48080' + +# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务 +VITE_UPLOAD_TYPE=server +# 上传路径 +VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload' + +# 接口地址 +VITE_API_URL=/admin-api + +# 是否删除debugger +VITE_DROP_DEBUGGER=true + +# 是否删除console.log +VITE_DROP_CONSOLE=true + +# 是否sourcemap +VITE_SOURCEMAP=false + +# 打包路径 +VITE_BASE_PATH=/ + +# 输出路径 +VITE_OUT_DIR=dist-prod + +# 商城H5会员端域名 +VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn' diff --git a/.env.stage b/.env.stage new file mode 100644 index 0000000..f7c521b --- /dev/null +++ b/.env.stage @@ -0,0 +1,33 @@ +# 预发布环境:只在打包时使用 +NODE_ENV=production + +VITE_DEV=false + +# 请求路径 +VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn' + +# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务 +VITE_UPLOAD_TYPE=server +# 上传路径 +VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload' + +# 接口地址 +VITE_API_URL=/admin-api + +# 是否删除debugger +VITE_DROP_DEBUGGER=true + +# 是否删除console.log +VITE_DROP_CONSOLE=true + +# 是否sourcemap +VITE_SOURCEMAP=false + +# 打包路径 +VITE_BASE_PATH='http://static-vue3.yudao.iocoder.cn/' + +# 输出路径 +VITE_OUT_DIR=dist-stage + +# 商城H5会员端域名 +VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn' diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..7bf1b41 --- /dev/null +++ b/.env.test @@ -0,0 +1,33 @@ +# 测试环境:只在打包时使用 +NODE_ENV=production + +VITE_DEV=false + +# 请求路径 +VITE_BASE_URL='http://localhost:48080' + +# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务 +VITE_UPLOAD_TYPE=server +# 上传路径 +VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload' + +# 接口地址 +VITE_API_URL=/admin-api + +# 是否删除debugger +VITE_DROP_DEBUGGER=true + +# 是否删除console.log +VITE_DROP_CONSOLE=true + +# 是否sourcemap +VITE_SOURCEMAP=false + +# 打包路径 +VITE_BASE_PATH=/admin-ui-vue3/ + +# 输出路径 +VITE_OUT_DIR=dist-test + +# 商城H5会员端域名 +VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn' diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..1e85c0f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,8 @@ +/build/ +/config/ +/dist/ +/*.js +/test/unit/coverage/ +/node_modules/* +/dist* +/src/main.ts diff --git a/.eslintrc-auto-import.json b/.eslintrc-auto-import.json new file mode 100644 index 0000000..024c96a --- /dev/null +++ b/.eslintrc-auto-import.json @@ -0,0 +1,259 @@ +{ + "globals": { + "EffectScope": true, + "ElMessage": true, + "ElMessageBox": true, + "ElTag": true, + "asyncComputed": true, + "autoResetRef": true, + "computed": true, + "computedAsync": true, + "computedEager": true, + "computedInject": true, + "computedWithControl": true, + "controlledComputed": true, + "controlledRef": true, + "createApp": true, + "createEventHook": true, + "createGlobalState": true, + "createInjectionState": true, + "createReactiveFn": true, + "createSharedComposable": true, + "createUnrefFn": true, + "customRef": true, + "debouncedRef": true, + "debouncedWatch": true, + "defineAsyncComponent": true, + "defineComponent": true, + "eagerComputed": true, + "effectScope": true, + "extendRef": true, + "getCurrentInstance": true, + "getCurrentScope": true, + "h": true, + "ignorableWatch": true, + "inject": true, + "isDefined": true, + "isProxy": true, + "isReactive": true, + "isReadonly": true, + "isRef": true, + "makeDestructurable": true, + "markRaw": true, + "nextTick": true, + "onActivated": true, + "onBeforeMount": true, + "onBeforeUnmount": true, + "onBeforeUpdate": true, + "onClickOutside": true, + "onDeactivated": true, + "onErrorCaptured": true, + "onKeyStroke": true, + "onLongPress": true, + "onMounted": true, + "onRenderTracked": true, + "onRenderTriggered": true, + "onScopeDispose": true, + "onServerPrefetch": true, + "onStartTyping": true, + "onUnmounted": true, + "onUpdated": true, + "pausableWatch": true, + "provide": true, + "reactify": true, + "reactifyObject": true, + "reactive": true, + "reactiveComputed": true, + "reactiveOmit": true, + "reactivePick": true, + "readonly": true, + "ref": true, + "refAutoReset": true, + "refDebounced": true, + "refDefault": true, + "refThrottled": true, + "refWithControl": true, + "resolveComponent": true, + "resolveRef": true, + "resolveUnref": true, + "shallowReactive": true, + "shallowReadonly": true, + "shallowRef": true, + "syncRef": true, + "syncRefs": true, + "templateRef": true, + "throttledRef": true, + "throttledWatch": true, + "toRaw": true, + "toReactive": true, + "toRef": true, + "toRefs": true, + "triggerRef": true, + "tryOnBeforeMount": true, + "tryOnBeforeUnmount": true, + "tryOnMounted": true, + "tryOnScopeDispose": true, + "tryOnUnmounted": true, + "unref": true, + "unrefElement": true, + "until": true, + "useActiveElement": true, + "useArrayEvery": true, + "useArrayFilter": true, + "useArrayFind": true, + "useArrayFindIndex": true, + "useArrayJoin": true, + "useArrayMap": true, + "useArrayReduce": true, + "useArraySome": true, + "useAsyncQueue": true, + "useAsyncState": true, + "useAttrs": true, + "useBase64": true, + "useBattery": true, + "useBluetooth": true, + "useBreakpoints": true, + "useBroadcastChannel": true, + "useBrowserLocation": true, + "useCached": true, + "useClipboard": true, + "useColorMode": true, + "useConfirmDialog": true, + "useCounter": true, + "useCssModule": true, + "useCssVar": true, + "useCssVars": true, + "useCurrentElement": true, + "useCycleList": true, + "useDark": true, + "useDateFormat": true, + "useDebounce": true, + "useDebounceFn": true, + "useDebouncedRefHistory": true, + "useDeviceMotion": true, + "useDeviceOrientation": true, + "useDevicePixelRatio": true, + "useDevicesList": true, + "useDisplayMedia": true, + "useDocumentVisibility": true, + "useDraggable": true, + "useDropZone": true, + "useElementBounding": true, + "useElementByPoint": true, + "useElementHover": true, + "useElementSize": true, + "useElementVisibility": true, + "useEventBus": true, + "useEventListener": true, + "useEventSource": true, + "useEyeDropper": true, + "useFavicon": true, + "useFetch": true, + "useFileDialog": true, + "useFileSystemAccess": true, + "useFocus": true, + "useFocusWithin": true, + "useFps": true, + "useFullscreen": true, + "useGamepad": true, + "useGeolocation": true, + "useIdle": true, + "useImage": true, + "useInfiniteScroll": true, + "useIntersectionObserver": true, + "useInterval": true, + "useIntervalFn": true, + "useKeyModifier": true, + "useLastChanged": true, + "useLocalStorage": true, + "useMagicKeys": true, + "useManualRefHistory": true, + "useMediaControls": true, + "useMediaQuery": true, + "useMemoize": true, + "useMemory": true, + "useMounted": true, + "useMouse": true, + "useMouseInElement": true, + "useMousePressed": true, + "useMutationObserver": true, + "useNavigatorLanguage": true, + "useNetwork": true, + "useNow": true, + "useObjectUrl": true, + "useOffsetPagination": true, + "useOnline": true, + "usePageLeave": true, + "useParallax": true, + "usePermission": true, + "usePointer": true, + "usePointerSwipe": true, + "usePreferredColorScheme": true, + "usePreferredDark": true, + "usePreferredLanguages": true, + "useRafFn": true, + "useRefHistory": true, + "useResizeObserver": true, + "useRoute": true, + "useRouter": true, + "useScreenOrientation": true, + "useScreenSafeArea": true, + "useScriptTag": true, + "useScroll": true, + "useScrollLock": true, + "useSessionStorage": true, + "useShare": true, + "useSlots": true, + "useSpeechRecognition": true, + "useSpeechSynthesis": true, + "useStepper": true, + "useStorage": true, + "useStorageAsync": true, + "useStyleTag": true, + "useSupported": true, + "useSwipe": true, + "useTemplateRefsList": true, + "useTextDirection": true, + "useTextSelection": true, + "useTextareaAutosize": true, + "useThrottle": true, + "useThrottleFn": true, + "useThrottledRefHistory": true, + "useTimeAgo": true, + "useTimeout": true, + "useTimeoutFn": true, + "useTimeoutPoll": true, + "useTimestamp": true, + "useTitle": true, + "useToggle": true, + "useTransition": true, + "useUrlSearchParams": true, + "useUserMedia": true, + "useVModel": true, + "useVModels": true, + "useVibrate": true, + "useVirtualList": true, + "useWakeLock": true, + "useWebNotification": true, + "useWebSocket": true, + "useWebWorker": true, + "useWebWorkerFn": true, + "useWindowFocus": true, + "useWindowScroll": true, + "useWindowSize": true, + "watch": true, + "watchArray": true, + "watchAtMost": true, + "watchDebounced": true, + "watchEffect": true, + "watchIgnorable": true, + "watchOnce": true, + "watchPausable": true, + "watchPostEffect": true, + "watchSyncEffect": true, + "watchThrottled": true, + "watchTriggerable": true, + "watchWithFilter": true, + "whenever": true + } +} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..b28255c --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,75 @@ +// @ts-check +const { defineConfig } = require('eslint-define-config') +module.exports = defineConfig({ + root: true, + env: { + browser: true, + node: true, + es6: true + }, + parser: 'vue-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser', + ecmaVersion: 2020, + sourceType: 'module', + jsxPragma: 'React', + ecmaFeatures: { + jsx: true + } + }, + extends: [ + 'plugin:vue/vue3-recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + 'plugin:prettier/recommended', + '@unocss' + ], + rules: { + 'vue/no-setup-props-destructure': 'off', + 'vue/script-setup-uses-vars': 'error', + 'vue/no-reserved-component-names': 'off', + '@typescript-eslint/ban-ts-ignore': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-empty-function': 'off', + 'vue/custom-event-name-casing': 'off', + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'no-unused-vars': 'off', + 'space-before-function-paren': 'off', + + 'vue/attributes-order': 'off', + 'vue/one-component-per-file': 'off', + 'vue/html-closing-bracket-newline': 'off', + 'vue/max-attributes-per-line': 'off', + 'vue/multiline-html-element-content-newline': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/attribute-hyphenation': 'off', + 'vue/require-default-prop': 'off', + 'vue/require-explicit-emits': 'off', + 'vue/require-toggle-inside-transition': 'off', + 'vue/html-self-closing': [ + 'error', + { + html: { + void: 'always', + normal: 'never', + component: 'always' + }, + svg: 'always', + math: 'always' + } + ], + 'vue/multi-word-component-names': 'off', + 'vue/no-v-html': 'off', + 'prettier/prettier': 'off', // 芋艿:默认关闭 prettier 的 ESLint 校验,因为我们使用的是 IDE 的 Prettier 插件 + '@unocss/order': 'off', // 芋艿:禁用 unocss 【css】顺序的提示,因为暂时不需要这么严格,警告也有点繁琐 + '@unocss/order-attributify': 'off' // 芋艿:禁用 unocss 【属性】顺序的提示,因为暂时不需要这么严格,警告也有点繁琐 + } +}) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..848638a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +.DS_Store +dist +dist-ssr +/dist* +pnpm-debug +auto-*.d.ts +.idea +.history diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f68ea86 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +/node_modules/** +/dist/ +/dist* +/public/* +/docs/* +/vite.config.ts +/src/types/env.d.ts +/src/types/auto-components.d.ts +/src/types/auto-imports.d.ts +/docs/**/* +CHANGELOG diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 0000000..aa605b4 --- /dev/null +++ b/.stylelintignore @@ -0,0 +1,6 @@ +/dist/* +/public/* +public/* +/dist* +/src/types/env.d.ts +/docs/**/* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9861118 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-present Archer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce6f2b4 --- /dev/null +++ b/README.md @@ -0,0 +1,262 @@ +**严肃声明:现在、未来都不会有商业版本,所有代码全部开源!!** + +**「我喜欢写代码,乐此不疲」** +**「我喜欢做开源,以此为乐」** + +我 🐶 在上海艰苦奋斗,早中晚在 top3 大厂认真搬砖,夜里为开源做贡献。 + +如果这个项目让你有所收获,记得 Star 关注哦,这对我是非常不错的鼓励与支持。 + +## 🐶 新手必读 + +* nodejs > 16.18.0 && pnpm > 8.6.0 (强制使用pnpm) +* 演示地址【Vue3 + element-plus】:<http://dashboard-vue3.yudao.iocoder.cn> +* 演示地址【Vue3 + vben(ant-design-vue)】:<http://dashboard-vben.yudao.iocoder.cn> +* 演示地址【Vue2 + element-ui】:<http://dashboard.yudao.iocoder.cn> +* 启动文档:<https://doc.iocoder.cn/quick-start/> +* 视频教程:<https://doc.iocoder.cn/video/> + +## 🐯 平台简介 + +**芋道**,以开发者为中心,打造中国第一流的快速开发平台,全部开源,个人与企业可 100% 免费使用。 + +* 采用 [vue-element-plus-admin](https://gitee.com/kailong110120130/vue-element-plus-admin) 实现 +* 改换 saas,自动引入等功能 +* 使用 Element Plus 免费开源的中后台模版,具备如下特性: + + + +* **最新技术栈**:使用 Vue3、Vite4 等前端前沿技术开发 +* **TypeScript**: 应用程序级 JavaScript 的语言 +* **主题**: 可配置的主题 +* **国际化**:内置完善的国际化方案 +* **权限**:内置完善的动态路由权限生成方案 +* **组件**:二次封装了多个常用的组件 +* **示例**:内置丰富的示例 + +## 技术栈 + +| 框架 | 说明 | 版本 | +|----------------------------------------------------------------------|------------------|--------| +| [Vue](https://staging-cn.vuejs.org/) | Vue 框架 | 3.3.8 | +| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 4.5.0 | +| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.4.2 | +| [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 的超集 | 5.2.2 | +| [pinia](https://pinia.vuejs.org/) | Vue 存储库 替代 vuex5 | 2.1.7 | +| [vueuse](https://vueuse.org/) | 常用工具集 | 10.6.1 | +| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 9.6.5 | +| [vue-router](https://router.vuejs.org/) | Vue 路由 | 4.2.5 | +| [unocss](https://uno.antfu.me/) | 原子 css | 0.57.4 | +| [iconify](https://icon-sets.iconify.design/) | 在线图标库 | 3.1.1 | +| [wangeditor](https://www.wangeditor.com/) | 富文本编辑器 | 5.1.23 | + +## 开发工具 + +推荐 VS Code 开发,配合插件如下: + +| 插件名 | 功能 | +|-------------------------------|---------------------| +| Vue - Official | Vue 与 TypeScript 支持 | +| unocss | unocss for vscode | +| Iconify IntelliSense | Iconify 预览和搜索 | +| i18n Ally | 国际化智能提示 | +| Stylelint | Css 格式化 | +| Prettier | 代码格式化 | +| ESLint | 脚本代码检查 | +| DotENV | env 文件高亮 | + +## 🔥 后端架构 + +支持 Spring Boot、Spring Cloud 两种架构: + +① Spring Boot 单体架构:<https://github.com/YunaiV/ruoyi-vue-pro> + + + +② Spring Cloud 微服务架构:<https://github.com/YunaiV/yudao-cloud> + + + +## 内置功能 + +系统内置多种多种业务功能,可以用于快速你的业务系统: + +* 系统功能 +* 基础设施 +* 工作流程 +* 支付系统 +* 会员中心 +* 数据报表 +* 商城系统 +* 微信公众号 +* ERP 系统 +* CRM 系统 + +### 系统功能 + +| | 功能 | 描述 | +|-----|-------|---------------------------------| +| | 用户管理 | 用户是系统操作者,该功能主要完成系统用户配置 | +| ⭐️ | 在线用户 | 当前系统中活跃用户状态监控,支持手动踢下线 | +| | 角色管理 | 角色菜单权限分配、设置角色按机构进行数据范围权限划分 | +| | 菜单管理 | 配置系统菜单、操作权限、按钮权限标识等,本地缓存提供性能 | +| | 部门管理 | 配置系统组织机构(公司、部门、小组),树结构展现支持数据权限 | +| | 岗位管理 | 配置系统用户所属担任职务 | +| 🚀 | 租户管理 | 配置系统租户,支持 SaaS 场景下的多租户功能 | +| 🚀 | 租户套餐 | 配置租户套餐,自定每个租户的菜单、操作、按钮的权限 | +| | 字典管理 | 对系统中经常使用的一些较为固定的数据进行维护 | +| 🚀 | 短信管理 | 短信渠道、短息模板、短信日志,对接阿里云、腾讯云等主流短信平台 | +| 🚀 | 邮件管理 | 邮箱账号、邮件模版、邮件发送日志,支持所有邮件平台 | +| 🚀 | 站内信 | 系统内的消息通知,提供站内信模版、站内信消息 | +| 🚀 | 操作日志 | 系统正常操作日志记录和查询,集成 Swagger 生成日志内容 | +| ⭐️ | 登录日志 | 系统登录日志记录查询,包含登录异常 | +| 🚀 | 错误码管理 | 系统所有错误码的管理,可在线修改错误提示,无需重启服务 | +| | 通知公告 | 系统通知公告信息发布维护 | +| 🚀 | 敏感词 | 配置系统敏感词,支持标签分组 | +| 🚀 | 应用管理 | 管理 SSO 单点登录的应用,支持多种 OAuth2 授权方式 | +| 🚀 | 地区管理 | 展示省份、城市、区镇等城市信息,支持 IP 对应城市 | + + + +### 工作流程 + +| | 功能 | 描述 | +|-----|-------|----------------------------------------| +| 🚀 | 流程模型 | 配置工作流的流程模型,支持文件导入与在线设计流程图,提供 7 种任务分配规则 | +| 🚀 | 流程表单 | 拖动表单元素生成相应的工作流表单,覆盖 Element UI 所有的表单组件 | +| 🚀 | 用户分组 | 自定义用户分组,可用于工作流的审批分组 | +| 🚀 | 我的流程 | 查看我发起的工作流程,支持新建、取消流程等操作,高亮流程图、审批时间线 | +| 🚀 | 待办任务 | 查看自己【未】审批的工作任务,支持通过、不通过、转发、委派、退回等操作 | +| 🚀 | 已办任务 | 查看自己【已】审批的工作任务,未来会支持回退操作 | +| 🚀 | OA 请假 | 作为业务自定义接入工作流的使用示例,只需创建请求对应的工作流程,即可进行审批 | + + + +### 支付系统 + +| | 功能 | 描述 | +|-----|------|---------------------------| +| 🚀 | 商户信息 | 管理商户信息,支持 Saas 场景下的多商户功能 | +| 🚀 | 应用信息 | 配置商户的应用信息,对接支付宝、微信等多个支付渠道 | +| 🚀 | 支付订单 | 查看用户发起的支付宝、微信等的【支付】订单 | +| 🚀 | 退款订单 | 查看用户发起的支付宝、微信等的【退款】订单 | + +ps:核心功能已经实现,正在对接微信小程序中... + +### 基础设施 + +| | 功能 | 描述 | +|----|----------|----------------------------------------------| +| 🚀 | 代码生成 | 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 | +| 🚀 | 系统接口 | 基于 Swagger 自动生成相关的 RESTful API 接口文档 | +| 🚀 | 数据库文档 | 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 | +| | 表单构建 | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 | +| 🚀 | 配置管理 | 对系统动态配置常用参数,支持 SpringBoot 加载 | +| ⭐️ | 定时任务 | 在线(添加、修改、删除)任务调度包含执行结果日志 | +| 🚀 | 文件服务 | 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 | +| 🚀 | API 日志 | 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 | +| | MySQL 监控 | 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 | +| | Redis 监控 | 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 | +| 🚀 | 消息队列 | 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 | +| 🚀 | Java 监控 | 基于 Spring Boot Admin 实现 Java 应用的监控 | +| 🚀 | 链路追踪 | 接入 SkyWalking 组件,实现链路追踪 | +| 🚀 | 日志中心 | 接入 SkyWalking 组件,实现日志中心 | +| 🚀 | 服务保障 | 基于 Redis 实现分布式锁、幂等、限流功能,满足高并发场景 | +| 🚀 | 日志服务 | 轻量级日志中心,查看远程服务器的日志 | +| 🚀 | 单元测试 | 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 | + + + +### 数据报表 + +| | 功能 | 描述 | +|-----|-------|--------------------| +| 🚀 | 报表设计器 | 支持数据报表、图形报表、打印设计等 | +| 🚀 | 大屏设计器 | 拖拽生成数据大屏,内置几十种图表组件 | + +### 微信公众号 + +| | 功能 | 描述 | +|-----|--------|-------------------------------| +| 🚀 | 账号管理 | 配置接入的微信公众号,可支持多个公众号 | +| 🚀 | 数据统计 | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 | +| 🚀 | 粉丝管理 | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 | +| 🚀 | 消息管理 | 查看粉丝发送的消息列表,可主动回复粉丝消息 | +| 🚀 | 自动回复 | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 | +| 🚀 | 标签管理 | 对公众号的标签进行创建、查询、修改、删除等操作 | +| 🚀 | 菜单管理 | 自定义公众号的菜单,也可以从公众号同步菜单 | +| 🚀 | 素材管理 | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 | +| 🚀 | 图文草稿箱 | 新增常用的图文素材到草稿箱,可发布到公众号 | +| 🚀 | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作 | + +### 商城系统 + +演示地址:<https://doc.iocoder.cn/mall-preview/> + + + + + +### ERP 系统 + +演示地址:<https://doc.iocoder.cn/erp-preview/> + + + +### CRM 系统 + +演示地址:<https://doc.iocoder.cn/crm-preview/> + + + +## 🐷 演示图 + +### 系统功能 + +| 模块 | biu | biu | biu | +|----------|-----------------------------|---------------------------|--------------------------| +| 登录 & 首页 |  |  |  | +| 用户 & 应用 |  |  |  | +| 租户 & 套餐 |  |  | - | +| 部门 & 岗位 |  |  | - | +| 菜单 & 角色 |  |  | - | +| 审计日志 |  |  | - | +| 短信 |  |  |  | +| 字典 & 敏感词 |  |  |  | +| 错误码 & 通知 |  |  | - | + +### 工作流程 + +| 模块 | biu | biu | biu | +|---------|---------------------------------|---------------------------------|---------------------------------| +| 流程模型 |  |  |  | +| 表单 & 分组 |  |  | - | +| 我的流程 |  |  |  | +| 待办 & 已办 |  |  |  | +| OA 请假 |  |  |  | + +### 基础设施 + +| 模块 | biu | biu | biu | +|---------------|-------------------------------|-----------------------------|---------------------------| +| 代码生成 |  |  | - | +| 文档 |  |  | - | +| 文件 & 配置 |  |  |  | +| 定时任务 |  |  | - | +| API 日志 |  |  | - | +| MySQL & Redis |  |  | - | +| 监控平台 |  |  |  | + +### 支付系统 + +| 模块 | biu | biu | biu | +|---------|---------------------------|---------------------------------|---------------------------------| +| 商家 & 应用 |  |  |  | +| 支付 & 退款 |  |  | --- | + +### 数据报表 + +| 模块 | biu | biu | biu | +|-------|---------------------------------|---------------------------------|---------------------------------------| +| 报表设计器 |  |  |  | +| 大屏设计器 |  |  |  | diff --git a/build/vite/index.ts b/build/vite/index.ts new file mode 100644 index 0000000..585759f --- /dev/null +++ b/build/vite/index.ts @@ -0,0 +1,100 @@ +import { resolve } from 'path' +import Vue from '@vitejs/plugin-vue' +import VueJsx from '@vitejs/plugin-vue-jsx' +import progress from 'vite-plugin-progress' +import EslintPlugin from 'vite-plugin-eslint' +import PurgeIcons from 'vite-plugin-purge-icons' +import { ViteEjsPlugin } from 'vite-plugin-ejs' +// @ts-ignore +import ElementPlus from 'unplugin-element-plus/vite' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import viteCompression from 'vite-plugin-compression' +import topLevelAwait from 'vite-plugin-top-level-await' +import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite' +import { createSvgIconsPlugin } from 'vite-plugin-svg-icons' +import UnoCSS from 'unocss/vite' + +export function createVitePlugins() { + const root = process.cwd() + + // 路径查找 + function pathResolve(dir: string) { + return resolve(root, '.', dir) + } + + return [ + Vue(), + VueJsx(), + UnoCSS(), + progress(), + PurgeIcons(), + ElementPlus({}), + AutoImport({ + include: [ + /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx + /\.vue$/, + /\.vue\?vue/, // .vue + /\.md$/ // .md + ], + imports: [ + 'vue', + 'vue-router', + // 可额外添加需要 autoImport 的组件 + { + '@/hooks/web/useI18n': ['useI18n'], + '@/hooks/web/useMessage': ['useMessage'], + '@/hooks/web/useTable': ['useTable'], + '@/hooks/web/useCrudSchemas': ['useCrudSchemas'], + '@/utils/formRules': ['required'], + '@/utils/dict': ['DICT_TYPE'] + } + ], + dts: 'src/types/auto-imports.d.ts', + resolvers: [ElementPlusResolver()], + eslintrc: { + enabled: false, // Default `false` + filepath: './.eslintrc-auto-import.json', // Default `./.eslintrc-auto-import.json` + globalsPropValue: true // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable') + } + }), + Components({ + // 生成自定义 `auto-components.d.ts` 全局声明 + dts: 'src/types/auto-components.d.ts', + // 自定义组件的解析器 + resolvers: [ElementPlusResolver()], + globs: ["src/components/**/**.{vue, md}", '!src/components/DiyEditor/components/mobile/**'] + }), + EslintPlugin({ + cache: false, + include: ['src/**/*.vue', 'src/**/*.ts', 'src/**/*.tsx'] // 检查的文件 + }), + VueI18nPlugin({ + runtimeOnly: true, + compositionOnly: true, + include: [resolve(__dirname, 'src/locales/**')] + }), + createSvgIconsPlugin({ + iconDirs: [pathResolve('src/assets/svgs')], + symbolId: 'icon-[dir]-[name]', + svgoOptions: true + }), + viteCompression({ + verbose: true, // 是否在控制台输出压缩结果 + disable: false, // 是否禁用 + threshold: 10240, // 体积大于 threshold 才会被压缩,单位 b + algorithm: 'gzip', // 压缩算法,可选 [ 'gzip' , 'brotliCompress' ,'deflate' , 'deflateRaw'] + ext: '.gz', // 生成的压缩包后缀 + deleteOriginFile: false //压缩后是否删除源文件 + }), + ViteEjsPlugin(), + topLevelAwait({ + // https://juejin.cn/post/7152191742513512485 + // The export name of top-level await promise for each chunk module + promiseExportName: '__tla', + // The function to generate import names of top-level await promise in each chunk module + promiseImportName: (i) => `__tla_${i}` + }) + ] +} diff --git a/build/vite/optimize.ts b/build/vite/optimize.ts new file mode 100644 index 0000000..aa7e68c --- /dev/null +++ b/build/vite/optimize.ts @@ -0,0 +1,122 @@ +const include = [ + 'qs', + 'url', + 'vue', + 'sass', + 'mitt', + 'axios', + 'pinia', + 'dayjs', + 'qrcode', + 'unocss', + 'vue-router', + 'vue-types', + 'vue-i18n', + 'crypto-js', + 'cropperjs', + 'lodash-es', + 'nprogress', + 'web-storage-cache', + '@iconify/iconify', + '@vueuse/core', + '@zxcvbn-ts/core', + 'echarts/core', + 'echarts/charts', + 'echarts/components', + 'echarts/renderers', + 'echarts-wordcloud', + '@wangeditor/editor', + '@wangeditor/editor-for-vue', + '@microsoft/fetch-event-source', + 'markdown-it', + 'markmap-view', + 'markmap-lib', + 'markmap-toolbar', + 'highlight.js', + 'element-plus', + 'element-plus/es', + 'element-plus/es/locale/lang/zh-cn', + 'element-plus/es/locale/lang/en', + 'element-plus/es/components/avatar/style/css', + 'element-plus/es/components/space/style/css', + 'element-plus/es/components/backtop/style/css', + 'element-plus/es/components/form/style/css', + 'element-plus/es/components/radio-group/style/css', + 'element-plus/es/components/radio/style/css', + 'element-plus/es/components/checkbox/style/css', + 'element-plus/es/components/checkbox-group/style/css', + 'element-plus/es/components/switch/style/css', + 'element-plus/es/components/time-picker/style/css', + 'element-plus/es/components/date-picker/style/css', + 'element-plus/es/components/descriptions/style/css', + 'element-plus/es/components/descriptions-item/style/css', + 'element-plus/es/components/link/style/css', + 'element-plus/es/components/tooltip/style/css', + 'element-plus/es/components/drawer/style/css', + 'element-plus/es/components/dialog/style/css', + 'element-plus/es/components/checkbox-button/style/css', + 'element-plus/es/components/option-group/style/css', + 'element-plus/es/components/radio-button/style/css', + 'element-plus/es/components/cascader/style/css', + 'element-plus/es/components/color-picker/style/css', + 'element-plus/es/components/input-number/style/css', + 'element-plus/es/components/rate/style/css', + 'element-plus/es/components/select-v2/style/css', + 'element-plus/es/components/tree-select/style/css', + 'element-plus/es/components/slider/style/css', + 'element-plus/es/components/time-select/style/css', + 'element-plus/es/components/autocomplete/style/css', + 'element-plus/es/components/image-viewer/style/css', + 'element-plus/es/components/upload/style/css', + 'element-plus/es/components/col/style/css', + 'element-plus/es/components/form-item/style/css', + 'element-plus/es/components/alert/style/css', + 'element-plus/es/components/breadcrumb/style/css', + 'element-plus/es/components/select/style/css', + 'element-plus/es/components/input/style/css', + 'element-plus/es/components/breadcrumb-item/style/css', + 'element-plus/es/components/tag/style/css', + 'element-plus/es/components/pagination/style/css', + 'element-plus/es/components/table/style/css', + 'element-plus/es/components/table-v2/style/css', + 'element-plus/es/components/table-column/style/css', + 'element-plus/es/components/card/style/css', + 'element-plus/es/components/row/style/css', + 'element-plus/es/components/button/style/css', + 'element-plus/es/components/menu/style/css', + 'element-plus/es/components/sub-menu/style/css', + 'element-plus/es/components/menu-item/style/css', + 'element-plus/es/components/option/style/css', + 'element-plus/es/components/dropdown/style/css', + 'element-plus/es/components/dropdown-menu/style/css', + 'element-plus/es/components/dropdown-item/style/css', + 'element-plus/es/components/skeleton/style/css', + 'element-plus/es/components/skeleton/style/css', + 'element-plus/es/components/backtop/style/css', + 'element-plus/es/components/menu/style/css', + 'element-plus/es/components/sub-menu/style/css', + 'element-plus/es/components/menu-item/style/css', + 'element-plus/es/components/dropdown/style/css', + 'element-plus/es/components/tree/style/css', + 'element-plus/es/components/dropdown-menu/style/css', + 'element-plus/es/components/dropdown-item/style/css', + 'element-plus/es/components/badge/style/css', + 'element-plus/es/components/breadcrumb/style/css', + 'element-plus/es/components/breadcrumb-item/style/css', + 'element-plus/es/components/image/style/css', + 'element-plus/es/components/collapse-transition/style/css', + 'element-plus/es/components/timeline/style/css', + 'element-plus/es/components/timeline-item/style/css', + 'element-plus/es/components/collapse/style/css', + 'element-plus/es/components/collapse-item/style/css', + 'element-plus/es/components/button-group/style/css', + 'element-plus/es/components/text/style/css', + 'element-plus/es/components/segmented/style/css', + '@element-plus/icons-vue', + 'element-plus/es/components/footer/style/css', + 'element-plus/es/components/empty/style/css' +] + +const exclude = ['@iconify/json'] + +export { include, exclude } diff --git a/index.html b/index.html new file mode 100644 index 0000000..8cfcbef --- /dev/null +++ b/index.html @@ -0,0 +1,151 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" href="/favicon.ico" /> + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta + name="keywords" + content="芋道管理系统 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!" + /> + <meta + name="description" + content="芋道管理系统 基于 vue3 + CompositionAPI + typescript + vite3 + element plus 的后台开源免费管理系统!" + /> + <title>%VITE_APP_TITLE%</title> + </head> + <body> + <div id="app"> + <style> + .app-loading { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + flex-direction: column; + background: #f0f2f5; + } + + .app-loading .app-loading-wrap { + position: absolute; + top: 50%; + left: 50%; + display: flex; + -webkit-transform: translate3d(-50%, -50%, 0); + transform: translate3d(-50%, -50%, 0); + justify-content: center; + align-items: center; + flex-direction: column; + } + + .app-loading .app-loading-title { + margin-bottom: 30px; + font-size: 20px; + font-weight: bold; + text-align: center; + } + + .app-loading .app-loading-logo { + width: 100px; + margin: 0 auto 15px auto; + } + + .app-loading .app-loading-item { + position: relative; + display: inline-block; + width: 60px; + height: 60px; + vertical-align: middle; + border-radius: 50%; + } + + .app-loading .app-loading-outter { + position: absolute; + width: 100%; + height: 100%; + border: 4px solid #2d8cf0; + border-bottom: 0; + border-left-color: transparent; + border-radius: 50%; + animation: loader-outter 1s cubic-bezier(0.42, 0.61, 0.58, 0.41) infinite; + } + + .app-loading .app-loading-inner { + position: absolute; + top: calc(50% - 20px); + left: calc(50% - 20px); + width: 40px; + height: 40px; + border: 4px solid #87bdff; + border-right: 0; + border-top-color: transparent; + border-radius: 50%; + animation: loader-inner 1s cubic-bezier(0.42, 0.61, 0.58, 0.41) infinite; + } + + @-webkit-keyframes loader-outter { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } + } + + @keyframes loader-outter { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } + } + + @-webkit-keyframes loader-inner { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(-360deg); + transform: rotate(-360deg); + } + } + + @keyframes loader-inner { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(-360deg); + transform: rotate(-360deg); + } + } + </style> + <div class="app-loading"> + <div class="app-loading-wrap"> + <div class="app-loading-title"> + <img src="/logo.gif" class="app-loading-logo" alt="Logo" /> + <div class="app-loading-title">%VITE_APP_TITLE%</div> + </div> + <div class="app-loading-item"> + <div class="app-loading-outter"></div> + <div class="app-loading-inner"></div> + </div> + </div> + </div> + </div> + <script type="module" src="/src/main.ts"></script> + </body> +</html> diff --git a/package.json b/package.json new file mode 100644 index 0000000..48d16a2 --- /dev/null +++ b/package.json @@ -0,0 +1,150 @@ +{ + "name": "yudao-ui-admin-vue3", + "version": "2.2.0-snapshot", + "description": "基于vue3、vite4、element-plus、typesScript", + "author": "xingyu", + "private": false, + "scripts": { + "i": "pnpm install", + "dev": "vite --mode env.local", + "dev-server": "vite --mode dev", + "ts:check": "vue-tsc --noEmit", + "build:local": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build", + "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode dev", + "build:test": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode test", + "build:stage": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode stage", + "build:prod": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode prod", + "serve:dev": "vite preview --mode dev", + "serve:prod": "vite preview --mode prod", + "preview": "pnpm build:local && vite preview", + "clean": "npx rimraf node_modules", + "clean:cache": "npx rimraf node_modules/.cache", + "lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src", + "lint:format": "prettier --write --loglevel warn \"src/**/*.{js,ts,json,tsx,css,less,scss,vue,html,md}\"", + "lint:style": "stylelint --fix \"./src/**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/", + "lint:lint-staged": "lint-staged -c " + }, + "dependencies": { + "@element-plus/icons-vue": "^2.1.0", + "@form-create/designer": "^3.1.3", + "@form-create/element-ui": "^3.1.24", + "@iconify/iconify": "^3.1.1", + "@microsoft/fetch-event-source": "^2.0.1", + "@videojs-player/vue": "^1.0.0", + "@vueuse/core": "^10.9.0", + "@wangeditor/editor": "^5.1.23", + "@wangeditor/editor-for-vue": "^5.1.10", + "@zxcvbn-ts/core": "^3.0.4", + "animate.css": "^4.1.1", + "axios": "^1.6.8", + "benz-amr-recorder": "^1.1.5", + "bpmn-js-token-simulation": "^0.10.0", + "camunda-bpmn-moddle": "^7.0.1", + "cropperjs": "^1.6.1", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.10", + "diagram-js": "^12.8.0", + "driver.js": "^1.3.1", + "echarts": "^5.5.0", + "echarts-wordcloud": "^2.1.0", + "element-plus": "2.7.0", + "fast-xml-parser": "^4.3.2", + "highlight.js": "^11.9.0", + "jsencrypt": "^3.3.2", + "lodash-es": "^4.17.21", + "markdown-it": "^14.1.0", + "markmap-common": "^0.16.0", + "markmap-lib": "^0.16.1", + "markmap-toolbar": "^0.17.0", + "markmap-view": "^0.16.0", + "min-dash": "^4.1.1", + "mitt": "^3.0.1", + "nprogress": "^0.2.0", + "pinia": "^2.1.7", + "pinia-plugin-persistedstate": "^3.2.1", + "qrcode": "^1.5.3", + "qs": "^6.12.0", + "steady-xml": "^0.1.0", + "url": "^0.11.3", + "video.js": "^7.21.5", + "vue": "3.4.21", + "vue-dompurify-html": "^4.1.4", + "vue-i18n": "9.10.2", + "vue-router": "^4.3.0", + "vue-types": "^5.1.1", + "vuedraggable": "^4.1.0", + "web-storage-cache": "^1.1.1", + "xml-js": "^1.6.11" + }, + "devDependencies": { + "@commitlint/cli": "^19.0.1", + "@commitlint/config-conventional": "^19.0.0", + "@iconify/json": "^2.2.187", + "@intlify/unplugin-vue-i18n": "^2.0.0", + "@purge-icons/generated": "^0.9.0", + "@types/lodash-es": "^4.17.12", + "@types/node": "^20.11.21", + "@types/nprogress": "^0.2.3", + "@types/qrcode": "^1.5.5", + "@types/qs": "^6.9.12", + "@typescript-eslint/eslint-plugin": "^7.1.0", + "@typescript-eslint/parser": "^7.1.0", + "@unocss/eslint-config": "^0.57.4", + "@unocss/transformer-variant-group": "^0.58.5", + "@vitejs/plugin-legacy": "^5.3.1", + "@vitejs/plugin-vue": "^5.0.4", + "@vitejs/plugin-vue-jsx": "^3.1.0", + "autoprefixer": "^10.4.17", + "bpmn-js": "8.9.0", + "bpmn-js-properties-panel": "0.46.0", + "consola": "^3.2.3", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-define-config": "^2.1.0", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-vue": "^9.22.0", + "lint-staged": "^15.2.2", + "postcss": "^8.4.35", + "postcss-html": "^1.6.0", + "postcss-scss": "^4.0.9", + "prettier": "^3.2.5", + "prettier-eslint": "^16.3.0", + "rimraf": "^5.0.5", + "rollup": "^4.12.0", + "sass": "^1.69.5", + "stylelint": "^16.2.1", + "stylelint-config-html": "^1.1.0", + "stylelint-config-recommended": "^14.0.0", + "stylelint-config-standard": "^36.0.0", + "stylelint-order": "^6.0.4", + "terser": "^5.28.1", + "typescript": "5.3.3", + "unocss": "^0.58.5", + "unplugin-auto-import": "^0.16.7", + "unplugin-element-plus": "^0.8.0", + "unplugin-vue-components": "^0.25.2", + "vite": "5.1.4", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-ejs": "^1.7.0", + "vite-plugin-eslint": "^1.8.1", + "vite-plugin-progress": "^0.0.7", + "vite-plugin-purge-icons": "^0.10.0", + "vite-plugin-svg-icons": "^2.0.1", + "vite-plugin-top-level-await": "^1.3.1", + "vue-eslint-parser": "^9.3.2", + "vue-tsc": "^1.8.27" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://gitee.com/yudaocode/yudao-ui-admin-vue3" + }, + "bugs": { + "url": "https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues" + }, + "homepage": "https://gitee.com/yudaocode/yudao-ui-admin-vue3", + "engines": { + "node": ">= 16.0.0", + "pnpm": ">=8.6.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..6a7349a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,11620 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@element-plus/icons-vue': + specifier: ^2.1.0 + version: 2.3.1(vue@3.4.21(typescript@5.3.3)) + '@form-create/designer': + specifier: ^3.1.3 + version: 3.1.5(vue@3.4.21(typescript@5.3.3)) + '@form-create/element-ui': + specifier: ^3.1.24 + version: 3.1.29(vue@3.4.21(typescript@5.3.3)) + '@iconify/iconify': + specifier: ^3.1.1 + version: 3.1.1 + '@microsoft/fetch-event-source': + specifier: ^2.0.1 + version: 2.0.1 + '@videojs-player/vue': + specifier: ^1.0.0 + version: 1.0.0(@types/video.js@7.3.58)(video.js@7.21.5)(vue@3.4.21(typescript@5.3.3)) + '@vueuse/core': + specifier: ^10.9.0 + version: 10.9.0(vue@3.4.21(typescript@5.3.3)) + '@wangeditor/editor': + specifier: ^5.1.23 + version: 5.1.23 + '@wangeditor/editor-for-vue': + specifier: ^5.1.10 + version: 5.1.12(@wangeditor/editor@5.1.23)(vue@3.4.21(typescript@5.3.3)) + '@zxcvbn-ts/core': + specifier: ^3.0.4 + version: 3.0.4 + animate.css: + specifier: ^4.1.1 + version: 4.1.1 + axios: + specifier: ^1.6.8 + version: 1.6.8 + benz-amr-recorder: + specifier: ^1.1.5 + version: 1.1.5 + bpmn-js-token-simulation: + specifier: ^0.10.0 + version: 0.10.0 + camunda-bpmn-moddle: + specifier: ^7.0.1 + version: 7.0.1 + cropperjs: + specifier: ^1.6.1 + version: 1.6.2 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 + dayjs: + specifier: ^1.11.10 + version: 1.11.11 + diagram-js: + specifier: ^12.8.0 + version: 12.8.1 + driver.js: + specifier: ^1.3.1 + version: 1.3.1 + echarts: + specifier: ^5.5.0 + version: 5.5.0 + echarts-wordcloud: + specifier: ^2.1.0 + version: 2.1.0(echarts@5.5.0) + element-plus: + specifier: 2.7.0 + version: 2.7.0(vue@3.4.21(typescript@5.3.3)) + fast-xml-parser: + specifier: ^4.3.2 + version: 4.3.6 + highlight.js: + specifier: ^11.9.0 + version: 11.9.0 + jsencrypt: + specifier: ^3.3.2 + version: 3.3.2 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 + markdown-it: + specifier: ^14.1.0 + version: 14.1.0 + markmap-common: + specifier: ^0.16.0 + version: 0.16.0 + markmap-lib: + specifier: ^0.16.1 + version: 0.16.1(markmap-common@0.16.0) + markmap-toolbar: + specifier: ^0.17.0 + version: 0.17.0(markmap-common@0.16.0) + markmap-view: + specifier: ^0.16.0 + version: 0.16.0(markmap-common@0.16.0) + min-dash: + specifier: ^4.1.1 + version: 4.2.1 + mitt: + specifier: ^3.0.1 + version: 3.0.1 + nprogress: + specifier: ^0.2.0 + version: 0.2.0 + pinia: + specifier: ^2.1.7 + version: 2.1.7(typescript@5.3.3)(vue@3.4.21(typescript@5.3.3)) + pinia-plugin-persistedstate: + specifier: ^3.2.1 + version: 3.2.1(pinia@2.1.7(typescript@5.3.3)(vue@3.4.21(typescript@5.3.3))) + qrcode: + specifier: ^1.5.3 + version: 1.5.3 + qs: + specifier: ^6.12.0 + version: 6.12.1 + steady-xml: + specifier: ^0.1.0 + version: 0.1.0 + url: + specifier: ^0.11.3 + version: 0.11.3 + video.js: + specifier: ^7.21.5 + version: 7.21.5 + vue: + specifier: 3.4.21 + version: 3.4.21(typescript@5.3.3) + vue-dompurify-html: + specifier: ^4.1.4 + version: 4.1.4(vue@3.4.21(typescript@5.3.3)) + vue-i18n: + specifier: 9.10.2 + version: 9.10.2(vue@3.4.21(typescript@5.3.3)) + vue-router: + specifier: ^4.3.0 + version: 4.3.2(vue@3.4.21(typescript@5.3.3)) + vue-types: + specifier: ^5.1.1 + version: 5.1.1(vue@3.4.21(typescript@5.3.3)) + vuedraggable: + specifier: ^4.1.0 + version: 4.1.0(vue@3.4.21(typescript@5.3.3)) + web-storage-cache: + specifier: ^1.1.1 + version: 1.1.1 + xml-js: + specifier: ^1.6.11 + version: 1.6.11 + devDependencies: + '@commitlint/cli': + specifier: ^19.0.1 + version: 19.3.0(@types/node@20.12.7)(typescript@5.3.3) + '@commitlint/config-conventional': + specifier: ^19.0.0 + version: 19.2.2 + '@iconify/json': + specifier: ^2.2.187 + version: 2.2.205 + '@intlify/unplugin-vue-i18n': + specifier: ^2.0.0 + version: 2.0.0(rollup@4.17.1)(vue-i18n@9.10.2(vue@3.4.21(typescript@5.3.3))) + '@purge-icons/generated': + specifier: ^0.9.0 + version: 0.9.0 + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 + '@types/node': + specifier: ^20.11.21 + version: 20.12.7 + '@types/nprogress': + specifier: ^0.2.3 + version: 0.2.3 + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.5 + '@types/qs': + specifier: ^6.9.12 + version: 6.9.15 + '@typescript-eslint/eslint-plugin': + specifier: ^7.1.0 + version: 7.7.1(@typescript-eslint/parser@7.7.1(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/parser': + specifier: ^7.1.0 + version: 7.7.1(eslint@8.57.0)(typescript@5.3.3) + '@unocss/eslint-config': + specifier: ^0.57.4 + version: 0.57.7(eslint@8.57.0)(typescript@5.3.3) + '@unocss/transformer-variant-group': + specifier: ^0.58.5 + version: 0.58.9 + '@vitejs/plugin-legacy': + specifier: ^5.3.1 + version: 5.3.2(terser@5.30.4)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + '@vitejs/plugin-vue': + specifier: ^5.0.4 + version: 5.0.4(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4))(vue@3.4.21(typescript@5.3.3)) + '@vitejs/plugin-vue-jsx': + specifier: ^3.1.0 + version: 3.1.0(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4))(vue@3.4.21(typescript@5.3.3)) + autoprefixer: + specifier: ^10.4.17 + version: 10.4.19(postcss@8.4.38) + bpmn-js: + specifier: 8.9.0 + version: 8.9.0 + bpmn-js-properties-panel: + specifier: 0.46.0 + version: 0.46.0(bpmn-js@8.9.0) + consola: + specifier: ^3.2.3 + version: 3.2.3 + eslint: + specifier: ^8.57.0 + version: 8.57.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.0) + eslint-define-config: + specifier: ^2.1.0 + version: 2.1.0 + eslint-plugin-prettier: + specifier: ^5.1.3 + version: 5.1.3(@types/eslint@8.56.10)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.2.5) + eslint-plugin-vue: + specifier: ^9.22.0 + version: 9.25.0(eslint@8.57.0) + lint-staged: + specifier: ^15.2.2 + version: 15.2.2 + postcss: + specifier: ^8.4.35 + version: 8.4.38 + postcss-html: + specifier: ^1.6.0 + version: 1.6.0 + postcss-scss: + specifier: ^4.0.9 + version: 4.0.9(postcss@8.4.38) + prettier: + specifier: ^3.2.5 + version: 3.2.5 + prettier-eslint: + specifier: ^16.3.0 + version: 16.3.0 + rimraf: + specifier: ^5.0.5 + version: 5.0.5 + rollup: + specifier: ^4.12.0 + version: 4.17.1 + sass: + specifier: ^1.69.5 + version: 1.75.0 + stylelint: + specifier: ^16.2.1 + version: 16.4.0(typescript@5.3.3) + stylelint-config-html: + specifier: ^1.1.0 + version: 1.1.0(postcss-html@1.6.0)(stylelint@16.4.0(typescript@5.3.3)) + stylelint-config-recommended: + specifier: ^14.0.0 + version: 14.0.0(stylelint@16.4.0(typescript@5.3.3)) + stylelint-config-standard: + specifier: ^36.0.0 + version: 36.0.0(stylelint@16.4.0(typescript@5.3.3)) + stylelint-order: + specifier: ^6.0.4 + version: 6.0.4(stylelint@16.4.0(typescript@5.3.3)) + terser: + specifier: ^5.28.1 + version: 5.30.4 + typescript: + specifier: 5.3.3 + version: 5.3.3 + unocss: + specifier: ^0.58.5 + version: 0.58.9(postcss@8.4.38)(rollup@4.17.1)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + unplugin-auto-import: + specifier: ^0.16.7 + version: 0.16.7(@vueuse/core@10.9.0(vue@3.4.21(typescript@5.3.3)))(rollup@4.17.1) + unplugin-element-plus: + specifier: ^0.8.0 + version: 0.8.0(rollup@4.17.1) + unplugin-vue-components: + specifier: ^0.25.2 + version: 0.25.2(@babel/parser@7.24.4)(rollup@4.17.1)(vue@3.4.21(typescript@5.3.3)) + vite: + specifier: 5.1.4 + version: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + vite-plugin-compression: + specifier: ^0.5.1 + version: 0.5.1(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + vite-plugin-ejs: + specifier: ^1.7.0 + version: 1.7.0(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + vite-plugin-eslint: + specifier: ^1.8.1 + version: 1.8.1(eslint@8.57.0)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + vite-plugin-progress: + specifier: ^0.0.7 + version: 0.0.7(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + vite-plugin-purge-icons: + specifier: ^0.10.0 + version: 0.10.0(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + vite-plugin-svg-icons: + specifier: ^2.0.1 + version: 2.0.1(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + vite-plugin-top-level-await: + specifier: ^1.3.1 + version: 1.4.1(rollup@4.17.1)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + vue-eslint-parser: + specifier: ^9.3.2 + version: 9.4.2(eslint@8.57.0) + vue-tsc: + specifier: ^1.8.27 + version: 1.8.27(typescript@5.3.3) + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==, tarball: https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz} + engines: {node: '>=6.0.0'} + + '@antfu/install-pkg@0.1.1': + resolution: {integrity: sha512-LyB/8+bSfa0DFGC06zpCEfs89/XoWZwws5ygEa5D+Xsm3OfI+aXQ86VgVG7Acyef+rSZ5HE7J8rrxzrQeM3PjQ==, tarball: https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-0.1.1.tgz} + + '@antfu/utils@0.7.7': + resolution: {integrity: sha512-gFPqTG7otEJ8uP6wrhDv6mqwGWYZKNvAcCq6u9hOj0c+IKCEsY4L1oC9trPq2SaWIzAfHvqfBDxF591JkMf+kg==, tarball: https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.7.tgz} + + '@babel/code-frame@7.24.2': + resolution: {integrity: sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==, tarball: https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.24.2.tgz} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.24.4': + resolution: {integrity: sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==, tarball: https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.24.4.tgz} + engines: {node: '>=6.9.0'} + + '@babel/core@7.24.4': + resolution: {integrity: sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==, tarball: https://registry.npmmirror.com/@babel/core/-/core-7.24.4.tgz} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.24.4': + resolution: {integrity: sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==, tarball: https://registry.npmmirror.com/@babel/generator/-/generator-7.24.4.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.22.5': + resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==, tarball: https://registry.npmmirror.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-builder-binary-assignment-operator-visitor@7.22.15': + resolution: {integrity: sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==, tarball: https://registry.npmmirror.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.23.6': + resolution: {integrity: sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==, tarball: https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.24.4': + resolution: {integrity: sha512-lG75yeuUSVu0pIcbhiYMXBXANHrpUPaOfu7ryAzskCgKUHuAxRQI5ssrtmF0X9UXldPlvT0XM/A4F44OXRt6iQ==, tarball: https://registry.npmmirror.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.4.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.22.15': + resolution: {integrity: sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==, tarball: https://registry.npmmirror.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.2': + resolution: {integrity: sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==, tarball: https://registry.npmmirror.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-environment-visitor@7.22.20': + resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==, tarball: https://registry.npmmirror.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-function-name@7.23.0': + resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==, tarball: https://registry.npmmirror.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-hoist-variables@7.22.5': + resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==, tarball: https://registry.npmmirror.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.23.0': + resolution: {integrity: sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==, tarball: https://registry.npmmirror.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.22.15': + resolution: {integrity: sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==, tarball: https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.24.3': + resolution: {integrity: sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==, tarball: https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.23.3': + resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==, tarball: https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.22.5': + resolution: {integrity: sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==, tarball: https://registry.npmmirror.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.24.0': + resolution: {integrity: sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==, tarball: https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.22.20': + resolution: {integrity: sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==, tarball: https://registry.npmmirror.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.24.1': + resolution: {integrity: sha512-QCR1UqC9BzG5vZl8BMicmZ28RuUBnHhAMddD8yHFHDRH9lLTZ9uUPehX8ctVPT8l0TKblJidqcgUUKGVrePleQ==, tarball: https://registry.npmmirror.com/@babel/helper-replace-supers/-/helper-replace-supers-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-simple-access@7.22.5': + resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==, tarball: https://registry.npmmirror.com/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-skip-transparent-expression-wrappers@7.22.5': + resolution: {integrity: sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==, tarball: https://registry.npmmirror.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-split-export-declaration@7.22.6': + resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==, tarball: https://registry.npmmirror.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.24.1': + resolution: {integrity: sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==, tarball: https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.22.20': + resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==, tarball: https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.23.5': + resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==, tarball: https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.22.20': + resolution: {integrity: sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==, tarball: https://registry.npmmirror.com/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.24.4': + resolution: {integrity: sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==, tarball: https://registry.npmmirror.com/@babel/helpers/-/helpers-7.24.4.tgz} + engines: {node: '>=6.9.0'} + + '@babel/highlight@7.24.2': + resolution: {integrity: sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==, tarball: https://registry.npmmirror.com/@babel/highlight/-/highlight-7.24.2.tgz} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.24.4': + resolution: {integrity: sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==, tarball: https://registry.npmmirror.com/@babel/parser/-/parser-7.24.4.tgz} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.24.4': + resolution: {integrity: sha512-qpl6vOOEEzTLLcsuqYYo8yDtrTocmu2xkGvgNebvPjT9DTtfFYGmgDqY+rBYXNlqL4s9qLDn6xkrJv4RxAPiTA==, tarball: https://registry.npmmirror.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.4.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.24.1': + resolution: {integrity: sha512-y4HqEnkelJIOQGd+3g1bTeKsA5c6qM7eOn7VggGVbBc0y8MLSKHacwcIE2PplNlQSj0PqS9rrXL/nkPVK+kUNg==, tarball: https://registry.npmmirror.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.24.1': + resolution: {integrity: sha512-Hj791Ii4ci8HqnaKHAlLNs+zaLXb0EzSDhiAWp5VNlyvCNymYfacs64pxTxbH1znW/NcArSmwpmG9IKE/TUVVQ==, tarball: https://registry.npmmirror.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.24.1': + resolution: {integrity: sha512-m9m/fXsXLiHfwdgydIFnpk+7jlVbnvlK5B2EKiPdLUb6WX654ZaaEWJUjk8TftRbZpK0XibovlLWX4KIZhV6jw==, tarball: https://registry.npmmirror.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==, tarball: https://registry.npmmirror.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-namespace-from@7.8.3': + resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.24.1': + resolution: {integrity: sha512-IuwnI5XnuF189t91XbxmXeCDz3qs6iDRO7GJ++wcfgeXNs/8FmIlKcpDSXNVyuLQxlwvskmI3Ct73wUODkJBlQ==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.24.1': + resolution: {integrity: sha512-zhQTMH0X2nVLnb04tz+s7AMuasX8U0FnpE+nHTOhSOINjWMnopoZTxtIKsd45n4GQ/HIZLyfIpoul8e2m0DnRA==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.24.1': + resolution: {integrity: sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.24.1': + resolution: {integrity: sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==, tarball: https://registry.npmmirror.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.24.1': + resolution: {integrity: sha512-ngT/3NkRhsaep9ck9uj2Xhv9+xB1zShY3tM3g6om4xxCELwCDN4g4Aq5dRn48+0hasAql7s2hdBOysCfNpr4fw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.24.3': + resolution: {integrity: sha512-Qe26CMYVjpQxJ8zxM1340JFNjZaF+ISWpr1Kt/jGo+ZTUzKkfw/pphEWbRCb+lmSM6k/TOgfYLvmbHkUQ0asIg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.24.3.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.24.1': + resolution: {integrity: sha512-AawPptitRXp1y0n4ilKcGbRYWfbbzFWz2NqNu7dacYDtFtz0CMjG64b3LQsb3KIgnf4/obcUL78hfaOS7iCUfw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.24.1': + resolution: {integrity: sha512-TWWC18OShZutrv9C6mye1xwtam+uNi2bnTOCBUd5sZxyHOiWbU6ztSROofIMrK84uweEZC219POICK/sTYwfgg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.24.4': + resolution: {integrity: sha512-nIFUZIpGKDf9O9ttyRXpHFpKC+X3Y5mtshZONuEUYBomAKoM4y029Jr+uB1bHGPhNmK8YXHevDtKDOLmtRrp6g==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.24.4.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.24.1': + resolution: {integrity: sha512-OMLCXi0NqvJfORTaPQBwqLXHhb93wkBKZ4aNwMl6WtehO7ar+cmp+89iPEQPqxAnxsOKTaMcs3POz3rKayJ72g==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.24.4': + resolution: {integrity: sha512-B8q7Pz870Hz/q9UgP8InNpY01CSLDSCyqX7zcRuv3FcPl87A2G17lASroHWaCtbdIcbYzOZ7kWmXFKbijMSmFg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.4.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.24.1': + resolution: {integrity: sha512-ZTIe3W7UejJd3/3R4p7ScyyOoafetUShSf4kCqV0O7F/RiHxVj/wRaRnQlrGwflvcehNA8M42HkAiEDYZu2F1Q==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.24.1': + resolution: {integrity: sha512-5pJGVIUfJpOS+pAqBQd+QMaTD2vCL/HcePooON6pDpHgRp4gNRmzyHTPIkXntwKsq3ayUFVfJaIKPw2pOkOcTw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.24.1': + resolution: {integrity: sha512-ow8jciWqNxR3RYbSNVuF4U2Jx130nwnBnhRw6N6h1bOejNkABmcI5X5oz29K4alWX7vf1C+o6gtKXikzRKkVdw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.24.1': + resolution: {integrity: sha512-p7uUxgSoZwZ2lPNMzUkqCts3xlp8n+o05ikjy7gbtFJSt9gdU88jAmtfmOxHM14noQXBxfgzf2yRWECiNVhTCw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.24.1': + resolution: {integrity: sha512-msyzuUnvsjsaSaocV6L7ErfNsa5nDWL1XKNnDePLgmz+WdU4w/J8+AxBMrWfi9m4IxfL5sZQKUPQKDQeeAT6lA==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dynamic-import@7.24.1': + resolution: {integrity: sha512-av2gdSTyXcJVdI+8aFZsCAtR29xJt0S5tas+Ef8NvBNmD1a+N/3ecMLeMBgfcK+xzsjdLDT6oHt+DFPyeqUbDA==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.24.1': + resolution: {integrity: sha512-U1yX13dVBSwS23DEAqU+Z/PkwE9/m7QQy8Y9/+Tdb8UWYaGNDYwTLi19wqIAiROr8sXVum9A/rtiH5H0boUcTw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.24.1': + resolution: {integrity: sha512-Ft38m/KFOyzKw2UaJFkWG9QnHPG/Q/2SkOrRk4pNBPg5IPZ+dOxcmkK5IyuBcxiNPyyYowPGUReyBvrvZs7IlQ==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.24.1': + resolution: {integrity: sha512-OxBdcnF04bpdQdR3i4giHZNZQn7cm8RQKcSwA17wAAqEELo1ZOwp5FFgeptWUQXFyT9kwHo10aqqauYkRZPCAg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.24.1': + resolution: {integrity: sha512-BXmDZpPlh7jwicKArQASrj8n22/w6iymRnvHYYd2zO30DbE277JO20/7yXJT3QxDPtiQiOxQBbZH4TpivNXIxA==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.24.1': + resolution: {integrity: sha512-U7RMFmRvoasscrIFy5xA4gIp8iWnWubnKkKuUGJjsuOH7GfbMkB+XZzeslx2kLdEGdOJDamEmCqOks6e8nv8DQ==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.24.1': + resolution: {integrity: sha512-zn9pwz8U7nCqOYIiBaOxoQOtYmMODXTJnkxG4AtX8fPmnCRYWBOHD0qcpwS9e2VDSp1zNJYpdnFMIKb8jmwu6g==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.24.1': + resolution: {integrity: sha512-OhN6J4Bpz+hIBqItTeWJujDOfNP+unqv/NJgyhlpSqgBTPm37KkMmZV6SYcOj+pnDbdcl1qRGV/ZiIjX9Iy34w==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.24.1': + resolution: {integrity: sha512-4ojai0KysTWXzHseJKa1XPNXKRbuUrhkOPY4rEGeR+7ChlJVKxFa3H3Bz+7tWaGKgJAXUWKOGmltN+u9B3+CVg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.24.1': + resolution: {integrity: sha512-lAxNHi4HVtjnHd5Rxg3D5t99Xm6H7b04hUS7EHIXcUl2EV4yl1gWdqZrNzXnSrHveL9qMdbODlLF55mvgjAfaQ==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.24.1': + resolution: {integrity: sha512-szog8fFTUxBfw0b98gEWPaEqF42ZUD/T3bkynW/wtgx2p/XCP55WEsb+VosKceRSd6njipdZvNogqdtI4Q0chw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.24.1': + resolution: {integrity: sha512-mqQ3Zh9vFO1Tpmlt8QPnbwGHzNz3lpNEMxQb1kAemn/erstyqw1r9KeOlOfo3y6xAnFEcOv2tSyrXfmMk+/YZA==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.24.1': + resolution: {integrity: sha512-tuA3lpPj+5ITfcCluy6nWonSL7RvaG0AOTeAuvXqEKS34lnLzXpDb0dcP6K8jD0zWZFNDVly90AGFJPnm4fOYg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.22.5': + resolution: {integrity: sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.24.1': + resolution: {integrity: sha512-/rurytBM34hYy0HKZQyA0nHbQgQNFm4Q/BOc9Hflxi2X3twRof7NaE5W46j4kQitm7SvACVRXsa6N/tSZxvPug==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.24.1': + resolution: {integrity: sha512-iQ+caew8wRrhCikO5DrUYx0mrmdhkaELgFa+7baMcVuhxIkN7oxt06CZ51D65ugIb1UWRQ8oQe+HXAVM6qHFjw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.24.1': + resolution: {integrity: sha512-7GAsGlK4cNL2OExJH1DzmDeKnRv/LXq0eLUSvudrehVA5Rgg4bIrqEUW29FbKMBRT0ztSqisv7kjP+XIC4ZMNw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.24.1': + resolution: {integrity: sha512-XjD5f0YqOtebto4HGISLNfiNMTTs6tbkFf2TOqJlYKYmbo+mN9Dnpl4SRoofiziuOWMIyq3sZEUqLo3hLITFEA==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.24.1': + resolution: {integrity: sha512-oKJqR3TeI5hSLRxudMjFQ9re9fBVUU0GICqM3J1mi8MqlhVr6hC/ZN4ttAyMuQR6EZZIY6h/exe5swqGNNIkWQ==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.24.1': + resolution: {integrity: sha512-oBTH7oURV4Y+3EUrf6cWn1OHio3qG/PVwO5J03iSJmBg6m2EhKjkAu/xuaXaYwWW9miYtvbWv4LNf0AmR43LUA==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.24.1': + resolution: {integrity: sha512-n03wmDt+987qXwAgcBlnUUivrZBPZ8z1plL0YvgQalLm+ZE5BMhGm94jhxXtA1wzv1Cu2aaOv1BM9vbVttrzSg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.24.1': + resolution: {integrity: sha512-8Jl6V24g+Uw5OGPeWNKrKqXPDw2YDjLc53ojwfMcKwlEoETKU9rU0mHUtcg9JntWI/QYzGAXNWEcVHZ+fR+XXg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.24.1': + resolution: {integrity: sha512-tGvisebwBO5em4PaYNqt4fkw56K2VALsAbAakY0FjTYqJp7gfdrgr7YX76Or8/cpik0W6+tj3rZ0uHU9Oil4tw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.24.1': + resolution: {integrity: sha512-pTHxDVa0BpUbvAgX3Gat+7cSciXqUcY9j2VZKTbSB6+VQGpNgNO9ailxTGHSXlqOnX1Hcx1Enme2+yv7VqP9bg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.24.1': + resolution: {integrity: sha512-LetvD7CrHmEx0G442gOomRr66d7q8HzzGGr4PMHGr+5YIm6++Yke+jxj246rpvsbyhJwCLxcTn6zW1P1BSenqA==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.24.1': + resolution: {integrity: sha512-sJwZBCzIBE4t+5Q4IGLaaun5ExVMRY0lYwos/jNecjMrVCygCdph3IKv0tkP5Fc87e/1+bebAmEAGBfnRD+cnw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-reserved-words@7.24.1': + resolution: {integrity: sha512-JAclqStUfIwKN15HrsQADFgeZt+wexNQ0uLhuqvqAUFoqPMjEcFCYZBhq0LUdz6dZK/mD+rErhW71fbx8RYElg==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.24.1': + resolution: {integrity: sha512-LyjVB1nsJ6gTTUKRjRWx9C1s9hE7dLfP/knKdrfeH9UPtAGjYGgxIbFfx7xyLIEWs7Xe1Gnf8EWiUqfjLhInZA==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.24.1': + resolution: {integrity: sha512-KjmcIM+fxgY+KxPVbjelJC6hrH1CgtPmTvdXAfn3/a9CnWGSTY7nH4zm5+cjmWJybdcPSsD0++QssDsjcpe47g==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.24.1': + resolution: {integrity: sha512-9v0f1bRXgPVcPrngOQvLXeGNNVLc8UjMVfebo9ka0WF3/7+aVUHmaJVT3sa0XCzEFioPfPHZiOcYG9qOsH63cw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.24.1': + resolution: {integrity: sha512-WRkhROsNzriarqECASCNu/nojeXCDTE/F2HmRgOzi7NGvyfYGq1NEjKBK3ckLfRgGc6/lPAqP0vDOSw3YtG34g==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.24.1': + resolution: {integrity: sha512-CBfU4l/A+KruSUoW+vTQthwcAdwuqbpRNB8HQKlZABwHRhsdHZ9fezp4Sn18PeAlYxTNiLMlx4xUBV3AWfg1BA==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.24.4': + resolution: {integrity: sha512-79t3CQ8+oBGk/80SQ8MN3Bs3obf83zJ0YZjDmDaEZN8MqhMI760apl5z6a20kFeMXBwJX99VpKT8CKxEBp5H1g==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.24.4.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.24.1': + resolution: {integrity: sha512-RlkVIcWT4TLI96zM660S877E7beKlQw7Ig+wqkKBiWfj0zH5Q4h50q6er4wzZKRNSYpfo6ILJ+hrJAGSX2qcNw==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.24.1': + resolution: {integrity: sha512-Ss4VvlfYV5huWApFsF8/Sq0oXnGO+jB+rijFEFugTd3cwSObUSnUi88djgR5528Csl0uKlrI331kRqe56Ov2Ng==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.24.1': + resolution: {integrity: sha512-2A/94wgZgxfTsiLaQ2E36XAOdcZmGAaEEgVmxQWwZXWkGhvoHbaqXcKnU8zny4ycpu3vNqg0L/PcCiYtHtA13g==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.24.1': + resolution: {integrity: sha512-fqj4WuzzS+ukpgerpAoOnMfQXwUHFxXUZUE84oL2Kao2N8uSlvcpnAidKASgsNgzZHBsHWvcm8s9FPWUhAb8fA==, tarball: https://registry.npmmirror.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.24.4': + resolution: {integrity: sha512-7Kl6cSmYkak0FK/FXjSEnLJ1N9T/WA2RkMhu17gZ/dsxKJUuTYNIylahPTzqpLyJN4WhDif8X0XK1R8Wsguo/A==, tarball: https://registry.npmmirror.com/@babel/preset-env/-/preset-env-7.24.4.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==, tarball: https://registry.npmmirror.com/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/preset-typescript@7.24.1': + resolution: {integrity: sha512-1DBaMmRDpuYQBPWD8Pf/WEwCrtgRHxsZnP4mIy9G/X+hFfbI47Q2G4t1Paakld84+qsk2fSsUPMKg71jkoOOaQ==, tarball: https://registry.npmmirror.com/@babel/preset-typescript/-/preset-typescript-7.24.1.tgz} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/regjsgen@0.8.0': + resolution: {integrity: sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==, tarball: https://registry.npmmirror.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz} + + '@babel/runtime-corejs3@7.24.4': + resolution: {integrity: sha512-VOQOexSilscN24VEY810G/PqtpFvx/z6UqDIjIWbDe2368HhDLkYN5TYwaEz/+eRCUkhJ2WaNLLmQAlxzfWj4w==, tarball: https://registry.npmmirror.com/@babel/runtime-corejs3/-/runtime-corejs3-7.24.4.tgz} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.24.4': + resolution: {integrity: sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==, tarball: https://registry.npmmirror.com/@babel/runtime/-/runtime-7.24.4.tgz} + engines: {node: '>=6.9.0'} + + '@babel/template@7.24.0': + resolution: {integrity: sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==, tarball: https://registry.npmmirror.com/@babel/template/-/template-7.24.0.tgz} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.24.1': + resolution: {integrity: sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==, tarball: https://registry.npmmirror.com/@babel/traverse/-/traverse-7.24.1.tgz} + engines: {node: '>=6.9.0'} + + '@babel/types@7.24.0': + resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==, tarball: https://registry.npmmirror.com/@babel/types/-/types-7.24.0.tgz} + engines: {node: '>=6.9.0'} + + '@bpmn-io/diagram-js-ui@0.2.3': + resolution: {integrity: sha512-OGyjZKvGK8tHSZ0l7RfeKhilGoOGtFDcoqSGYkX0uhFlo99OVZ9Jn1K7TJGzcE9BdKwvA5Y5kGqHEhdTxHvFfw==, tarball: https://registry.npmmirror.com/@bpmn-io/diagram-js-ui/-/diagram-js-ui-0.2.3.tgz} + + '@bpmn-io/element-templates-validator@0.2.0': + resolution: {integrity: sha512-/ogp0+6zUFdoiY09NYaHL5JtapB8zN1spG8hpML96qetXDCODRxnsqlHTvSwxtZHUDcgun+lxcK8b4wgtCP+6Q==, tarball: https://registry.npmmirror.com/@bpmn-io/element-templates-validator/-/element-templates-validator-0.2.0.tgz} + + '@bpmn-io/extract-process-variables@0.4.5': + resolution: {integrity: sha512-LtHx5b9xqS8avRLrq/uTlKhWzMeV3bWQKIdDic2bdo5n9roitX13GRb01u2S0hSsKDWEhXQtydFYN2b6G7bqfw==, tarball: https://registry.npmmirror.com/@bpmn-io/extract-process-variables/-/extract-process-variables-0.4.5.tgz} + + '@camunda/element-templates-json-schema@0.4.0': + resolution: {integrity: sha512-M5xW61ba7z2maBxfoT4c1bjuLD8OIL7863et/hULiNG6+R/B9CZ4Qze1juuIfXv4zpF2fYSuUsTPkTtiZrcspQ==, tarball: https://registry.npmmirror.com/@camunda/element-templates-json-schema/-/element-templates-json-schema-0.4.0.tgz} + + '@commitlint/cli@19.3.0': + resolution: {integrity: sha512-LgYWOwuDR7BSTQ9OLZ12m7F/qhNY+NpAyPBgo4YNMkACE7lGuUnuQq1yi9hz1KA4+3VqpOYl8H1rY/LYK43v7g==, tarball: https://registry.npmmirror.com/@commitlint/cli/-/cli-19.3.0.tgz} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@19.2.2': + resolution: {integrity: sha512-mLXjsxUVLYEGgzbxbxicGPggDuyWNkf25Ht23owXIH+zV2pv1eJuzLK3t1gDY5Gp6pxdE60jZnWUY5cvgL3ufw==, tarball: https://registry.npmmirror.com/@commitlint/config-conventional/-/config-conventional-19.2.2.tgz} + engines: {node: '>=v18'} + + '@commitlint/config-validator@19.0.3': + resolution: {integrity: sha512-2D3r4PKjoo59zBc2auodrSCaUnCSALCx54yveOFwwP/i2kfEAQrygwOleFWswLqK0UL/F9r07MFi5ev2ohyM4Q==, tarball: https://registry.npmmirror.com/@commitlint/config-validator/-/config-validator-19.0.3.tgz} + engines: {node: '>=v18'} + + '@commitlint/ensure@19.0.3': + resolution: {integrity: sha512-SZEpa/VvBLoT+EFZVb91YWbmaZ/9rPH3ESrINOl0HD2kMYsjvl0tF7nMHh0EpTcv4+gTtZBAe1y/SS6/OhfZzQ==, tarball: https://registry.npmmirror.com/@commitlint/ensure/-/ensure-19.0.3.tgz} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@19.0.0': + resolution: {integrity: sha512-mtsdpY1qyWgAO/iOK0L6gSGeR7GFcdW7tIjcNFxcWkfLDF5qVbPHKuGATFqRMsxcO8OUKNj0+3WOHB7EHm4Jdw==, tarball: https://registry.npmmirror.com/@commitlint/execute-rule/-/execute-rule-19.0.0.tgz} + engines: {node: '>=v18'} + + '@commitlint/format@19.3.0': + resolution: {integrity: sha512-luguk5/aF68HiF4H23ACAfk8qS8AHxl4LLN5oxPc24H+2+JRPsNr1OS3Gaea0CrH7PKhArBMKBz5RX9sA5NtTg==, tarball: https://registry.npmmirror.com/@commitlint/format/-/format-19.3.0.tgz} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@19.2.2': + resolution: {integrity: sha512-eNX54oXMVxncORywF4ZPFtJoBm3Tvp111tg1xf4zWXGfhBPKpfKG6R+G3G4v5CPlRROXpAOpQ3HMhA9n1Tck1g==, tarball: https://registry.npmmirror.com/@commitlint/is-ignored/-/is-ignored-19.2.2.tgz} + engines: {node: '>=v18'} + + '@commitlint/lint@19.2.2': + resolution: {integrity: sha512-xrzMmz4JqwGyKQKTpFzlN0dx0TAiT7Ran1fqEBgEmEj+PU98crOFtysJgY+QdeSagx6EDRigQIXJVnfrI0ratA==, tarball: https://registry.npmmirror.com/@commitlint/lint/-/lint-19.2.2.tgz} + engines: {node: '>=v18'} + + '@commitlint/load@19.2.0': + resolution: {integrity: sha512-XvxxLJTKqZojCxaBQ7u92qQLFMMZc4+p9qrIq/9kJDy8DOrEa7P1yx7Tjdc2u2JxIalqT4KOGraVgCE7eCYJyQ==, tarball: https://registry.npmmirror.com/@commitlint/load/-/load-19.2.0.tgz} + engines: {node: '>=v18'} + + '@commitlint/message@19.0.0': + resolution: {integrity: sha512-c9czf6lU+9oF9gVVa2lmKaOARJvt4soRsVmbR7Njwp9FpbBgste5i7l/2l5o8MmbwGh4yE1snfnsy2qyA2r/Fw==, tarball: https://registry.npmmirror.com/@commitlint/message/-/message-19.0.0.tgz} + engines: {node: '>=v18'} + + '@commitlint/parse@19.0.3': + resolution: {integrity: sha512-Il+tNyOb8VDxN3P6XoBBwWJtKKGzHlitEuXA5BP6ir/3loWlsSqDr5aecl6hZcC/spjq4pHqNh0qPlfeWu38QA==, tarball: https://registry.npmmirror.com/@commitlint/parse/-/parse-19.0.3.tgz} + engines: {node: '>=v18'} + + '@commitlint/read@19.2.1': + resolution: {integrity: sha512-qETc4+PL0EUv7Q36lJbPG+NJiBOGg7SSC7B5BsPWOmei+Dyif80ErfWQ0qXoW9oCh7GTpTNRoaVhiI8RbhuaNw==, tarball: https://registry.npmmirror.com/@commitlint/read/-/read-19.2.1.tgz} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@19.1.0': + resolution: {integrity: sha512-z2riI+8G3CET5CPgXJPlzftH+RiWYLMYv4C9tSLdLXdr6pBNimSKukYP9MS27ejmscqCTVA4almdLh0ODD2KYg==, tarball: https://registry.npmmirror.com/@commitlint/resolve-extends/-/resolve-extends-19.1.0.tgz} + engines: {node: '>=v18'} + + '@commitlint/rules@19.0.3': + resolution: {integrity: sha512-TspKb9VB6svklxNCKKwxhELn7qhtY1rFF8ls58DcFd0F97XoG07xugPjjbVnLqmMkRjZDbDIwBKt9bddOfLaPw==, tarball: https://registry.npmmirror.com/@commitlint/rules/-/rules-19.0.3.tgz} + engines: {node: '>=v18'} + + '@commitlint/to-lines@19.0.0': + resolution: {integrity: sha512-vkxWo+VQU5wFhiP9Ub9Sre0FYe019JxFikrALVoD5UGa8/t3yOJEpEhxC5xKiENKKhUkTpEItMTRAjHw2SCpZw==, tarball: https://registry.npmmirror.com/@commitlint/to-lines/-/to-lines-19.0.0.tgz} + engines: {node: '>=v18'} + + '@commitlint/top-level@19.0.0': + resolution: {integrity: sha512-KKjShd6u1aMGNkCkaX4aG1jOGdn7f8ZI8TR1VEuNqUOjWTOdcDSsmglinglJ18JTjuBX5I1PtjrhQCRcixRVFQ==, tarball: https://registry.npmmirror.com/@commitlint/top-level/-/top-level-19.0.0.tgz} + engines: {node: '>=v18'} + + '@commitlint/types@19.0.3': + resolution: {integrity: sha512-tpyc+7i6bPG9mvaBbtKUeghfyZSDgWquIDfMgqYtTbmZ9Y9VzEm2je9EYcQ0aoz5o7NvGS+rcDec93yO08MHYA==, tarball: https://registry.npmmirror.com/@commitlint/types/-/types-19.0.3.tgz} + engines: {node: '>=v18'} + + '@csstools/css-parser-algorithms@2.6.1': + resolution: {integrity: sha512-ubEkAaTfVZa+WwGhs5jbo5Xfqpeaybr/RvWzvFxRs4jfq16wH8l8Ty/QEEpINxll4xhuGfdMbipRyz5QZh9+FA==, tarball: https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.6.1.tgz} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + '@csstools/css-tokenizer': ^2.2.4 + + '@csstools/css-tokenizer@2.2.4': + resolution: {integrity: sha512-PuWRAewQLbDhGeTvFuq2oClaSCKPIBmHyIobCV39JHRYN0byDcUWJl5baPeNUcqrjtdMNqFooE0FGl31I3JOqw==, tarball: https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-2.2.4.tgz} + engines: {node: ^14 || ^16 || >=18} + + '@csstools/media-query-list-parser@2.1.9': + resolution: {integrity: sha512-qqGuFfbn4rUmyOB0u8CVISIp5FfJ5GAR3mBrZ9/TKndHakdnm6pY0L/fbLcpPnrzwCyyTEZl1nUcXAYHEWneTA==, tarball: https://registry.npmmirror.com/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.9.tgz} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + '@csstools/css-parser-algorithms': ^2.6.1 + '@csstools/css-tokenizer': ^2.2.4 + + '@csstools/selector-specificity@3.0.3': + resolution: {integrity: sha512-KEPNw4+WW5AVEIyzC80rTbWEUatTW2lXpN8+8ILC8PiPeWPjwUzrPZDIOZ2wwqDmeqOYTdSGyL3+vE5GC3FB3Q==, tarball: https://registry.npmmirror.com/@csstools/selector-specificity/-/selector-specificity-3.0.3.tgz} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss-selector-parser: ^6.0.13 + + '@ctrl/tinycolor@3.6.1': + resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==, tarball: https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz} + engines: {node: '>=10'} + + '@dual-bundle/import-meta-resolve@4.0.0': + resolution: {integrity: sha512-ZKXyJeFAzcpKM2kk8ipoGIPUqx9BX52omTGnfwjJvxOCaZTM2wtDK7zN0aIgPRbT9XYAlha0HtmZ+XKteuh0Gw==, tarball: https://registry.npmmirror.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz} + + '@element-plus/icons-vue@2.3.1': + resolution: {integrity: sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==, tarball: https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz} + peerDependencies: + vue: ^3.2.0 + + '@esbuild/aix-ppc64@0.19.12': + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==, tarball: https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.19.12': + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==, tarball: https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.19.12': + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==, tarball: https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.19.12': + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==, tarball: https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.19.12': + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==, tarball: https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.19.12': + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==, tarball: https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.19.12': + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==, tarball: https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.19.12': + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==, tarball: https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.19.12': + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==, tarball: https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.19.12': + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==, tarball: https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.19.12': + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==, tarball: https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.19.12': + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==, tarball: https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.19.12': + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==, tarball: https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.19.12': + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==, tarball: https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.19.12': + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==, tarball: https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.19.12': + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==, tarball: https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.19.12': + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==, tarball: https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.19.12': + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==, tarball: https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.19.12': + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==, tarball: https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.19.12': + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==, tarball: https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.19.12': + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==, tarball: https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.19.12': + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==, tarball: https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.19.12': + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==, tarball: https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==, tarball: https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.10.0': + resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==, tarball: https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==, tarball: https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.0': + resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==, tarball: https://registry.npmmirror.com/@eslint/js/-/js-8.57.0.tgz} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@floating-ui/core@1.6.1': + resolution: {integrity: sha512-42UH54oPZHPdRHdw6BgoBD6cg/eVTmVrFcgeRDM3jbO7uxSoipVcmcIGFcA5jmOHO5apcyvBhkSKES3fQJnu7A==, tarball: https://registry.npmmirror.com/@floating-ui/core/-/core-1.6.1.tgz} + + '@floating-ui/dom@1.6.4': + resolution: {integrity: sha512-0G8R+zOvQsAG1pg2Q99P21jiqxqGBW1iRe/iXHsBRBxnpXKFI8QwbB4x5KmYLggNO5m34IQgOIu9SCRfR/WWiQ==, tarball: https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.6.4.tgz} + + '@floating-ui/utils@0.2.2': + resolution: {integrity: sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==, tarball: https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.2.tgz} + + '@form-create/component-elm-checkbox@3.1.29': + resolution: {integrity: sha512-tzqpwg+lq1X/V1wEsOkHBC9QxZyUsoymFRrWiEdqvstRcTQKQjntt/3gl8MQ3Tcq22dP2xJbQxjTeu1J8o6NCA==, tarball: https://registry.npmmirror.com/@form-create/component-elm-checkbox/-/component-elm-checkbox-3.1.29.tgz} + + '@form-create/component-elm-frame@3.1.29': + resolution: {integrity: sha512-PqOQLvGwxvOssT/IHFrTVcptVvWYYwA5OCMUfoaXqSf9HBQk++EwLd5sdsvAY6ILJMSIa9zHU8tuvSXFOWbg3w==, tarball: https://registry.npmmirror.com/@form-create/component-elm-frame/-/component-elm-frame-3.1.29.tgz} + + '@form-create/component-elm-group@3.1.29': + resolution: {integrity: sha512-Hn4l0k1A/noqX3OWgPOnYV7OAAOlH/vNQFwxS9a/18QKuCpLU03MNeanPxwSQY1NL6Fk8NPQGBDcgeOBAcP9hg==, tarball: https://registry.npmmirror.com/@form-create/component-elm-group/-/component-elm-group-3.1.29.tgz} + + '@form-create/component-elm-radio@3.1.29': + resolution: {integrity: sha512-AGAOb/T02uDaRPUhDUBe4iSM5uR24TftiOjxHjG1nGtfjRtP1WMsSpH5JcFrRTDMnSmw/YO6/5j7QiYyzpoj6g==, tarball: https://registry.npmmirror.com/@form-create/component-elm-radio/-/component-elm-radio-3.1.29.tgz} + + '@form-create/component-elm-select@3.1.29': + resolution: {integrity: sha512-3oSj5zpDIpdtBD53EOMgFsz5+DE7zRuSLHjaRjiQaYhn7HwZeq02TNQ+t+d5vMjtcc/CfDlPEckbBwg2YNIGFA==, tarball: https://registry.npmmirror.com/@form-create/component-elm-select/-/component-elm-select-3.1.29.tgz} + + '@form-create/component-elm-tree@3.1.29': + resolution: {integrity: sha512-aEu62P7VrgzVOuOigRGral8k5PsNQbtbJxZ6dd8cdbHxTsVVyh1rYTMAtNceR84DZYuRfth1H/lxU0JHRJT0rQ==, tarball: https://registry.npmmirror.com/@form-create/component-elm-tree/-/component-elm-tree-3.1.29.tgz} + + '@form-create/component-elm-upload@3.1.29': + resolution: {integrity: sha512-drYhUf7yRBKzAzPp5Mgb1A3Ik+vBmzGD9gGf61wsD7+iiQR9vC6LIF8bGUrzX99DqXLrGP++Xxb9Iii63srmKA==, tarball: https://registry.npmmirror.com/@form-create/component-elm-upload/-/component-elm-upload-3.1.29.tgz} + + '@form-create/component-subform@3.1.5': + resolution: {integrity: sha512-JHNEFGuwpnjGvCJ0I0GCqPL5al0qXoN4ymnRBpm+oL+6MMo5bz1kUyoqMX1MutuC96gHTqpeqc67hssi8g2mIw==, tarball: https://registry.npmmirror.com/@form-create/component-subform/-/component-subform-3.1.5.tgz} + + '@form-create/component-wangeditor@3.1.20': + resolution: {integrity: sha512-lAjpltmYfr3a2AeXasCehGsZNL/1WB6vWqqV9TIsJ4pleTr0/D/oPwEYQjfv+gG+NoB2Sa25SRGhtlnephjyhg==, tarball: https://registry.npmmirror.com/@form-create/component-wangeditor/-/component-wangeditor-3.1.20.tgz} + + '@form-create/core@3.1.29': + resolution: {integrity: sha512-nPFFdiEmATIKeocnP8pubKSwMSegc+tcN5PU+cSuXl5RJ1w3k0UZr80Dx2yPUmw8sv4XwSMmMUkHUojz10hqFg==, tarball: https://registry.npmmirror.com/@form-create/core/-/core-3.1.29.tgz} + peerDependencies: + vue: ^3.1.0 + + '@form-create/designer@3.1.5': + resolution: {integrity: sha512-OSBXW8PfL9OpckCHA7VQ87HR1WOlzfJMz9mnDiMLjbb8Pkh6oYfAohZCuMCs+S68jW8eKaDjw977wBrKXqiylA==, tarball: https://registry.npmmirror.com/@form-create/designer/-/designer-3.1.5.tgz} + + '@form-create/element-ui@3.1.29': + resolution: {integrity: sha512-gG6RViw8/ZY72COHB2soNfiaoS55Il3gJ9C3lQ/J/8VccR3u6DtcK43ZoP5salQYxjQOFLyQmQidFQtmyphpgg==, tarball: https://registry.npmmirror.com/@form-create/element-ui/-/element-ui-3.1.29.tgz} + peerDependencies: + vue: ^3.1.0 + + '@form-create/utils@3.1.29': + resolution: {integrity: sha512-CsD3htq2qyuvqc3kJipUk2OFZA5eg+Fwna9zZPoi8T8UuEKBkfgR5fp2s0AgZ87i2a5NgwCk87kfVntijnxvPw==, tarball: https://registry.npmmirror.com/@form-create/utils/-/utils-3.1.29.tgz} + + '@gera2ld/jsx-dom@2.2.2': + resolution: {integrity: sha512-EOqf31IATRE6zS1W1EoWmXZhGfLAoO9FIlwTtHduSrBdud4npYBxYAkv8dZ5hudDPwJeeSjn40kbCL4wAzr8dA==, tarball: https://registry.npmmirror.com/@gera2ld/jsx-dom/-/jsx-dom-2.2.2.tgz} + + '@humanwhocodes/config-array@0.11.14': + resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==, tarball: https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz} + engines: {node: '>=10.10.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==, tarball: https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==, tarball: https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz} + + '@iconify/iconify@2.1.2': + resolution: {integrity: sha512-QcUzFeEWkE/mW+BVtEGmcWATClcCOIJFiYUD/PiCWuTcdEA297o8D4oN6Ra44WrNOHu1wqNW4J0ioaDIiqaFOQ==, tarball: https://registry.npmmirror.com/@iconify/iconify/-/iconify-2.1.2.tgz} + deprecated: no longer maintained, switch to modern iconify-icon web component + + '@iconify/iconify@3.1.1': + resolution: {integrity: sha512-1nemfyD/OJzh9ALepH7YfuuP8BdEB24Skhd8DXWh0hzcOxImbb1ZizSZkpCzAwSZSGcJFmscIBaBQu+yLyWaxQ==, tarball: https://registry.npmmirror.com/@iconify/iconify/-/iconify-3.1.1.tgz} + deprecated: no longer maintained, switch to modern iconify-icon web component + + '@iconify/json@2.2.205': + resolution: {integrity: sha512-79DbcI0U40w6jCYADjhSheJ6SVB/FJG/z0ltnqdHF/uRi6/MLroqe7y9Qy+99Ebb6F2WZgVV+TXfFMMORMPXFw==, tarball: https://registry.npmmirror.com/@iconify/json/-/json-2.2.205.tgz} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==, tarball: https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz} + + '@iconify/utils@2.1.23': + resolution: {integrity: sha512-YGNbHKM5tyDvdWZ92y2mIkrfvm5Fvhe6WJSkWu7vvOFhMtYDP0casZpoRz0XEHZCrYsR4stdGT3cZ52yp5qZdQ==, tarball: https://registry.npmmirror.com/@iconify/utils/-/utils-2.1.23.tgz} + + '@intlify/bundle-utils@7.5.1': + resolution: {integrity: sha512-UovJl10oBIlmYEcWw+VIHdKY5Uv5sdPG0b/b6bOYxGLln3UwB75+2dlc0F3Fsa0RhoznQ5Rp589/BZpABpE4Xw==, tarball: https://registry.npmmirror.com/@intlify/bundle-utils/-/bundle-utils-7.5.1.tgz} + engines: {node: '>= 14.16'} + peerDependencies: + petite-vue-i18n: '*' + vue-i18n: '*' + peerDependenciesMeta: + petite-vue-i18n: + optional: true + vue-i18n: + optional: true + + '@intlify/core-base@9.10.2': + resolution: {integrity: sha512-HGStVnKobsJL0DoYIyRCGXBH63DMQqEZxDUGrkNI05FuTcruYUtOAxyL3zoAZu/uDGO6mcUvm3VXBaHG2GdZCg==, tarball: https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.10.2.tgz} + engines: {node: '>= 16'} + + '@intlify/message-compiler@9.10.2': + resolution: {integrity: sha512-ntY/kfBwQRtX5Zh6wL8cSATujPzWW2ZQd1QwKyWwAy5fMqJyyixHMeovN4fmEyCqSu+hFfYOE63nU94evsy4YA==, tarball: https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.10.2.tgz} + engines: {node: '>= 16'} + + '@intlify/message-compiler@9.13.1': + resolution: {integrity: sha512-SKsVa4ajYGBVm7sHMXd5qX70O2XXjm55zdZB3VeMFCvQyvLew/dLvq3MqnaIsTMF1VkkOb9Ttr6tHcMlyPDL9w==, tarball: https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.13.1.tgz} + engines: {node: '>= 16'} + + '@intlify/shared@9.10.2': + resolution: {integrity: sha512-ttHCAJkRy7R5W2S9RVnN9KYQYPIpV2+GiS79T4EE37nrPyH6/1SrOh3bmdCRC1T3ocL8qCDx7x2lBJ0xaITU7Q==, tarball: https://registry.npmmirror.com/@intlify/shared/-/shared-9.10.2.tgz} + engines: {node: '>= 16'} + + '@intlify/shared@9.13.1': + resolution: {integrity: sha512-u3b6BKGhE6j/JeRU6C/RL2FgyJfy6LakbtfeVF8fJXURpZZTzfh3e05J0bu0XPw447Q6/WUp3C4ajv4TMS4YsQ==, tarball: https://registry.npmmirror.com/@intlify/shared/-/shared-9.13.1.tgz} + engines: {node: '>= 16'} + + '@intlify/unplugin-vue-i18n@2.0.0': + resolution: {integrity: sha512-1oKvm92L9l2od2H9wKx2ZvR4tzn7gUtd7bPLI7AWUmm7U9H1iEypndt5d985ypxGsEs0gToDaKTrytbBIJwwSg==, tarball: https://registry.npmmirror.com/@intlify/unplugin-vue-i18n/-/unplugin-vue-i18n-2.0.0.tgz} + engines: {node: '>= 14.16'} + peerDependencies: + petite-vue-i18n: '*' + vue-i18n: '*' + vue-i18n-bridge: '*' + peerDependenciesMeta: + petite-vue-i18n: + optional: true + vue-i18n: + optional: true + vue-i18n-bridge: + optional: true + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==, tarball: https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz} + engines: {node: '>=12'} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==, tarball: https://registry.npmmirror.com/@jest/schemas/-/schemas-29.6.3.tgz} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==, tarball: https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, tarball: https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==, tarball: https://registry.npmmirror.com/@jridgewell/set-array/-/set-array-1.2.1.tgz} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.6': + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==, tarball: https://registry.npmmirror.com/@jridgewell/source-map/-/source-map-0.3.6.tgz} + + '@jridgewell/sourcemap-codec@1.4.15': + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==, tarball: https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==, tarball: https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz} + + '@microsoft/fetch-event-source@2.0.1': + resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==, tarball: https://registry.npmmirror.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, tarball: https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==, tarball: https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, tarball: https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz} + engines: {node: '>= 8'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz} + engines: {node: '>=14'} + + '@pkgr/core@0.1.1': + resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==, tarball: https://registry.npmmirror.com/@pkgr/core/-/core-0.1.1.tgz} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@polka/url@1.0.0-next.25': + resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==, tarball: https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.25.tgz} + + '@purge-icons/core@0.10.0': + resolution: {integrity: sha512-AtJbZv5Yy+vWX5v32DPTr+CW7AkSK8HJx52orDbrYt/9s4lGM2t4KKAmwaTQEH2HYr2HVh1mlqs54/S1s3WT1g==, tarball: https://registry.npmmirror.com/@purge-icons/core/-/core-0.10.0.tgz} + + '@purge-icons/generated@0.10.0': + resolution: {integrity: sha512-I+1yN7/yDy/eZzfhAZqKF8Z6FM8D/O1vempbPrHJ0m9HlZwvf8sWXOArPJ2qRQGB6mJUVSpaXkoGBuoz1GQX5A==, tarball: https://registry.npmmirror.com/@purge-icons/generated/-/generated-0.10.0.tgz} + + '@purge-icons/generated@0.9.0': + resolution: {integrity: sha512-s2t+1oVtGDV6KtqfCXtUOhxfeYvOdDF90IVm+nMs/6bUP0HeGZLslguuL/AibpwtfL4FA/oCsIu/RhwapgAdJw==, tarball: https://registry.npmmirror.com/@purge-icons/generated/-/generated-0.9.0.tgz} + + '@rollup/plugin-virtual@3.0.2': + resolution: {integrity: sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==, tarball: https://registry.npmmirror.com/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@4.2.1': + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==, tarball: https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz} + engines: {node: '>= 8.0.0'} + + '@rollup/pluginutils@5.1.0': + resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==, tarball: https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.17.1': + resolution: {integrity: sha512-P6Wg856Ou/DLpR+O0ZLneNmrv7QpqBg+hK4wE05ijbC/t349BRfMfx+UFj5Ha3fCFopIa6iSZlpdaB4agkWp2Q==, tarball: https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.1.tgz} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.17.1': + resolution: {integrity: sha512-piwZDjuW2WiHr05djVdUkrG5JbjnGbtx8BXQchYCMfib/nhjzWoiScelZ+s5IJI7lecrwSxHCzW026MWBL+oJQ==, tarball: https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.1.tgz} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.17.1': + resolution: {integrity: sha512-LsZXXIsN5Q460cKDT4Y+bzoPDhBmO5DTr7wP80d+2EnYlxSgkwdPfE3hbE+Fk8dtya+8092N9srjBTJ0di8RIA==, tarball: https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.1.tgz} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.17.1': + resolution: {integrity: sha512-S7TYNQpWXB9APkxu/SLmYHezWwCoZRA9QLgrDeml+SR2A1LLPD2DBUdUlvmCF7FUpRMKvbeeWky+iizQj65Etw==, tarball: https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.1.tgz} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-linux-arm-gnueabihf@4.17.1': + resolution: {integrity: sha512-Lq2JR5a5jsA5um2ZoLiXXEaOagnVyCpCW7xvlcqHC7y46tLwTEgUSTM3a2TfmmTMmdqv+jknUioWXlmxYxE9Yw==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.1.tgz} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.17.1': + resolution: {integrity: sha512-9BfzwyPNV0IizQoR+5HTNBGkh1KXE8BqU0DBkqMngmyFW7BfuIZyMjQ0s6igJEiPSBvT3ZcnIFohZ19OqjhDPg==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.1.tgz} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.17.1': + resolution: {integrity: sha512-e2uWaoxo/rtzA52OifrTSXTvJhAXb0XeRkz4CdHBK2KtxrFmuU/uNd544Ogkpu938BzEfvmWs8NZ8Axhw33FDw==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.1.tgz} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.17.1': + resolution: {integrity: sha512-ekggix/Bc/d/60H1Mi4YeYb/7dbal1kEDZ6sIFVAE8pUSx7PiWeEh+NWbL7bGu0X68BBIkgF3ibRJe1oFTksQQ==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.1.tgz} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-powerpc64le-gnu@4.17.1': + resolution: {integrity: sha512-UGV0dUo/xCv4pkr/C8KY7XLFwBNnvladt8q+VmdKrw/3RUd3rD0TptwjisvE2TTnnlENtuY4/PZuoOYRiGp8Gw==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.1.tgz} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-gnu@4.17.1': + resolution: {integrity: sha512-gEYmYYHaehdvX46mwXrU49vD6Euf1Bxhq9pPb82cbUU9UT2NV+RSckQ5tKWOnNXZixKsy8/cPGtiUWqzPuAcXQ==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.1.tgz} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-s390x-gnu@4.17.1': + resolution: {integrity: sha512-xeae5pMAxHFp6yX5vajInG2toST5lsCTrckSRUFwNgzYqnUjNBcQyqk1bXUxX5yhjWFl2Mnz3F8vQjl+2FRIcw==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.1.tgz} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.17.1': + resolution: {integrity: sha512-AsdnINQoDWfKpBzCPqQWxSPdAWzSgnYbrJYtn6W0H2E9It5bZss99PiLA8CgmDRfvKygt20UpZ3xkhFlIfX9zQ==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.1.tgz} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.17.1': + resolution: {integrity: sha512-KoB4fyKXTR+wYENkIG3fFF+5G6N4GFvzYx8Jax8BR4vmddtuqSb5oQmYu2Uu067vT/Fod7gxeQYKupm8gAcMSQ==, tarball: https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.1.tgz} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-win32-arm64-msvc@4.17.1': + resolution: {integrity: sha512-J0d3NVNf7wBL9t4blCNat+d0PYqAx8wOoY+/9Q5cujnafbX7BmtYk3XvzkqLmFECaWvXGLuHmKj/wrILUinmQg==, tarball: https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.1.tgz} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.17.1': + resolution: {integrity: sha512-xjgkWUwlq7IbgJSIxvl516FJ2iuC/7ttjsAxSPpC9kkI5iQQFHKyEN5BjbhvJ/IXIZ3yIBcW5QDlWAyrA+TFag==, tarball: https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.1.tgz} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.17.1': + resolution: {integrity: sha512-0QbCkfk6cnnVKWqqlC0cUrrUMDMfu5ffvYMTUHf+qMN2uAb3MKP31LPcwiMXBNsvoFGs/kYdFOsuLmvppCopXA==, tarball: https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.1.tgz} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==, tarball: https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.27.8.tgz} + + '@swc/core-darwin-arm64@1.4.17': + resolution: {integrity: sha512-HVl+W4LezoqHBAYg2JCqR+s9ife9yPfgWSj37iIawLWzOmuuJ7jVdIB7Ee2B75bEisSEKyxRlTl6Y1Oq3owBgw==, tarball: https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.4.17.tgz} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.4.17': + resolution: {integrity: sha512-WYRO9Fdzq4S/he8zjW5I95G1zcvyd9yyD3Tgi4/ic84P5XDlSMpBDpBLbr/dCPjmSg7aUXxNQqKqGkl6dQxYlA==, tarball: https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.4.17.tgz} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.4.17': + resolution: {integrity: sha512-cgbvpWOvtMH0XFjvwppUCR+Y+nf6QPaGu6AQ5hqCP+5Lv2zO5PG0RfasC4zBIjF53xgwEaaWmGP5/361P30X8Q==, tarball: https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.4.17.tgz} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.4.17': + resolution: {integrity: sha512-l7zHgaIY24cF9dyQ/FOWbmZDsEj2a9gRFbmgx2u19e3FzOPuOnaopFj0fRYXXKCmtdx+anD750iBIYnTR+pq/Q==, tarball: https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.4.17.tgz} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.4.17': + resolution: {integrity: sha512-qhH4gr9gAlVk8MBtzXbzTP3BJyqbAfUOATGkyUtohh85fPXQYuzVlbExix3FZXTwFHNidGHY8C+ocscI7uDaYw==, tarball: https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.4.17.tgz} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-x64-gnu@1.4.17': + resolution: {integrity: sha512-vRDFATL1oN5oZMImkwbgSHEkp8xG1ofEASBypze01W1Tqto8t+yo6gsp69wzCZBlxldsvPpvFZW55Jq0Rn+UnA==, tarball: https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.4.17.tgz} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.4.17': + resolution: {integrity: sha512-zQNPXAXn3nmPqv54JVEN8k2JMEcMTQ6veVuU0p5O+A7KscJq+AGle/7ZQXzpXSfUCXlLMX4wvd+rwfGhh3J4cw==, tarball: https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.4.17.tgz} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.4.17': + resolution: {integrity: sha512-z86n7EhOwyzxwm+DLE5NoLkxCTme2lq7QZlDjbQyfCxOt6isWz8rkW5QowTX8w9Rdmk34ncrjSLvnHOeLY17+w==, tarball: https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.4.17.tgz} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.4.17': + resolution: {integrity: sha512-JBwuSTJIgiJJX6wtr4wmXbfvOswHFj223AumUrK544QV69k60FJ9q2adPW9Csk+a8wm1hLxq4HKa2K334UHJ/g==, tarball: https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.4.17.tgz} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.4.17': + resolution: {integrity: sha512-jFkOnGQamtVDBm3MF5Kq1lgW8vx4Rm1UvJWRUfg+0gx7Uc3Jp3QMFeMNw/rDNQYRDYPG3yunCC+2463ycd5+dg==, tarball: https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.4.17.tgz} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.4.17': + resolution: {integrity: sha512-tq+mdWvodMBNBBZbwFIMTVGYHe9N7zvEaycVVjfvAx20k1XozHbHhRv+9pEVFJjwRxLdXmtvFZd3QZHRAOpoNQ==, tarball: https://registry.npmmirror.com/@swc/core/-/core-1.4.17.tgz} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': ^0.5.0 + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==, tarball: https://registry.npmmirror.com/@swc/counter/-/counter-0.1.3.tgz} + + '@swc/types@0.1.6': + resolution: {integrity: sha512-/JLo/l2JsT/LRd80C3HfbmVpxOAJ11FO2RCEslFrgzLltoP9j8XIbsyDcfCt2WWyX+CM96rBoNM+IToAkFOugg==, tarball: https://registry.npmmirror.com/@swc/types/-/types-0.1.6.tgz} + + '@sxzz/popperjs-es@2.11.7': + resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==, tarball: https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz} + + '@transloadit/prettier-bytes@0.0.7': + resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==, tarball: https://registry.npmmirror.com/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz} + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==, tarball: https://registry.npmmirror.com/@trysound/sax/-/sax-0.2.0.tgz} + engines: {node: '>=10.13.0'} + + '@types/conventional-commits-parser@5.0.0': + resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==, tarball: https://registry.npmmirror.com/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz} + + '@types/d3-array@3.2.1': + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==, tarball: https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.1.tgz} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==, tarball: https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==, tarball: https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==, tarball: https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==, tarball: https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==, tarball: https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==, tarball: https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz} + + '@types/d3-dispatch@3.0.6': + resolution: {integrity: sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==, tarball: https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==, tarball: https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==, tarball: https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==, tarball: https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==, tarball: https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==, tarball: https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==, tarball: https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==, tarball: https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==, tarball: https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==, tarball: https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz} + + '@types/d3-path@3.1.0': + resolution: {integrity: sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==, tarball: https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.0.tgz} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==, tarball: https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==, tarball: https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==, tarball: https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz} + + '@types/d3-scale-chromatic@3.0.3': + resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==, tarball: https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz} + + '@types/d3-scale@4.0.8': + resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==, tarball: https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.8.tgz} + + '@types/d3-selection@3.0.10': + resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==, tarball: https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.10.tgz} + + '@types/d3-shape@3.1.6': + resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==, tarball: https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.6.tgz} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==, tarball: https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz} + + '@types/d3-time@3.0.3': + resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==, tarball: https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.3.tgz} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==, tarball: https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz} + + '@types/d3-transition@3.0.8': + resolution: {integrity: sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==, tarball: https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.8.tgz} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==, tarball: https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==, tarball: https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz} + + '@types/eslint@8.56.10': + resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==, tarball: https://registry.npmmirror.com/@types/eslint/-/eslint-8.56.10.tgz} + + '@types/estree@1.0.5': + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==, tarball: https://registry.npmmirror.com/@types/estree/-/estree-1.0.5.tgz} + + '@types/event-emitter@0.3.5': + resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==, tarball: https://registry.npmmirror.com/@types/event-emitter/-/event-emitter-0.3.5.tgz} + + '@types/geojson@7946.0.14': + resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==, tarball: https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.14.tgz} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, tarball: https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==, tarball: https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz} + + '@types/lodash@4.17.0': + resolution: {integrity: sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==, tarball: https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.0.tgz} + + '@types/node@10.17.60': + resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==, tarball: https://registry.npmmirror.com/@types/node/-/node-10.17.60.tgz} + + '@types/node@20.12.7': + resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==, tarball: https://registry.npmmirror.com/@types/node/-/node-20.12.7.tgz} + + '@types/nprogress@0.2.3': + resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==, tarball: https://registry.npmmirror.com/@types/nprogress/-/nprogress-0.2.3.tgz} + + '@types/qrcode@1.5.5': + resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==, tarball: https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.5.tgz} + + '@types/qs@6.9.15': + resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==, tarball: https://registry.npmmirror.com/@types/qs/-/qs-6.9.15.tgz} + + '@types/semver@7.5.8': + resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==, tarball: https://registry.npmmirror.com/@types/semver/-/semver-7.5.8.tgz} + + '@types/svgo@2.6.4': + resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==, tarball: https://registry.npmmirror.com/@types/svgo/-/svgo-2.6.4.tgz} + + '@types/video.js@7.3.58': + resolution: {integrity: sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==, tarball: https://registry.npmmirror.com/@types/video.js/-/video.js-7.3.58.tgz} + + '@types/web-bluetooth@0.0.16': + resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==, tarball: https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz} + + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==, tarball: https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz} + + '@typescript-eslint/eslint-plugin@7.7.1': + resolution: {integrity: sha512-KwfdWXJBOviaBVhxO3p5TJiLpNuh2iyXyjmWN0f1nU87pwyvfS0EmjC6ukQVYVFJd/K1+0NWGPDXiyEyQorn0Q==, tarball: https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.1.tgz} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==, tarball: https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-6.21.0.tgz} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@7.7.1': + resolution: {integrity: sha512-vmPzBOOtz48F6JAGVS/kZYk4EkXao6iGrD838sp1w3NQQC0W8ry/q641KU4PrG7AKNAf56NOcR8GOpH8l9FPCw==, tarball: https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.7.1.tgz} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==, tarball: https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/scope-manager@7.7.1': + resolution: {integrity: sha512-PytBif2SF+9SpEUKynYn5g1RHFddJUcyynGpztX3l/ik7KmZEv19WCMhUBkHXPU9es/VWGD3/zg3wg90+Dh2rA==, tarball: https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-7.7.1.tgz} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/type-utils@7.7.1': + resolution: {integrity: sha512-ZksJLW3WF7o75zaBPScdW1Gbkwhd/lyeXGf1kQCxJaOeITscoSl0MjynVvCzuV5boUz/3fOI06Lz8La55mu29Q==, tarball: https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-7.7.1.tgz} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==, tarball: https://registry.npmmirror.com/@typescript-eslint/types/-/types-6.21.0.tgz} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/types@7.7.1': + resolution: {integrity: sha512-AmPmnGW1ZLTpWa+/2omPrPfR7BcbUU4oha5VIbSbS1a1Tv966bklvLNXxp3mrbc+P2j4MNOTfDffNsk4o0c6/w==, tarball: https://registry.npmmirror.com/@typescript-eslint/types/-/types-7.7.1.tgz} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==, tarball: https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/typescript-estree@7.7.1': + resolution: {integrity: sha512-CXe0JHCXru8Fa36dteXqmH2YxngKJjkQLjxzoj6LYwzZ7qZvgsLSc+eqItCrqIop8Vl2UKoAi0StVWu97FQZIQ==, tarball: https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.1.tgz} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==, tarball: https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-6.21.0.tgz} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/utils@7.7.1': + resolution: {integrity: sha512-QUvBxPEaBXf41ZBbaidKICgVL8Hin0p6prQDu6bbetWo39BKbWJxRsErOzMNT1rXvTll+J7ChrbmMCXM9rsvOQ==, tarball: https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-7.7.1.tgz} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==, tarball: https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/visitor-keys@7.7.1': + resolution: {integrity: sha512-gBL3Eq25uADw1LQ9kVpf3hRM+DWzs0uZknHYK3hq4jcTPqVCClHGDnB6UUUV2SFeBeA4KWHWbbLqmbGcZ4FYbw==, tarball: https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.1.tgz} + engines: {node: ^18.18.0 || >=20.0.0} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==, tarball: https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz} + + '@unocss/astro@0.58.9': + resolution: {integrity: sha512-VWfHNC0EfawFxLfb3uI+QcMGBN+ju+BYtutzeZTjilLKj31X2UpqIh8fepixL6ljgZzB3fweqg2xtUMC0gMnoQ==, tarball: https://registry.npmmirror.com/@unocss/astro/-/astro-0.58.9.tgz} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 + peerDependenciesMeta: + vite: + optional: true + + '@unocss/cli@0.58.9': + resolution: {integrity: sha512-q7qlwX3V6UaqljWUQ5gMj36yTA9eLuuRywahdQWt1ioy4aPF/MEEfnMBZf/ntrqf5tIT5TO8fE11nvCco2Q/sA==, tarball: https://registry.npmmirror.com/@unocss/cli/-/cli-0.58.9.tgz} + engines: {node: '>=14'} + hasBin: true + + '@unocss/config@0.57.7': + resolution: {integrity: sha512-UG8G9orWEdk/vyDvGUToXYn/RZy/Qjpx66pLsaf5wQK37hkYsBoReAU5v8Ia/6PL1ueJlkcNXLaNpN6/yVoJvg==, tarball: https://registry.npmmirror.com/@unocss/config/-/config-0.57.7.tgz} + engines: {node: '>=14'} + + '@unocss/config@0.58.9': + resolution: {integrity: sha512-90wRXIyGNI8UenWxvHUcH4l4rgq813MsTzYWsf6ZKyLLvkFjV2b2EfGXI27GPvZ7fVE1OAqx+wJNTw8CyQxwag==, tarball: https://registry.npmmirror.com/@unocss/config/-/config-0.58.9.tgz} + engines: {node: '>=14'} + + '@unocss/core@0.57.7': + resolution: {integrity: sha512-1d36M0CV3yC80J0pqOa5rH1BX6g2iZdtKmIb3oSBN4AWnMCSrrJEPBrUikyMq2TEQTrYWJIVDzv5A9hBUat3TA==, tarball: https://registry.npmmirror.com/@unocss/core/-/core-0.57.7.tgz} + + '@unocss/core@0.58.9': + resolution: {integrity: sha512-wYpPIPPsOIbIoMIDuH8ihehJk5pAZmyFKXIYO/Kro98GEOFhz6lJoLsy6/PZuitlgp2/TSlubUuWGjHWvp5osw==, tarball: https://registry.npmmirror.com/@unocss/core/-/core-0.58.9.tgz} + + '@unocss/eslint-config@0.57.7': + resolution: {integrity: sha512-EJlI6rV0ZfDCphIiddHSWZVeoHdYDTVohVXGo+NfNOuRuvYWGna3n4hY3VEAiT3mWLK0/0anzHF7X0PNzCR5lQ==, tarball: https://registry.npmmirror.com/@unocss/eslint-config/-/eslint-config-0.57.7.tgz} + engines: {node: '>=14'} + + '@unocss/eslint-plugin@0.57.7': + resolution: {integrity: sha512-nwj7UJF7wCfPVl5B7cUB0xrSk6yuVMdMgABnsy4N5xBlds8cclrUO+boaTB9qzh8Lg9nfJVLB3+cW3po2SJoew==, tarball: https://registry.npmmirror.com/@unocss/eslint-plugin/-/eslint-plugin-0.57.7.tgz} + engines: {node: '>=14'} + + '@unocss/extractor-arbitrary-variants@0.58.9': + resolution: {integrity: sha512-M/BvPdbEEMdhcFQh/z2Bf9gylO1Ky/ZnpIvKWS1YJPLt4KA7UWXSUf+ZNTFxX+X58Is5qAb5hNh/XBQmL3gbXg==, tarball: https://registry.npmmirror.com/@unocss/extractor-arbitrary-variants/-/extractor-arbitrary-variants-0.58.9.tgz} + + '@unocss/inspector@0.58.9': + resolution: {integrity: sha512-uRzqkCNeBmEvFePXcfIFcQPMlCXd9/bLwa5OkBthiOILwQdH1uRIW3GWAa2SWspu+kZLP0Ly3SjZ9Wqi+5ZtTw==, tarball: https://registry.npmmirror.com/@unocss/inspector/-/inspector-0.58.9.tgz} + + '@unocss/postcss@0.58.9': + resolution: {integrity: sha512-PnKmH6Qhimw35yO6u6yx9SHaX2NmvbRNPDvMDHA/1xr3M8L0o8U88tgKbWfm65NEGF3R1zJ9A8rjtZn/LPkgPA==, tarball: https://registry.npmmirror.com/@unocss/postcss/-/postcss-0.58.9.tgz} + engines: {node: '>=14'} + peerDependencies: + postcss: ^8.4.21 + + '@unocss/preset-attributify@0.58.9': + resolution: {integrity: sha512-ucP+kXRFcwmBmHohUVv31bE/SejMAMo7Hjb0QcKVLyHlzRWUJsfNR+jTAIGIUSYxN7Q8MeigYsongGo3nIeJnQ==, tarball: https://registry.npmmirror.com/@unocss/preset-attributify/-/preset-attributify-0.58.9.tgz} + + '@unocss/preset-icons@0.58.9': + resolution: {integrity: sha512-9dS48+yAunsbS0ylOW2Wisozwpn3nGY1CqTiidkUnrMnrZK3al579A7srUX9NyPWWDjprO7eU/JkWbdDQSmFFA==, tarball: https://registry.npmmirror.com/@unocss/preset-icons/-/preset-icons-0.58.9.tgz} + + '@unocss/preset-mini@0.58.9': + resolution: {integrity: sha512-m4aDGYtueP8QGsU3FsyML63T/w5Mtr4htme2jXy6m50+tzC1PPHaIBstMTMQfLc6h8UOregPJyGHB5iYQZGEvQ==, tarball: https://registry.npmmirror.com/@unocss/preset-mini/-/preset-mini-0.58.9.tgz} + + '@unocss/preset-tagify@0.58.9': + resolution: {integrity: sha512-obh75XrRmxYwrQMflzvhQUMeHwd/R9bEDhTWUW9aBTolBy4eNypmQwOhHCKh5Xi4Dg6o0xj6GWC/jcCj1SPLog==, tarball: https://registry.npmmirror.com/@unocss/preset-tagify/-/preset-tagify-0.58.9.tgz} + + '@unocss/preset-typography@0.58.9': + resolution: {integrity: sha512-hrsaqKlcZni3Vh4fwXC+lP9e92FQYbqtmlZw2jpxlVwwH5aLzwk4d4MiFQGyhCfzuSDYm0Zd52putFVV02J7bA==, tarball: https://registry.npmmirror.com/@unocss/preset-typography/-/preset-typography-0.58.9.tgz} + + '@unocss/preset-uno@0.58.9': + resolution: {integrity: sha512-Fze+X2Z/EegCkRdDRgwwvFBmXBenNR1AG8KxAyz8iPeWbhOBaRra2sn2ScryrfH6SbJHpw26ZyJXycAdS0Fq3A==, tarball: https://registry.npmmirror.com/@unocss/preset-uno/-/preset-uno-0.58.9.tgz} + + '@unocss/preset-web-fonts@0.58.9': + resolution: {integrity: sha512-XtiO+Z+RYnNYomNkS2XxaQiY++CrQZKOfNGw5htgIrb32QtYVQSkyYQ3jDw7JmMiCWlZ4E72cV/zUb++WrZLxg==, tarball: https://registry.npmmirror.com/@unocss/preset-web-fonts/-/preset-web-fonts-0.58.9.tgz} + + '@unocss/preset-wind@0.58.9': + resolution: {integrity: sha512-7l+7Vx5UoN80BmJKiqDXaJJ6EUqrnUQYv8NxCThFi5lYuHzxsYWZPLU3k3XlWRUQt8XL+6rYx7mMBmD7EUSHyw==, tarball: https://registry.npmmirror.com/@unocss/preset-wind/-/preset-wind-0.58.9.tgz} + + '@unocss/reset@0.58.9': + resolution: {integrity: sha512-nA2pg3tnwlquq+FDOHyKwZvs20A6iBsKPU7Yjb48JrNnzoaXqE+O9oN6782IG2yKVW4AcnsAnAnM4cxXhGzy1w==, tarball: https://registry.npmmirror.com/@unocss/reset/-/reset-0.58.9.tgz} + + '@unocss/rule-utils@0.58.9': + resolution: {integrity: sha512-45bDa+elmlFLthhJmKr2ltKMAB0yoXnDMQ6Zp5j3OiRB7dDMBkwYRPvHLvIe+34Ey7tDt/kvvDPtWMpPl2quUQ==, tarball: https://registry.npmmirror.com/@unocss/rule-utils/-/rule-utils-0.58.9.tgz} + engines: {node: '>=14'} + + '@unocss/scope@0.58.9': + resolution: {integrity: sha512-BIwcpx0R3bE0rYa9JVDJTk0GX32EBvnbvufBpNkWfC5tb7g+B7nMkVq9ichanksYCCxrIQQo0mrIz5PNzu9sGA==, tarball: https://registry.npmmirror.com/@unocss/scope/-/scope-0.58.9.tgz} + + '@unocss/transformer-attributify-jsx-babel@0.58.9': + resolution: {integrity: sha512-UGaQoGZg+3QrsPtnGHPECmsGn4EQb2KSdZ4eGEn2YssjKv+CcQhzRvpEUgnuF/F+jGPkCkS/G/YEQBHRWBY54Q==, tarball: https://registry.npmmirror.com/@unocss/transformer-attributify-jsx-babel/-/transformer-attributify-jsx-babel-0.58.9.tgz} + + '@unocss/transformer-attributify-jsx@0.58.9': + resolution: {integrity: sha512-jpL3PRwf8t43v1agUdQn2EHGgfdWfvzsMxFtoybO88xzOikzAJaaouteNtojc/fQat2T9iBduDxVj5egdKmhdQ==, tarball: https://registry.npmmirror.com/@unocss/transformer-attributify-jsx/-/transformer-attributify-jsx-0.58.9.tgz} + + '@unocss/transformer-compile-class@0.58.9': + resolution: {integrity: sha512-l2VpCqelJ6Tgc1kfSODxBtg7fCGPVRr2EUzTg1LrGYKa2McbKuc/wV/2DWKHGxL6+voWi7a2C9XflqGDXXutuQ==, tarball: https://registry.npmmirror.com/@unocss/transformer-compile-class/-/transformer-compile-class-0.58.9.tgz} + + '@unocss/transformer-directives@0.58.9': + resolution: {integrity: sha512-pLOUsdoY2ugVntJXg0xuGjO9XZ2xCiMxTPRtpZ4TsEzUtdEzMswR06Y8VWvNciTB/Zqxcz9ta8rD0DKePOfSuw==, tarball: https://registry.npmmirror.com/@unocss/transformer-directives/-/transformer-directives-0.58.9.tgz} + + '@unocss/transformer-variant-group@0.58.9': + resolution: {integrity: sha512-3A6voHSnFcyw6xpcZT6oxE+KN4SHRnG4z862tdtWvRGcN+jGyNr20ylEZtnbk4xj0VNMeGHHQRZ0WLvmrAwvOQ==, tarball: https://registry.npmmirror.com/@unocss/transformer-variant-group/-/transformer-variant-group-0.58.9.tgz} + + '@unocss/vite@0.58.9': + resolution: {integrity: sha512-mmppBuulAHCal+sC0Qz36Y99t0HicAmznpj70Kzwl7g/yvXwm58/DW2OnpCWw+uA8/JBft/+z3zE+XvrI+T1HA==, tarball: https://registry.npmmirror.com/@unocss/vite/-/vite-0.58.9.tgz} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 + + '@uppy/companion-client@2.2.2': + resolution: {integrity: sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==, tarball: https://registry.npmmirror.com/@uppy/companion-client/-/companion-client-2.2.2.tgz} + + '@uppy/core@2.3.4': + resolution: {integrity: sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==, tarball: https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz} + + '@uppy/store-default@2.1.1': + resolution: {integrity: sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==, tarball: https://registry.npmmirror.com/@uppy/store-default/-/store-default-2.1.1.tgz} + + '@uppy/utils@4.1.3': + resolution: {integrity: sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==, tarball: https://registry.npmmirror.com/@uppy/utils/-/utils-4.1.3.tgz} + + '@uppy/xhr-upload@2.1.3': + resolution: {integrity: sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==, tarball: https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz} + peerDependencies: + '@uppy/core': ^2.3.3 + + '@videojs-player/vue@1.0.0': + resolution: {integrity: sha512-WonTezRfKu3fYdQLt/ta+nuKH6gMZUv8l40Jke/j4Lae7IqeO/+lLAmBnh3ni88bwR+vkFXIlZ2Ci7VKInIYJg==, tarball: https://registry.npmmirror.com/@videojs-player/vue/-/vue-1.0.0.tgz} + peerDependencies: + '@types/video.js': 7.x + video.js: 7.x + vue: 3.x + + '@videojs/http-streaming@2.16.2': + resolution: {integrity: sha512-etPTUdCFu7gUWc+1XcbiPr+lrhOcBu3rV5OL1M+3PDW89zskScAkkcdqYzP4pFodBPye/ydamQoTDScOnElw5A==, tarball: https://registry.npmmirror.com/@videojs/http-streaming/-/http-streaming-2.16.2.tgz} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + video.js: ^6 || ^7 + + '@videojs/vhs-utils@3.0.5': + resolution: {integrity: sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==, tarball: https://registry.npmmirror.com/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz} + engines: {node: '>=8', npm: '>=5'} + + '@videojs/xhr@2.6.0': + resolution: {integrity: sha512-7J361GiN1tXpm+gd0xz2QWr3xNWBE+rytvo8J3KuggFaLg+U37gZQ2BuPLcnkfGffy2e+ozY70RHC8jt7zjA6Q==, tarball: https://registry.npmmirror.com/@videojs/xhr/-/xhr-2.6.0.tgz} + + '@vitejs/plugin-legacy@5.3.2': + resolution: {integrity: sha512-8moCOrIMaZ/Rjln0Q6GsH6s8fAt1JOI3k8nmfX4tXUxE5KAExVctSyOBk+A25GClsdSWqIk2yaUthH3KJ2X4tg==, tarball: https://registry.npmmirror.com/@vitejs/plugin-legacy/-/plugin-legacy-5.3.2.tgz} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + terser: ^5.4.0 + vite: ^5.0.0 + + '@vitejs/plugin-vue-jsx@3.1.0': + resolution: {integrity: sha512-w9M6F3LSEU5kszVb9An2/MmXNxocAnUb3WhRr8bHlimhDrXNt6n6D2nJQR3UXpGlZHh/EsgouOHCsM8V3Ln+WA==, tarball: https://registry.npmmirror.com/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-3.1.0.tgz} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + + '@vitejs/plugin-vue@5.0.4': + resolution: {integrity: sha512-WS3hevEszI6CEVEx28F8RjTX97k3KsrcY6kvTg7+Whm5y3oYvcqzVeGCU3hxSAn4uY2CLCkeokkGKpoctccilQ==, tarball: https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 + vue: ^3.2.25 + + '@volar/language-core@1.11.1': + resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==, tarball: https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz} + + '@volar/source-map@1.11.1': + resolution: {integrity: sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==, tarball: https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz} + + '@volar/typescript@1.11.1': + resolution: {integrity: sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==, tarball: https://registry.npmmirror.com/@volar/typescript/-/typescript-1.11.1.tgz} + + '@vue/babel-helper-vue-transform-on@1.2.2': + resolution: {integrity: sha512-nOttamHUR3YzdEqdM/XXDyCSdxMA9VizUKoroLX6yTyRtggzQMHXcmwh8a7ZErcJttIBIc9s68a1B8GZ+Dmvsw==, tarball: https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.2.2.tgz} + + '@vue/babel-plugin-jsx@1.2.2': + resolution: {integrity: sha512-nYTkZUVTu4nhP199UoORePsql0l+wj7v/oyQjtThUVhJl1U+6qHuoVhIvR3bf7eVKjbCK+Cs2AWd7mi9Mpz9rA==, tarball: https://registry.npmmirror.com/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.2.2.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + + '@vue/babel-plugin-resolve-type@1.2.2': + resolution: {integrity: sha512-EntyroPwNg5IPVdUJupqs0CFzuf6lUrVvCspmv2J1FITLeGnUCuoGNNk78dgCusxEiYj6RMkTJflGSxk5aIC4A==, tarball: https://registry.npmmirror.com/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.2.2.tgz} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@vue/compiler-core@3.4.21': + resolution: {integrity: sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==, tarball: https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.4.21.tgz} + + '@vue/compiler-core@3.4.26': + resolution: {integrity: sha512-N9Vil6Hvw7NaiyFUFBPXrAyETIGlQ8KcFMkyk6hW1Cl6NvoqvP+Y8p1Eqvx+UdqsnrnI9+HMUEJegzia3mhXmQ==, tarball: https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.4.26.tgz} + + '@vue/compiler-dom@3.4.21': + resolution: {integrity: sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==, tarball: https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.4.21.tgz} + + '@vue/compiler-dom@3.4.26': + resolution: {integrity: sha512-4CWbR5vR9fMg23YqFOhr6t6WB1Fjt62d6xdFPyj8pxrYub7d+OgZaObMsoxaF9yBUHPMiPFK303v61PwAuGvZA==, tarball: https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.4.26.tgz} + + '@vue/compiler-sfc@3.4.21': + resolution: {integrity: sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==, tarball: https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.4.21.tgz} + + '@vue/compiler-sfc@3.4.26': + resolution: {integrity: sha512-It1dp+FAOCgluYSVYlDn5DtZBxk1NCiJJfu2mlQqa/b+k8GL6NG/3/zRbJnHdhV2VhxFghaDq5L4K+1dakW6cw==, tarball: https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.4.26.tgz} + + '@vue/compiler-ssr@3.4.21': + resolution: {integrity: sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==, tarball: https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.4.21.tgz} + + '@vue/compiler-ssr@3.4.26': + resolution: {integrity: sha512-FNwLfk7LlEPRY/g+nw2VqiDKcnDTVdCfBREekF8X74cPLiWHUX6oldktf/Vx28yh4STNy7t+/yuLoMBBF7YDiQ==, tarball: https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.4.26.tgz} + + '@vue/devtools-api@6.6.1': + resolution: {integrity: sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA==, tarball: https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.1.tgz} + + '@vue/language-core@1.8.27': + resolution: {integrity: sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==, tarball: https://registry.npmmirror.com/@vue/language-core/-/language-core-1.8.27.tgz} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.4.21': + resolution: {integrity: sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==, tarball: https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.4.21.tgz} + + '@vue/runtime-core@3.4.21': + resolution: {integrity: sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==, tarball: https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.4.21.tgz} + + '@vue/runtime-dom@3.4.21': + resolution: {integrity: sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==, tarball: https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.4.21.tgz} + + '@vue/server-renderer@3.4.21': + resolution: {integrity: sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==, tarball: https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.4.21.tgz} + peerDependencies: + vue: 3.4.21 + + '@vue/shared@3.4.21': + resolution: {integrity: sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g==, tarball: https://registry.npmmirror.com/@vue/shared/-/shared-3.4.21.tgz} + + '@vue/shared@3.4.26': + resolution: {integrity: sha512-Fg4zwR0GNnjzodMt3KRy2AWGMKQXByl56+4HjN87soxLNU9P5xcJkstAlIeEF3cU6UYOzmJl1tV0dVPGIljCnQ==, tarball: https://registry.npmmirror.com/@vue/shared/-/shared-3.4.26.tgz} + + '@vueuse/core@10.9.0': + resolution: {integrity: sha512-/1vjTol8SXnx6xewDEKfS0Ra//ncg4Hb0DaZiwKf7drgfMsKFExQ+FnnENcN6efPen+1kIzhLQoGSy0eDUVOMg==, tarball: https://registry.npmmirror.com/@vueuse/core/-/core-10.9.0.tgz} + + '@vueuse/core@9.13.0': + resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==, tarball: https://registry.npmmirror.com/@vueuse/core/-/core-9.13.0.tgz} + + '@vueuse/metadata@10.9.0': + resolution: {integrity: sha512-iddNbg3yZM0X7qFY2sAotomgdHK7YJ6sKUvQqbvwnf7TmaVPxS4EJydcNsVejNdS8iWCtDk+fYXr7E32nyTnGA==, tarball: https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.9.0.tgz} + + '@vueuse/metadata@9.13.0': + resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==, tarball: https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz} + + '@vueuse/shared@10.9.0': + resolution: {integrity: sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==, tarball: https://registry.npmmirror.com/@vueuse/shared/-/shared-10.9.0.tgz} + + '@vueuse/shared@9.13.0': + resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==, tarball: https://registry.npmmirror.com/@vueuse/shared/-/shared-9.13.0.tgz} + + '@wangeditor/basic-modules@1.1.7': + resolution: {integrity: sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==, tarball: https://registry.npmmirror.com/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz} + peerDependencies: + '@wangeditor/core': 1.x + dom7: ^3.0.0 + lodash.throttle: ^4.1.1 + nanoid: ^3.2.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/code-highlight@1.0.3': + resolution: {integrity: sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==, tarball: https://registry.npmmirror.com/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz} + peerDependencies: + '@wangeditor/core': 1.x + dom7: ^3.0.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/core@1.1.19': + resolution: {integrity: sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==, tarball: https://registry.npmmirror.com/@wangeditor/core/-/core-1.1.19.tgz} + peerDependencies: + '@uppy/core': ^2.1.1 + '@uppy/xhr-upload': ^2.0.3 + dom7: ^3.0.0 + is-hotkey: ^0.2.0 + lodash.camelcase: ^4.3.0 + lodash.clonedeep: ^4.5.0 + lodash.debounce: ^4.0.8 + lodash.foreach: ^4.5.0 + lodash.isequal: ^4.5.0 + lodash.throttle: ^4.1.1 + lodash.toarray: ^4.4.0 + nanoid: ^3.2.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/editor-for-vue@5.1.12': + resolution: {integrity: sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==, tarball: https://registry.npmmirror.com/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz} + peerDependencies: + '@wangeditor/editor': '>=5.1.0' + vue: ^3.0.5 + + '@wangeditor/editor@5.1.23': + resolution: {integrity: sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==, tarball: https://registry.npmmirror.com/@wangeditor/editor/-/editor-5.1.23.tgz} + + '@wangeditor/list-module@1.0.5': + resolution: {integrity: sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==, tarball: https://registry.npmmirror.com/@wangeditor/list-module/-/list-module-1.0.5.tgz} + peerDependencies: + '@wangeditor/core': 1.x + dom7: ^3.0.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/table-module@1.1.4': + resolution: {integrity: sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==, tarball: https://registry.npmmirror.com/@wangeditor/table-module/-/table-module-1.1.4.tgz} + peerDependencies: + '@wangeditor/core': 1.x + dom7: ^3.0.0 + lodash.isequal: ^4.5.0 + lodash.throttle: ^4.1.1 + nanoid: ^3.2.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/upload-image-module@1.0.2': + resolution: {integrity: sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==, tarball: https://registry.npmmirror.com/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz} + peerDependencies: + '@uppy/core': ^2.0.3 + '@uppy/xhr-upload': ^2.0.3 + '@wangeditor/basic-modules': 1.x + '@wangeditor/core': 1.x + dom7: ^3.0.0 + lodash.foreach: ^4.5.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/video-module@1.1.4': + resolution: {integrity: sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==, tarball: https://registry.npmmirror.com/@wangeditor/video-module/-/video-module-1.1.4.tgz} + peerDependencies: + '@uppy/core': ^2.1.4 + '@uppy/xhr-upload': ^2.0.7 + '@wangeditor/core': 1.x + dom7: ^3.0.0 + nanoid: ^3.2.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@xmldom/xmldom@0.8.10': + resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==, tarball: https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz} + engines: {node: '>=10.0.0'} + + '@zxcvbn-ts/core@3.0.4': + resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==, tarball: https://registry.npmmirror.com/@zxcvbn-ts/core/-/core-3.0.4.tgz} + + JSONStream@1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==, tarball: https://registry.npmmirror.com/JSONStream/-/JSONStream-1.3.5.tgz} + hasBin: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, tarball: https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==, tarball: https://registry.npmmirror.com/acorn/-/acorn-8.11.3.tgz} + engines: {node: '>=0.4.0'} + hasBin: true + + aes-decrypter@3.1.3: + resolution: {integrity: sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==, tarball: https://registry.npmmirror.com/aes-decrypter/-/aes-decrypter-3.1.3.tgz} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==, tarball: https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz} + + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==, tarball: https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz} + + animate.css@4.1.1: + resolution: {integrity: sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==, tarball: https://registry.npmmirror.com/animate.css/-/animate.css-4.1.1.tgz} + + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==, tarball: https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-6.2.1.tgz} + engines: {node: '>=14.16'} + + ansi-regex@2.1.1: + resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==, tarball: https://registry.npmmirror.com/ansi-regex/-/ansi-regex-2.1.1.tgz} + engines: {node: '>=0.10.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, tarball: https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz} + engines: {node: '>=8'} + + ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==, tarball: https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.0.1.tgz} + engines: {node: '>=12'} + + ansi-styles@2.2.1: + resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==, tarball: https://registry.npmmirror.com/ansi-styles/-/ansi-styles-2.2.1.tgz} + engines: {node: '>=0.10.0'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==, tarball: https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, tarball: https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==, tarball: https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz} + engines: {node: '>=10'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==, tarball: https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.1.tgz} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==, tarball: https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==, tarball: https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, tarball: https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz} + + arr-diff@4.0.0: + resolution: {integrity: sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==, tarball: https://registry.npmmirror.com/arr-diff/-/arr-diff-4.0.0.tgz} + engines: {node: '>=0.10.0'} + + arr-flatten@1.1.0: + resolution: {integrity: sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==, tarball: https://registry.npmmirror.com/arr-flatten/-/arr-flatten-1.1.0.tgz} + engines: {node: '>=0.10.0'} + + arr-union@3.1.0: + resolution: {integrity: sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==, tarball: https://registry.npmmirror.com/arr-union/-/arr-union-3.1.0.tgz} + engines: {node: '>=0.10.0'} + + array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==, tarball: https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz} + engines: {node: '>= 0.4'} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==, tarball: https://registry.npmmirror.com/array-ify/-/array-ify-1.0.0.tgz} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==, tarball: https://registry.npmmirror.com/array-union/-/array-union-2.1.0.tgz} + engines: {node: '>=8'} + + array-unique@0.3.2: + resolution: {integrity: sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==, tarball: https://registry.npmmirror.com/array-unique/-/array-unique-0.3.2.tgz} + engines: {node: '>=0.10.0'} + + arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==, tarball: https://registry.npmmirror.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz} + engines: {node: '>= 0.4'} + + assign-symbols@1.0.0: + resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==, tarball: https://registry.npmmirror.com/assign-symbols/-/assign-symbols-1.0.0.tgz} + engines: {node: '>=0.10.0'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==, tarball: https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz} + engines: {node: '>=8'} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==, tarball: https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz} + + async@3.2.5: + resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==, tarball: https://registry.npmmirror.com/async/-/async-3.2.5.tgz} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, tarball: https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz} + + atob@2.1.2: + resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==, tarball: https://registry.npmmirror.com/atob/-/atob-2.1.2.tgz} + engines: {node: '>= 4.5.0'} + hasBin: true + + autolinker@3.16.2: + resolution: {integrity: sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA==, tarball: https://registry.npmmirror.com/autolinker/-/autolinker-3.16.2.tgz} + + autoprefixer@10.4.19: + resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==, tarball: https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.19.tgz} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==, tarball: https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz} + engines: {node: '>= 0.4'} + + axios@0.26.1: + resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==, tarball: https://registry.npmmirror.com/axios/-/axios-0.26.1.tgz} + + axios@1.6.8: + resolution: {integrity: sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==, tarball: https://registry.npmmirror.com/axios/-/axios-1.6.8.tgz} + + babel-plugin-polyfill-corejs2@0.4.11: + resolution: {integrity: sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==, tarball: https://registry.npmmirror.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.10.4: + resolution: {integrity: sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==, tarball: https://registry.npmmirror.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.2: + resolution: {integrity: sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==, tarball: https://registry.npmmirror.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, tarball: https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==, tarball: https://registry.npmmirror.com/balanced-match/-/balanced-match-2.0.0.tgz} + + base@0.11.2: + resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==, tarball: https://registry.npmmirror.com/base/-/base-0.11.2.tgz} + engines: {node: '>=0.10.0'} + + benz-amr-recorder@1.1.5: + resolution: {integrity: sha512-NepctcNTsZHK8NxBb5uKO5p8S+xkbm+vD6GLSkCYdJeEsriexvgumLHpDkanX4QJBcLRMVtg16buWMs+gUPB3g==, tarball: https://registry.npmmirror.com/benz-amr-recorder/-/benz-amr-recorder-1.1.5.tgz} + + benz-recorderjs@1.0.5: + resolution: {integrity: sha512-EwedOQo9KLti7HxDi/eZY51PSRbAXnOdEZmLvJ6ro3QQSoF9Y3AXBt57MIllGvVz5vtFYMeikG+GD7qTm3+p9w==, tarball: https://registry.npmmirror.com/benz-recorderjs/-/benz-recorderjs-1.0.5.tgz} + + big.js@5.2.2: + resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==, tarball: https://registry.npmmirror.com/big.js/-/big.js-5.2.2.tgz} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz} + engines: {node: '>=8'} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==, tarball: https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==, tarball: https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz} + + bpmn-js-properties-panel@0.46.0: + resolution: {integrity: sha512-8MlNvHklIZZQH9vtoKf0A0A1v0sHO4Iz19jGhHeX15czOOiCfdavjo+q23GHWNKzQA9347F91XYFcrnM6FO8zw==, tarball: https://registry.npmmirror.com/bpmn-js-properties-panel/-/bpmn-js-properties-panel-0.46.0.tgz} + peerDependencies: + bpmn-js: ^3.x || ^4.x || ^5.x || ^6.x || ^7.x || ^8.x + + bpmn-js-token-simulation@0.10.0: + resolution: {integrity: sha512-QuZQ/KVXKt9Vl+XENyOBoTW2Aw+uKjuBlKdCJL6El7AyM7DkJ5bZkSYURshId1SkBDdYg2mJ1flSmsrhGuSfwg==, tarball: https://registry.npmmirror.com/bpmn-js-token-simulation/-/bpmn-js-token-simulation-0.10.0.tgz} + + bpmn-js@8.9.0: + resolution: {integrity: sha512-cthSxiJUpEHspiUKiL0YA8/mRCYngNKwALWieLKPtFo42n+vWTFgmxnASNRwhxpPEbSXjYuTah1lZ0lSyLWPpw==, tarball: https://registry.npmmirror.com/bpmn-js/-/bpmn-js-8.9.0.tgz} + + bpmn-moddle@7.1.3: + resolution: {integrity: sha512-ZcBfw0NSOdYTSXFKEn7MOXHItz7VfLZTrFYKO8cK6V8ZzGjCcdiLIOiw7Lctw1PJsihhLiZQS8Htj2xKf+NwCg==, tarball: https://registry.npmmirror.com/bpmn-moddle/-/bpmn-moddle-7.1.3.tgz} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==, tarball: https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==, tarball: https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz} + + braces@2.3.2: + resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==, tarball: https://registry.npmmirror.com/braces/-/braces-2.3.2.tgz} + engines: {node: '>=0.10.0'} + + braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==, tarball: https://registry.npmmirror.com/braces/-/braces-3.0.2.tgz} + engines: {node: '>=8'} + + browserslist-to-esbuild@2.1.1: + resolution: {integrity: sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw==, tarball: https://registry.npmmirror.com/browserslist-to-esbuild/-/browserslist-to-esbuild-2.1.1.tgz} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + browserslist: '*' + + browserslist@4.23.0: + resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==, tarball: https://registry.npmmirror.com/browserslist/-/browserslist-4.23.0.tgz} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==, tarball: https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==, tarball: https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz} + engines: {node: '>=8'} + + cache-base@1.0.1: + resolution: {integrity: sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==, tarball: https://registry.npmmirror.com/cache-base/-/cache-base-1.0.1.tgz} + engines: {node: '>=0.10.0'} + + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==, tarball: https://registry.npmmirror.com/call-bind/-/call-bind-1.0.7.tgz} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, tarball: https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==, tarball: https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==, tarball: https://registry.npmmirror.com/camelcase/-/camelcase-6.3.0.tgz} + engines: {node: '>=10'} + + camunda-bpmn-moddle@7.0.1: + resolution: {integrity: sha512-Br8Diu6roMpziHdpl66Dhnm0DTnCFMrSD9zwLV08LpD52QA0UsXxU87XfHf08HjuB7ly0Hd1bvajZRpf9hbmYQ==, tarball: https://registry.npmmirror.com/camunda-bpmn-moddle/-/camunda-bpmn-moddle-7.0.1.tgz} + + caniuse-lite@1.0.30001614: + resolution: {integrity: sha512-jmZQ1VpmlRwHgdP1/uiKzgiAuGOfLEJsYFP4+GBou/QQ4U6IOJCB4NP1c+1p9RGLpwObcT94jA5/uO+F1vBbog==, tarball: https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001614.tgz} + + chalk@1.1.3: + resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==, tarball: https://registry.npmmirror.com/chalk/-/chalk-1.1.3.tgz} + engines: {node: '>=0.10.0'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==, tarball: https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, tarball: https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz} + engines: {node: '>=10'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==, tarball: https://registry.npmmirror.com/chalk/-/chalk-5.3.0.tgz} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==, tarball: https://registry.npmmirror.com/cheerio-select/-/cheerio-select-2.1.0.tgz} + + cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==, tarball: https://registry.npmmirror.com/cheerio/-/cheerio-1.0.0-rc.12.tgz} + engines: {node: '>= 6'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==, tarball: https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz} + engines: {node: '>= 8.10.0'} + + class-utils@0.3.6: + resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==, tarball: https://registry.npmmirror.com/class-utils/-/class-utils-0.3.6.tgz} + engines: {node: '>=0.10.0'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==, tarball: https://registry.npmmirror.com/cli-cursor/-/cli-cursor-4.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==, tarball: https://registry.npmmirror.com/cli-truncate/-/cli-truncate-4.0.0.tgz} + engines: {node: '>=18'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==, tarball: https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==, tarball: https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz} + engines: {node: '>=12'} + + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==, tarball: https://registry.npmmirror.com/clone/-/clone-2.1.2.tgz} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==, tarball: https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz} + engines: {node: '>=6'} + + collection-visit@1.0.0: + resolution: {integrity: sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==, tarball: https://registry.npmmirror.com/collection-visit/-/collection-visit-1.0.0.tgz} + engines: {node: '>=0.10.0'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==, tarball: https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==, tarball: https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, tarball: https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==, tarball: https://registry.npmmirror.com/colord/-/colord-2.9.3.tgz} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==, tarball: https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, tarball: https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz} + engines: {node: '>= 0.8'} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==, tarball: https://registry.npmmirror.com/commander/-/commander-11.1.0.tgz} + engines: {node: '>=16'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==, tarball: https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==, tarball: https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==, tarball: https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz} + engines: {node: '>= 12'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==, tarball: https://registry.npmmirror.com/common-tags/-/common-tags-1.8.2.tgz} + engines: {node: '>=4.0.0'} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==, tarball: https://registry.npmmirror.com/compare-func/-/compare-func-2.0.0.tgz} + + component-classes@1.2.6: + resolution: {integrity: sha512-hPFGULxdwugu1QWW3SvVOCUHLzO34+a2J6Wqy0c5ASQkfi9/8nZcBB0ZohaEbXOQlCflMAEMmEWk7u7BVs4koA==, tarball: https://registry.npmmirror.com/component-classes/-/component-classes-1.2.6.tgz} + + component-closest@0.1.4: + resolution: {integrity: sha512-NF9hMj6JKGM5sb6wP/dg7GdJOttaIH9PcTsUNdWcrvu7Kw/5R5swQAFpgaYEHlARrNMyn4Wf7O1PlRej+pt76Q==, tarball: https://registry.npmmirror.com/component-closest/-/component-closest-0.1.4.tgz} + + component-delegate@0.2.4: + resolution: {integrity: sha512-OlpcB/6Fi+kXQPh/TfXnSvvmrU04ghz7vcJh/jgLF0Ni+I+E3WGlKJQbBGDa5X+kVUG8WxOgjP+8iWbz902fPg==, tarball: https://registry.npmmirror.com/component-delegate/-/component-delegate-0.2.4.tgz} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==, tarball: https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz} + + component-event@0.1.4: + resolution: {integrity: sha512-GMwOG8MnUHP1l8DZx1ztFO0SJTFnIzZnBDkXAj8RM2ntV2A6ALlDxgbMY1Fvxlg6WPQ+5IM/a6vg4PEYbjg/Rw==, tarball: https://registry.npmmirror.com/component-event/-/component-event-0.1.4.tgz} + + component-event@0.2.1: + resolution: {integrity: sha512-wGA++isMqiDq1jPYeyv2as/Bt/u+3iLW0rEa+8NQ82jAv3TgqMiCM+B2SaBdn2DfLilLjjq736YcezihRYhfxw==, tarball: https://registry.npmmirror.com/component-event/-/component-event-0.2.1.tgz} + + component-indexof@0.0.3: + resolution: {integrity: sha512-puDQKvx/64HZXb4hBwIcvQLaLgux8o1CbWl39s41hrIIZDl1lJiD5jc22gj3RBeGK0ovxALDYpIbyjqDUUl0rw==, tarball: https://registry.npmmirror.com/component-indexof/-/component-indexof-0.0.3.tgz} + + component-matches-selector@0.1.7: + resolution: {integrity: sha512-Yb2+pVBvrqkQVpPaDBF0DYXRreBveXJNrpJs9FnFu8PF6/5IIcz5oDZqiH9nB5hbD2/TmFVN5ZCxBzqu7yFFYQ==, tarball: https://registry.npmmirror.com/component-matches-selector/-/component-matches-selector-0.1.7.tgz} + + component-query@0.0.3: + resolution: {integrity: sha512-VgebQseT1hz1Ps7vVp2uaSg+N/gsI5ts3AZUSnN6GMA2M82JH7o+qYifWhmVE/e8w/H48SJuA3nA9uX8zRe95Q==, tarball: https://registry.npmmirror.com/component-query/-/component-query-0.0.3.tgz} + + compute-scroll-into-view@1.0.20: + resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==, tarball: https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz} + + computeds@0.0.1: + resolution: {integrity: sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==, tarball: https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, tarball: https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz} + + confbox@0.1.7: + resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==, tarball: https://registry.npmmirror.com/confbox/-/confbox-0.1.7.tgz} + + consola@3.2.3: + resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==, tarball: https://registry.npmmirror.com/consola/-/consola-3.2.3.tgz} + engines: {node: ^14.18.0 || >=16.10.0} + + conventional-changelog-angular@7.0.0: + resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==, tarball: https://registry.npmmirror.com/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz} + engines: {node: '>=16'} + + conventional-changelog-conventionalcommits@7.0.2: + resolution: {integrity: sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==, tarball: https://registry.npmmirror.com/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz} + engines: {node: '>=16'} + + conventional-commits-parser@5.0.0: + resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==, tarball: https://registry.npmmirror.com/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz} + engines: {node: '>=16'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, tarball: https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz} + + copy-descriptor@0.1.1: + resolution: {integrity: sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==, tarball: https://registry.npmmirror.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz} + engines: {node: '>=0.10.0'} + + core-js-compat@3.37.0: + resolution: {integrity: sha512-vYq4L+T8aS5UuFg4UwDhc7YNRWVeVZwltad9C/jV3R2LgVOpS9BDr7l/WL6BN0dbV3k1XejPTHqqEzJgsa0frA==, tarball: https://registry.npmmirror.com/core-js-compat/-/core-js-compat-3.37.0.tgz} + + core-js-pure@3.37.0: + resolution: {integrity: sha512-d3BrpyFr5eD4KcbRvQ3FTUx/KWmaDesr7+a3+1+P46IUnNoEt+oiLijPINZMEon7w9oGkIINWxrBAU9DEciwFQ==, tarball: https://registry.npmmirror.com/core-js-pure/-/core-js-pure-3.37.0.tgz} + + core-js@3.37.0: + resolution: {integrity: sha512-fu5vHevQ8ZG4og+LXug8ulUtVxjOcEYvifJr7L5Bfq9GOztVqsKd9/59hUk2ZSbCrS3BqUr3EpaYGIYzq7g3Ug==, tarball: https://registry.npmmirror.com/core-js/-/core-js-3.37.0.tgz} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==, tarball: https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz} + engines: {node: '>= 0.10'} + + cosmiconfig-typescript-loader@5.0.0: + resolution: {integrity: sha512-+8cK7jRAReYkMwMiG+bxhcNKiHJDM6bR9FD/nGBXOWdMLuYawjF5cGrtLilJ+LGd3ZjCXnJjR5DkfWPoIVlqJA==, tarball: https://registry.npmmirror.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.0.0.tgz} + engines: {node: '>=v16'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=8.2' + typescript: '>=4' + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==, tarball: https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cropperjs@1.6.2: + resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==, tarball: https://registry.npmmirror.com/cropperjs/-/cropperjs-1.6.2.tgz} + + cross-fetch@3.1.8: + resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==, tarball: https://registry.npmmirror.com/cross-fetch/-/cross-fetch-3.1.8.tgz} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==, tarball: https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz} + engines: {node: '>= 8'} + + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==, tarball: https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz} + + css-functions-list@3.2.2: + resolution: {integrity: sha512-c+N0v6wbKVxTu5gOBBFkr9BEdBWaqqjQeiJ8QvSRIJOf+UxlJh930m8e6/WNeODIK0mYLFkoONrnj16i2EcvfQ==, tarball: https://registry.npmmirror.com/css-functions-list/-/css-functions-list-3.2.2.tgz} + engines: {node: '>=12 || >=16'} + + css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==, tarball: https://registry.npmmirror.com/css-select/-/css-select-4.3.0.tgz} + + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==, tarball: https://registry.npmmirror.com/css-select/-/css-select-5.1.0.tgz} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==, tarball: https://registry.npmmirror.com/css-tree/-/css-tree-1.1.3.tgz} + engines: {node: '>=8.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==, tarball: https://registry.npmmirror.com/css-tree/-/css-tree-2.3.1.tgz} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==, tarball: https://registry.npmmirror.com/css-what/-/css-what-6.1.0.tgz} + engines: {node: '>= 6'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==, tarball: https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==, tarball: https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz} + engines: {node: '>=4'} + hasBin: true + + csso@4.2.0: + resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==, tarball: https://registry.npmmirror.com/csso/-/csso-4.2.0.tgz} + engines: {node: '>=8.0.0'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==, tarball: https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==, tarball: https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==, tarball: https://registry.npmmirror.com/d3-axis/-/d3-axis-3.0.0.tgz} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==, tarball: https://registry.npmmirror.com/d3-brush/-/d3-brush-3.0.0.tgz} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==, tarball: https://registry.npmmirror.com/d3-chord/-/d3-chord-3.0.1.tgz} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==, tarball: https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==, tarball: https://registry.npmmirror.com/d3-contour/-/d3-contour-4.0.2.tgz} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==, tarball: https://registry.npmmirror.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==, tarball: https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==, tarball: https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==, tarball: https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==, tarball: https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==, tarball: https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz} + engines: {node: '>=12'} + + d3-flextree@2.1.2: + resolution: {integrity: sha512-gJiHrx5uTTHq44bjyIb3xpbmmdZcWLYPKeO9EPVOq8EylMFOiH2+9sWqKAiQ4DcFuOZTAxPOQyv0Rnmji/g15A==, tarball: https://registry.npmmirror.com/d3-flextree/-/d3-flextree-2.1.2.tgz} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==, tarball: https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==, tarball: https://registry.npmmirror.com/d3-format/-/d3-format-3.1.0.tgz} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==, tarball: https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz} + engines: {node: '>=12'} + + d3-hierarchy@1.1.9: + resolution: {integrity: sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==, tarball: https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==, tarball: https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==, tarball: https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==, tarball: https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==, tarball: https://registry.npmmirror.com/d3-polygon/-/d3-polygon-3.0.1.tgz} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==, tarball: https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==, tarball: https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz} + engines: {node: '>=12'} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==, tarball: https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==, tarball: https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==, tarball: https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==, tarball: https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==, tarball: https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==, tarball: https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==, tarball: https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==, tarball: https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==, tarball: https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==, tarball: https://registry.npmmirror.com/d3/-/d3-7.9.0.tgz} + engines: {node: '>=12'} + + d@1.0.2: + resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==, tarball: https://registry.npmmirror.com/d/-/d-1.0.2.tgz} + engines: {node: '>=0.12'} + + dargs@8.1.0: + resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==, tarball: https://registry.npmmirror.com/dargs/-/dargs-8.1.0.tgz} + engines: {node: '>=12'} + + data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==, tarball: https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==, tarball: https://registry.npmmirror.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==, tarball: https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz} + engines: {node: '>= 0.4'} + + dayjs@1.11.11: + resolution: {integrity: sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==, tarball: https://registry.npmmirror.com/dayjs/-/dayjs-1.11.11.tgz} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==, tarball: https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==, tarball: https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==, tarball: https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==, tarball: https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz} + engines: {node: '>=0.10.0'} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==, tarball: https://registry.npmmirror.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz} + engines: {node: '>=0.10'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, tarball: https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==, tarball: https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==, tarball: https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz} + engines: {node: '>= 0.4'} + + define-property@0.2.5: + resolution: {integrity: sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==, tarball: https://registry.npmmirror.com/define-property/-/define-property-0.2.5.tgz} + engines: {node: '>=0.10.0'} + + define-property@1.0.0: + resolution: {integrity: sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==, tarball: https://registry.npmmirror.com/define-property/-/define-property-1.0.0.tgz} + engines: {node: '>=0.10.0'} + + define-property@2.0.2: + resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==, tarball: https://registry.npmmirror.com/define-property/-/define-property-2.0.2.tgz} + engines: {node: '>=0.10.0'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==, tarball: https://registry.npmmirror.com/defu/-/defu-6.1.4.tgz} + + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==, tarball: https://registry.npmmirror.com/delaunator/-/delaunator-5.0.1.tgz} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, tarball: https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz} + engines: {node: '>=0.4.0'} + + destr@2.0.3: + resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==, tarball: https://registry.npmmirror.com/destr/-/destr-2.0.3.tgz} + + diagram-js-direct-editing@1.8.0: + resolution: {integrity: sha512-B4Xj+PJfgBjbPEzT3uZQEkZI5xHFB0Izc+7BhDFuHidzrEMzQKZrFGdA3PqfWhReHf3dp+iB6Tt11G9eGNjKMw==, tarball: https://registry.npmmirror.com/diagram-js-direct-editing/-/diagram-js-direct-editing-1.8.0.tgz} + peerDependencies: + diagram-js: '*' + + diagram-js@12.8.1: + resolution: {integrity: sha512-LF9BiwjbOPpZd0ez5VSlYRbdbEA59YQX43bWvNDp1rLMv0xwZ5yIg4oaYDK82nIQ0kH1tjvoQRpNevMTCgQVyw==, tarball: https://registry.npmmirror.com/diagram-js/-/diagram-js-12.8.1.tgz} + + diagram-js@7.9.0: + resolution: {integrity: sha512-o1yUtX5TXV1pmpevP55gxU/AEG6nCidOXGs/HLuxNXG0zMZ3jQta7kMqRxTK93rNw/XuHmP1eMOwdvdJ2RP5qA==, tarball: https://registry.npmmirror.com/diagram-js/-/diagram-js-7.9.0.tgz} + + didi@5.2.1: + resolution: {integrity: sha512-IKNnajUlD4lWMy/Q9Emkk7H1qnzREgY4UyE3IhmOi/9IKua0JYtYldk928bOdt1yNxN8EiOy1sqtSozEYsmjCg==, tarball: https://registry.npmmirror.com/didi/-/didi-5.2.1.tgz} + + didi@9.0.2: + resolution: {integrity: sha512-q2+aj+lnJcUweV7A9pdUrwFr4LHVmRPwTmQLtHPFz4aT7IBoryN6Iy+jmFku+oIzr5ebBkvtBCOb87+dJhb7bg==, tarball: https://registry.npmmirror.com/didi/-/didi-9.0.2.tgz} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==, tarball: https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==, tarball: https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==, tarball: https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==, tarball: https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz} + engines: {node: '>=6.0.0'} + + dom-serializer@0.2.2: + resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==, tarball: https://registry.npmmirror.com/dom-serializer/-/dom-serializer-0.2.2.tgz} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==, tarball: https://registry.npmmirror.com/dom-serializer/-/dom-serializer-1.4.1.tgz} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==, tarball: https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz} + + dom-walk@0.1.2: + resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==, tarball: https://registry.npmmirror.com/dom-walk/-/dom-walk-0.1.2.tgz} + + dom7@3.0.0: + resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==, tarball: https://registry.npmmirror.com/dom7/-/dom7-3.0.0.tgz} + + domelementtype@1.3.1: + resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==, tarball: https://registry.npmmirror.com/domelementtype/-/domelementtype-1.3.1.tgz} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==, tarball: https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz} + + domhandler@2.4.2: + resolution: {integrity: sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==, tarball: https://registry.npmmirror.com/domhandler/-/domhandler-2.4.2.tgz} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==, tarball: https://registry.npmmirror.com/domhandler/-/domhandler-4.3.1.tgz} + engines: {node: '>= 4'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==, tarball: https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz} + engines: {node: '>= 4'} + + domify@1.4.2: + resolution: {integrity: sha512-m4yreHcUWHBncGVV7U+yQzc12vIlq0jMrtHZ5mW6dQMiL/7skSYNVX9wqKwOtyO9SGCgevrAFEgOCAHmamHTUA==, tarball: https://registry.npmmirror.com/domify/-/domify-1.4.2.tgz} + + dompurify@3.1.1: + resolution: {integrity: sha512-tVP8C/GJwnABOn/7cx/ymx/hXpmBfWIPihC1aOEvS8GbMqy3pgeYtJk1HXN3CO7tu+8bpY18f6isjR5Cymj0TQ==, tarball: https://registry.npmmirror.com/dompurify/-/dompurify-3.1.1.tgz} + + domutils@1.7.0: + resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==, tarball: https://registry.npmmirror.com/domutils/-/domutils-1.7.0.tgz} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==, tarball: https://registry.npmmirror.com/domutils/-/domutils-2.8.0.tgz} + + domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==, tarball: https://registry.npmmirror.com/domutils/-/domutils-3.1.0.tgz} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==, tarball: https://registry.npmmirror.com/dot-prop/-/dot-prop-5.3.0.tgz} + engines: {node: '>=8'} + + driver.js@1.3.1: + resolution: {integrity: sha512-MvUdXbqSgEsgS/H9KyWb5Rxy0aE6BhOVT4cssi2x2XjmXea6qQfgdx32XKVLLSqTaIw7q/uxU5Xl3NV7+cN6FQ==, tarball: https://registry.npmmirror.com/driver.js/-/driver.js-1.3.1.tgz} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==, tarball: https://registry.npmmirror.com/duplexer/-/duplexer-0.1.2.tgz} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, tarball: https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz} + + echarts-wordcloud@2.1.0: + resolution: {integrity: sha512-Kt1JmbcROgb+3IMI48KZECK2AP5lG6bSsOEs+AsuwaWJxQom31RTNd6NFYI01E/YaI1PFZeueaupjlmzSQasjQ==, tarball: https://registry.npmmirror.com/echarts-wordcloud/-/echarts-wordcloud-2.1.0.tgz} + peerDependencies: + echarts: ^5.0.1 + + echarts@5.5.0: + resolution: {integrity: sha512-rNYnNCzqDAPCr4m/fqyUFv7fD9qIsd50S6GDFgO1DxZhncCsNsG7IfUlAlvZe5oSEQxtsjnHiUuppzccry93Xw==, tarball: https://registry.npmmirror.com/echarts/-/echarts-5.5.0.tgz} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==, tarball: https://registry.npmmirror.com/ejs/-/ejs-3.1.10.tgz} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-to-chromium@1.4.750: + resolution: {integrity: sha512-9ItEpeu15hW5m8jKdriL+BQrgwDTXEL9pn4SkillWFu73ZNNNQ2BKKLS+ZHv2vC9UkNhosAeyfxOf/5OSeTCPA==, tarball: https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.750.tgz} + + element-plus@2.7.0: + resolution: {integrity: sha512-WAiaFLavuWFxof9qwkC27jvkh9nRcNnB506g1vvJSiVaVqjCBWUFCIyJKeN11M1qcv2cS5VV5PfSLjTIkrw87A==, tarball: https://registry.npmmirror.com/element-plus/-/element-plus-2.7.0.tgz} + peerDependencies: + vue: ^3.2.0 + + emoji-regex@10.3.0: + resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==, tarball: https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.3.0.tgz} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, tarball: https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, tarball: https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz} + + emojis-list@3.0.0: + resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==, tarball: https://registry.npmmirror.com/emojis-list/-/emojis-list-3.0.0.tgz} + engines: {node: '>= 4'} + + encode-utf8@1.0.3: + resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==, tarball: https://registry.npmmirror.com/encode-utf8/-/encode-utf8-1.0.3.tgz} + + entities@1.1.2: + resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==, tarball: https://registry.npmmirror.com/entities/-/entities-1.1.2.tgz} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==, tarball: https://registry.npmmirror.com/entities/-/entities-2.2.0.tgz} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, tarball: https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==, tarball: https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz} + engines: {node: '>=6'} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==, tarball: https://registry.npmmirror.com/error-ex/-/error-ex-1.3.2.tgz} + + es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==, tarball: https://registry.npmmirror.com/es-abstract/-/es-abstract-1.23.3.tgz} + engines: {node: '>= 0.4'} + + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==, tarball: https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.0.tgz} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==, tarball: https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz} + engines: {node: '>= 0.4'} + + es-module-lexer@1.5.2: + resolution: {integrity: sha512-l60ETUTmLqbVbVHv1J4/qj+M8nq7AwMzEcg3kmJDt9dCNrTk+yHcYFf/Kw75pMDwd9mPcIGCG5LcS20SxYRzFA==, tarball: https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.5.2.tgz} + + es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==, tarball: https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==, tarball: https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz} + engines: {node: '>= 0.4'} + + es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==, tarball: https://registry.npmmirror.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz} + engines: {node: '>= 0.4'} + + es5-ext@0.10.64: + resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==, tarball: https://registry.npmmirror.com/es5-ext/-/es5-ext-0.10.64.tgz} + engines: {node: '>=0.10'} + + es6-iterator@2.0.3: + resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==, tarball: https://registry.npmmirror.com/es6-iterator/-/es6-iterator-2.0.3.tgz} + + es6-symbol@3.1.4: + resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==, tarball: https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz} + engines: {node: '>=0.12'} + + esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==, tarball: https://registry.npmmirror.com/esbuild/-/esbuild-0.19.12.tgz} + engines: {node: '>=12'} + hasBin: true + + escalade@3.1.2: + resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==, tarball: https://registry.npmmirror.com/escalade/-/escalade-3.1.2.tgz} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==, tarball: https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==, tarball: https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, tarball: https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==, tarball: https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz} + engines: {node: '>=12'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==, tarball: https://registry.npmmirror.com/escodegen/-/escodegen-2.1.0.tgz} + engines: {node: '>=6.0'} + hasBin: true + + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==, tarball: https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-define-config@2.1.0: + resolution: {integrity: sha512-QUp6pM9pjKEVannNAbSJNeRuYwW3LshejfyBBpjeMGaJjaDUpVps4C6KVR8R7dWZnD3i0synmrE36znjTkJvdQ==, tarball: https://registry.npmmirror.com/eslint-define-config/-/eslint-define-config-2.1.0.tgz} + engines: {node: '>=18.0.0', npm: '>=9.0.0', pnpm: '>=8.6.0'} + + eslint-plugin-prettier@5.1.3: + resolution: {integrity: sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==, tarball: https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '*' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-plugin-vue@9.25.0: + resolution: {integrity: sha512-tDWlx14bVe6Bs+Nnh3IGrD+hb11kf2nukfm6jLsmJIhmiRQ1SUaksvwY9U5MvPB0pcrg0QK0xapQkfITs3RKOA==, tarball: https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.25.0.tgz} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==, tarball: https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==, tarball: https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.0: + resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==, tarball: https://registry.npmmirror.com/eslint/-/eslint-8.57.0.tgz} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + hasBin: true + + esniff@2.0.1: + resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==, tarball: https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz} + engines: {node: '>=0.10'} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==, tarball: https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, tarball: https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz} + engines: {node: '>=4'} + hasBin: true + + esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==, tarball: https://registry.npmmirror.com/esquery/-/esquery-1.5.0.tgz} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==, tarball: https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, tarball: https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==, tarball: https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, tarball: https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, tarball: https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==, tarball: https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz} + engines: {node: '>= 0.6'} + + event-emitter@0.3.5: + resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==, tarball: https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==, tarball: https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.1.tgz} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==, tarball: https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz} + engines: {node: '>=10'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==, tarball: https://registry.npmmirror.com/execa/-/execa-8.0.1.tgz} + engines: {node: '>=16.17'} + + expand-brackets@2.1.4: + resolution: {integrity: sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==, tarball: https://registry.npmmirror.com/expand-brackets/-/expand-brackets-2.1.4.tgz} + engines: {node: '>=0.10.0'} + + ext@1.7.0: + resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==, tarball: https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==, tarball: https://registry.npmmirror.com/extend-shallow/-/extend-shallow-2.0.1.tgz} + engines: {node: '>=0.10.0'} + + extend-shallow@3.0.2: + resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==, tarball: https://registry.npmmirror.com/extend-shallow/-/extend-shallow-3.0.2.tgz} + engines: {node: '>=0.10.0'} + + extglob@2.0.4: + resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==, tarball: https://registry.npmmirror.com/extglob/-/extglob-2.0.4.tgz} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, tarball: https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==, tarball: https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==, tarball: https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.2.tgz} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, tarball: https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, tarball: https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz} + + fast-xml-parser@4.3.6: + resolution: {integrity: sha512-M2SovcRxD4+vC493Uc2GZVcZaj66CCJhWurC4viynVSTvrpErCShNcDz1lAho6n9REQKvL/ll4A4/fw6Y9z8nw==, tarball: https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-4.3.6.tgz} + hasBin: true + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==, tarball: https://registry.npmmirror.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz} + engines: {node: '>= 4.9.1'} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==, tarball: https://registry.npmmirror.com/fastq/-/fastq-1.17.1.tgz} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==, tarball: https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz} + engines: {node: ^10.12.0 || >=12.0.0} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, tarball: https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz} + engines: {node: '>=16.0.0'} + + filelist@1.0.4: + resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==, tarball: https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz} + + fill-range@4.0.0: + resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==, tarball: https://registry.npmmirror.com/fill-range/-/fill-range-4.0.0.tgz} + engines: {node: '>=0.10.0'} + + fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==, tarball: https://registry.npmmirror.com/fill-range/-/fill-range-7.0.1.tgz} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==, tarball: https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, tarball: https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz} + engines: {node: '>=10'} + + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==, tarball: https://registry.npmmirror.com/find-up/-/find-up-7.0.0.tgz} + engines: {node: '>=18'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==, tarball: https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz} + engines: {node: ^10.12.0 || >=12.0.0} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, tarball: https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz} + engines: {node: '>=16'} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==, tarball: https://registry.npmmirror.com/flatted/-/flatted-3.3.1.tgz} + + follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==, tarball: https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.6.tgz} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==, tarball: https://registry.npmmirror.com/for-each/-/for-each-0.3.3.tgz} + + for-in@1.0.2: + resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==, tarball: https://registry.npmmirror.com/for-in/-/for-in-1.0.2.tgz} + engines: {node: '>=0.10.0'} + + foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==, tarball: https://registry.npmmirror.com/foreground-child/-/foreground-child-3.1.1.tgz} + engines: {node: '>=14'} + + form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==, tarball: https://registry.npmmirror.com/form-data/-/form-data-4.0.0.tgz} + engines: {node: '>= 6'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==, tarball: https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz} + + fragment-cache@0.2.1: + resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==, tarball: https://registry.npmmirror.com/fragment-cache/-/fragment-cache-0.2.1.tgz} + engines: {node: '>=0.10.0'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==, tarball: https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz} + engines: {node: '>=12'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==, tarball: https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, tarball: https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz} + + function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==, tarball: https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==, tarball: https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, tarball: https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, tarball: https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.2.0: + resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==, tarball: https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz} + engines: {node: '>=18'} + + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==, tarball: https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==, tarball: https://registry.npmmirror.com/get-stream/-/get-stream-6.0.1.tgz} + engines: {node: '>=10'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==, tarball: https://registry.npmmirror.com/get-stream/-/get-stream-8.0.1.tgz} + engines: {node: '>=16'} + + get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==, tarball: https://registry.npmmirror.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz} + engines: {node: '>= 0.4'} + + get-value@2.0.6: + resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==, tarball: https://registry.npmmirror.com/get-value/-/get-value-2.0.6.tgz} + engines: {node: '>=0.10.0'} + + git-raw-commits@4.0.0: + resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==, tarball: https://registry.npmmirror.com/git-raw-commits/-/git-raw-commits-4.0.0.tgz} + engines: {node: '>=16'} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==, tarball: https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==, tarball: https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz} + engines: {node: '>=10.13.0'} + + glob@10.3.12: + resolution: {integrity: sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==, tarball: https://registry.npmmirror.com/glob/-/glob-10.3.12.tgz} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==, tarball: https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz} + + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==, tarball: https://registry.npmmirror.com/global-directory/-/global-directory-4.0.1.tgz} + engines: {node: '>=18'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==, tarball: https://registry.npmmirror.com/global-modules/-/global-modules-2.0.0.tgz} + engines: {node: '>=6'} + + global-object@1.0.0: + resolution: {integrity: sha512-mSPSkY6UsHv6hgW0V2dfWBWTS8TnPnLx3ECVNoWp6rBI2Bg66VYoqGoTFlH/l7XhAZ/l+StYlntXlt87BEeCcg==, tarball: https://registry.npmmirror.com/global-object/-/global-object-1.0.0.tgz} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==, tarball: https://registry.npmmirror.com/global-prefix/-/global-prefix-3.0.0.tgz} + engines: {node: '>=6'} + + global@4.4.0: + resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==, tarball: https://registry.npmmirror.com/global/-/global-4.4.0.tgz} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==, tarball: https://registry.npmmirror.com/globals/-/globals-11.12.0.tgz} + engines: {node: '>=4'} + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==, tarball: https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz} + engines: {node: '>=8'} + + globalthis@1.0.3: + resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==, tarball: https://registry.npmmirror.com/globalthis/-/globalthis-1.0.3.tgz} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==, tarball: https://registry.npmmirror.com/globby/-/globby-11.1.0.tgz} + engines: {node: '>=10'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==, tarball: https://registry.npmmirror.com/globjoin/-/globjoin-0.1.4.tgz} + + gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==, tarball: https://registry.npmmirror.com/gopd/-/gopd-1.0.1.tgz} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, tarball: https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==, tarball: https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz} + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==, tarball: https://registry.npmmirror.com/gzip-size/-/gzip-size-6.0.0.tgz} + engines: {node: '>=10'} + + hammerjs@2.0.8: + resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==, tarball: https://registry.npmmirror.com/hammerjs/-/hammerjs-2.0.8.tgz} + engines: {node: '>=0.8.0'} + + has-ansi@2.0.0: + resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==, tarball: https://registry.npmmirror.com/has-ansi/-/has-ansi-2.0.0.tgz} + engines: {node: '>=0.10.0'} + + has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==, tarball: https://registry.npmmirror.com/has-bigints/-/has-bigints-1.0.2.tgz} + + has-flag@1.0.0: + resolution: {integrity: sha512-DyYHfIYwAJmjAjSSPKANxI8bFY9YtFrgkAfinBojQ8YJTOuOuav64tMUJv584SES4xl74PmuaevIyaLESHdTAA==, tarball: https://registry.npmmirror.com/has-flag/-/has-flag-1.0.0.tgz} + engines: {node: '>=0.10.0'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==, tarball: https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, tarball: https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==, tarball: https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz} + + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==, tarball: https://registry.npmmirror.com/has-proto/-/has-proto-1.0.3.tgz} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==, tarball: https://registry.npmmirror.com/has-symbols/-/has-symbols-1.0.3.tgz} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==, tarball: https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz} + engines: {node: '>= 0.4'} + + has-value@0.3.1: + resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==, tarball: https://registry.npmmirror.com/has-value/-/has-value-0.3.1.tgz} + engines: {node: '>=0.10.0'} + + has-value@1.0.0: + resolution: {integrity: sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==, tarball: https://registry.npmmirror.com/has-value/-/has-value-1.0.0.tgz} + engines: {node: '>=0.10.0'} + + has-values@0.1.4: + resolution: {integrity: sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==, tarball: https://registry.npmmirror.com/has-values/-/has-values-0.1.4.tgz} + engines: {node: '>=0.10.0'} + + has-values@1.0.0: + resolution: {integrity: sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==, tarball: https://registry.npmmirror.com/has-values/-/has-values-1.0.0.tgz} + engines: {node: '>=0.10.0'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, tarball: https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==, tarball: https://registry.npmmirror.com/he/-/he-1.2.0.tgz} + hasBin: true + + highlight.js@11.9.0: + resolution: {integrity: sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==, tarball: https://registry.npmmirror.com/highlight.js/-/highlight.js-11.9.0.tgz} + engines: {node: '>=12.0.0'} + + htm@3.1.1: + resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==, tarball: https://registry.npmmirror.com/htm/-/htm-3.1.1.tgz} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==, tarball: https://registry.npmmirror.com/html-tags/-/html-tags-3.3.1.tgz} + engines: {node: '>=8'} + + html-void-elements@2.0.1: + resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==, tarball: https://registry.npmmirror.com/html-void-elements/-/html-void-elements-2.0.1.tgz} + + htmlparser2@3.10.1: + resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==, tarball: https://registry.npmmirror.com/htmlparser2/-/htmlparser2-3.10.1.tgz} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==, tarball: https://registry.npmmirror.com/htmlparser2/-/htmlparser2-8.0.2.tgz} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, tarball: https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz} + engines: {node: '>=10.17.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==, tarball: https://registry.npmmirror.com/human-signals/-/human-signals-5.0.0.tgz} + engines: {node: '>=16.17.0'} + + i18next@20.6.1: + resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==, tarball: https://registry.npmmirror.com/i18next/-/i18next-20.6.1.tgz} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==, tarball: https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz} + engines: {node: '>=0.10.0'} + + ids@1.0.5: + resolution: {integrity: sha512-XQ0yom/4KWTL29sLG+tyuycy7UmeaM/79GRtSJq6IG9cJGIPeBz5kwDCguie3TwxaMNIc3WtPi0cTa1XYHicpw==, tarball: https://registry.npmmirror.com/ids/-/ids-1.0.5.tgz} + + ignore@5.3.1: + resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==, tarball: https://registry.npmmirror.com/ignore/-/ignore-5.3.1.tgz} + engines: {node: '>= 4'} + + image-size@0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==, tarball: https://registry.npmmirror.com/image-size/-/image-size-0.5.5.tgz} + engines: {node: '>=0.10.0'} + hasBin: true + + immer@9.0.21: + resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==, tarball: https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz} + + immutable@4.3.5: + resolution: {integrity: sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==, tarball: https://registry.npmmirror.com/immutable/-/immutable-4.3.5.tgz} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==, tarball: https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.0.tgz} + engines: {node: '>=6'} + + import-meta-resolve@4.0.0: + resolution: {integrity: sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==, tarball: https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, tarball: https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==, tarball: https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz} + engines: {node: '>=8'} + + indexof@0.0.1: + resolution: {integrity: sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==, tarball: https://registry.npmmirror.com/indexof/-/indexof-0.0.1.tgz} + + individual@2.0.0: + resolution: {integrity: sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g==, tarball: https://registry.npmmirror.com/individual/-/individual-2.0.0.tgz} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==, tarball: https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz} + + inherits-browser@0.1.0: + resolution: {integrity: sha512-CJHHvW3jQ6q7lzsXPpapLdMx5hDpSF3FSh45pwsj6bKxJJ8Nl8v43i5yXnr3BdfOimGHKyniewQtnAIp3vyJJw==, tarball: https://registry.npmmirror.com/inherits-browser/-/inherits-browser-0.1.0.tgz} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==, tarball: https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==, tarball: https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==, tarball: https://registry.npmmirror.com/ini/-/ini-4.1.1.tgz} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==, tarball: https://registry.npmmirror.com/internal-slot/-/internal-slot-1.0.7.tgz} + engines: {node: '>= 0.4'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==, tarball: https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz} + engines: {node: '>=12'} + + is-accessor-descriptor@1.0.1: + resolution: {integrity: sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==, tarball: https://registry.npmmirror.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz} + engines: {node: '>= 0.10'} + + is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==, tarball: https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==, tarball: https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz} + + is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==, tarball: https://registry.npmmirror.com/is-bigint/-/is-bigint-1.0.4.tgz} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==, tarball: https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz} + engines: {node: '>=8'} + + is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==, tarball: https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz} + engines: {node: '>= 0.4'} + + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==, tarball: https://registry.npmmirror.com/is-buffer/-/is-buffer-1.1.6.tgz} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==, tarball: https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz} + engines: {node: '>= 0.4'} + + is-core-module@2.13.1: + resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==, tarball: https://registry.npmmirror.com/is-core-module/-/is-core-module-2.13.1.tgz} + + is-data-descriptor@1.0.1: + resolution: {integrity: sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==, tarball: https://registry.npmmirror.com/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz} + engines: {node: '>= 0.4'} + + is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==, tarball: https://registry.npmmirror.com/is-data-view/-/is-data-view-1.0.1.tgz} + engines: {node: '>= 0.4'} + + is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==, tarball: https://registry.npmmirror.com/is-date-object/-/is-date-object-1.0.5.tgz} + engines: {node: '>= 0.4'} + + is-descriptor@0.1.7: + resolution: {integrity: sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==, tarball: https://registry.npmmirror.com/is-descriptor/-/is-descriptor-0.1.7.tgz} + engines: {node: '>= 0.4'} + + is-descriptor@1.0.3: + resolution: {integrity: sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==, tarball: https://registry.npmmirror.com/is-descriptor/-/is-descriptor-1.0.3.tgz} + engines: {node: '>= 0.4'} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==, tarball: https://registry.npmmirror.com/is-extendable/-/is-extendable-0.1.1.tgz} + engines: {node: '>=0.10.0'} + + is-extendable@1.0.1: + resolution: {integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==, tarball: https://registry.npmmirror.com/is-extendable/-/is-extendable-1.0.1.tgz} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, tarball: https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==, tarball: https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==, tarball: https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==, tarball: https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz} + engines: {node: '>=18'} + + is-function@1.0.2: + resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==, tarball: https://registry.npmmirror.com/is-function/-/is-function-1.0.2.tgz} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, tarball: https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz} + engines: {node: '>=0.10.0'} + + is-hotkey@0.2.0: + resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==, tarball: https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==, tarball: https://registry.npmmirror.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz} + engines: {node: '>= 0.4'} + + is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==, tarball: https://registry.npmmirror.com/is-number-object/-/is-number-object-1.0.7.tgz} + engines: {node: '>= 0.4'} + + is-number@3.0.0: + resolution: {integrity: sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==, tarball: https://registry.npmmirror.com/is-number/-/is-number-3.0.0.tgz} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, tarball: https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==, tarball: https://registry.npmmirror.com/is-obj/-/is-obj-2.0.0.tgz} + engines: {node: '>=8'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==, tarball: https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz} + engines: {node: '>=8'} + + is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==, tarball: https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz} + engines: {node: '>=0.10.0'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==, tarball: https://registry.npmmirror.com/is-plain-object/-/is-plain-object-2.0.4.tgz} + engines: {node: '>=0.10.0'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==, tarball: https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz} + engines: {node: '>=0.10.0'} + + is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==, tarball: https://registry.npmmirror.com/is-regex/-/is-regex-1.1.4.tgz} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==, tarball: https://registry.npmmirror.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==, tarball: https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==, tarball: https://registry.npmmirror.com/is-stream/-/is-stream-3.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==, tarball: https://registry.npmmirror.com/is-string/-/is-string-1.0.7.tgz} + engines: {node: '>= 0.4'} + + is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==, tarball: https://registry.npmmirror.com/is-symbol/-/is-symbol-1.0.4.tgz} + engines: {node: '>= 0.4'} + + is-text-path@2.0.0: + resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==, tarball: https://registry.npmmirror.com/is-text-path/-/is-text-path-2.0.0.tgz} + engines: {node: '>=8'} + + is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==, tarball: https://registry.npmmirror.com/is-typed-array/-/is-typed-array-1.1.13.tgz} + engines: {node: '>= 0.4'} + + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==, tarball: https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz} + + is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==, tarball: https://registry.npmmirror.com/is-weakref/-/is-weakref-1.0.2.tgz} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==, tarball: https://registry.npmmirror.com/is-windows/-/is-windows-1.0.2.tgz} + engines: {node: '>=0.10.0'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==, tarball: https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==, tarball: https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz} + + isobject@2.1.0: + resolution: {integrity: sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==, tarball: https://registry.npmmirror.com/isobject/-/isobject-2.1.0.tgz} + engines: {node: '>=0.10.0'} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==, tarball: https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz} + engines: {node: '>=0.10.0'} + + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==, tarball: https://registry.npmmirror.com/jackspeak/-/jackspeak-2.3.6.tgz} + engines: {node: '>=14'} + + jake@10.8.7: + resolution: {integrity: sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==, tarball: https://registry.npmmirror.com/jake/-/jake-10.8.7.tgz} + engines: {node: '>=10'} + hasBin: true + + jiti@1.21.0: + resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==, tarball: https://registry.npmmirror.com/jiti/-/jiti-1.21.0.tgz} + hasBin: true + + js-base64@2.6.4: + resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==, tarball: https://registry.npmmirror.com/js-base64/-/js-base64-2.6.4.tgz} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, tarball: https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz} + + js-tokens@8.0.3: + resolution: {integrity: sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==, tarball: https://registry.npmmirror.com/js-tokens/-/js-tokens-8.0.3.tgz} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==, tarball: https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz} + hasBin: true + + jsencrypt@3.3.2: + resolution: {integrity: sha512-arQR1R1ESGdAxY7ZheWr12wCaF2yF47v5qpB76TtV64H1pyGudk9Hvw8Y9tb/FiTIaaTRUyaSnm5T/Y53Ghm/A==, tarball: https://registry.npmmirror.com/jsencrypt/-/jsencrypt-3.3.2.tgz} + + jsesc@0.5.0: + resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==, tarball: https://registry.npmmirror.com/jsesc/-/jsesc-0.5.0.tgz} + hasBin: true + + jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==, tarball: https://registry.npmmirror.com/jsesc/-/jsesc-2.5.2.tgz} + engines: {node: '>=4'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, tarball: https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==, tarball: https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, tarball: https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==, tarball: https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz} + + json-source-map@0.6.1: + resolution: {integrity: sha512-1QoztHPsMQqhDq0hlXY5ZqcEdUzxQEIxgFkKl4WUp2pgShObl+9ovi4kRh2TfvAfxAoHOJ9vIMEqk3k4iex7tg==, tarball: https://registry.npmmirror.com/json-source-map/-/json-source-map-0.6.1.tgz} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, tarball: https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==, tarball: https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, tarball: https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz} + engines: {node: '>=6'} + hasBin: true + + jsonc-eslint-parser@2.4.0: + resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==, tarball: https://registry.npmmirror.com/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.0.tgz} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==, tarball: https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz} + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==, tarball: https://registry.npmmirror.com/jsonparse/-/jsonparse-1.3.1.tgz} + engines: {'0': node >= 0.2.0} + + katex@0.16.11: + resolution: {integrity: sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==, tarball: https://registry.npmmirror.com/katex/-/katex-0.16.11.tgz} + hasBin: true + + keycode@2.2.1: + resolution: {integrity: sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==, tarball: https://registry.npmmirror.com/keycode/-/keycode-2.2.1.tgz} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, tarball: https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz} + + kind-of@3.2.2: + resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==, tarball: https://registry.npmmirror.com/kind-of/-/kind-of-3.2.2.tgz} + engines: {node: '>=0.10.0'} + + kind-of@4.0.0: + resolution: {integrity: sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==, tarball: https://registry.npmmirror.com/kind-of/-/kind-of-4.0.0.tgz} + engines: {node: '>=0.10.0'} + + kind-of@5.1.0: + resolution: {integrity: sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==, tarball: https://registry.npmmirror.com/kind-of/-/kind-of-5.1.0.tgz} + engines: {node: '>=0.10.0'} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==, tarball: https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz} + engines: {node: '>=0.10.0'} + + known-css-properties@0.30.0: + resolution: {integrity: sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==, tarball: https://registry.npmmirror.com/known-css-properties/-/known-css-properties-0.30.0.tgz} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==, tarball: https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, tarball: https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz} + engines: {node: '>= 0.8.0'} + + lilconfig@3.0.0: + resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==, tarball: https://registry.npmmirror.com/lilconfig/-/lilconfig-3.0.0.tgz} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, tarball: https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==, tarball: https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz} + + lint-staged@15.2.2: + resolution: {integrity: sha512-TiTt93OPh1OZOsb5B7k96A/ATl2AjIZo+vnzFZ6oHK5FuTk63ByDtxGQpHm+kFETjEWqgkF95M8FRXKR/LEBcw==, tarball: https://registry.npmmirror.com/lint-staged/-/lint-staged-15.2.2.tgz} + engines: {node: '>=18.12.0'} + hasBin: true + + listr2@8.0.1: + resolution: {integrity: sha512-ovJXBXkKGfq+CwmKTjluEqFi3p4h8xvkxGQQAQan22YCgef4KZ1mKGjzfGh6PL6AW5Csw0QiQPNuQyH+6Xk3hA==, tarball: https://registry.npmmirror.com/listr2/-/listr2-8.0.1.tgz} + engines: {node: '>=18.0.0'} + + loader-utils@1.4.2: + resolution: {integrity: sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==, tarball: https://registry.npmmirror.com/loader-utils/-/loader-utils-1.4.2.tgz} + engines: {node: '>=4.0.0'} + + local-pkg@0.4.3: + resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==, tarball: https://registry.npmmirror.com/local-pkg/-/local-pkg-0.4.3.tgz} + engines: {node: '>=14'} + + local-pkg@0.5.0: + resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==, tarball: https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.0.tgz} + engines: {node: '>=14'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==, tarball: https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, tarball: https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz} + engines: {node: '>=10'} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==, tarball: https://registry.npmmirror.com/locate-path/-/locate-path-7.2.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==, tarball: https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==, tarball: https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==, tarball: https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==, tarball: https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==, tarball: https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz} + + lodash.foreach@4.5.0: + resolution: {integrity: sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==, tarball: https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==, tarball: https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==, tarball: https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==, tarball: https://registry.npmmirror.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, tarball: https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz} + + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==, tarball: https://registry.npmmirror.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==, tarball: https://registry.npmmirror.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==, tarball: https://registry.npmmirror.com/lodash.startcase/-/lodash.startcase-4.4.0.tgz} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==, tarball: https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz} + + lodash.toarray@4.4.0: + resolution: {integrity: sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==, tarball: https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==, tarball: https://registry.npmmirror.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==, tarball: https://registry.npmmirror.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==, tarball: https://registry.npmmirror.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==, tarball: https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz} + + log-update@6.0.0: + resolution: {integrity: sha512-niTvB4gqvtof056rRIrTZvjNYE4rCUzO6X/X+kYjd7WFxXeJ0NwEFnRxX6ehkvv3jTwrXnNdtAak5XYZuIyPFw==, tarball: https://registry.npmmirror.com/log-update/-/log-update-6.0.0.tgz} + engines: {node: '>=18'} + + loglevel-colored-level-prefix@1.0.0: + resolution: {integrity: sha512-u45Wcxxc+SdAlh4yeF/uKlC1SPUPCy0gullSNKXod5I4bmifzk+Q4lSLExNEVn19tGaJipbZ4V4jbFn79/6mVA==, tarball: https://registry.npmmirror.com/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz} + + loglevel@1.9.1: + resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==, tarball: https://registry.npmmirror.com/loglevel/-/loglevel-1.9.1.tgz} + engines: {node: '>= 0.6.0'} + + lru-cache@10.2.2: + resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==, tarball: https://registry.npmmirror.com/lru-cache/-/lru-cache-10.2.2.tgz} + engines: {node: 14 || >=16.14} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, tarball: https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==, tarball: https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz} + engines: {node: '>=10'} + + m3u8-parser@4.8.0: + resolution: {integrity: sha512-UqA2a/Pw3liR6Df3gwxrqghCP17OpPlQj6RBPLYygf/ZSQ4MoSgvdvhvt35qV+3NaaA0FSZx93Ix+2brT1U7cA==, tarball: https://registry.npmmirror.com/m3u8-parser/-/m3u8-parser-4.8.0.tgz} + + magic-string@0.30.10: + resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==, tarball: https://registry.npmmirror.com/magic-string/-/magic-string-0.30.10.tgz} + + map-cache@0.2.2: + resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==, tarball: https://registry.npmmirror.com/map-cache/-/map-cache-0.2.2.tgz} + engines: {node: '>=0.10.0'} + + map-visit@1.0.0: + resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==, tarball: https://registry.npmmirror.com/map-visit/-/map-visit-1.0.0.tgz} + engines: {node: '>=0.10.0'} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==, tarball: https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz} + hasBin: true + + markmap-common@0.16.0: + resolution: {integrity: sha512-q3nlNDMKuWXTm3VwZFY9V5zteL/+iBLZanUK5vS+e26bUbzTSG5VtAzsyJbmgJm1WhwmIIAxbXEnp6JdvtTduA==, tarball: https://registry.npmmirror.com/markmap-common/-/markmap-common-0.16.0.tgz} + + markmap-html-parser@0.16.1: + resolution: {integrity: sha512-/Mgm4g1qMQ8uEOz8h8K+jPspdgjfw29NqmfTLZSt8yG+vW7fWWduPjGRFc5axAZxCzP7PTzZLEuOxAqOwEg8Bg==, tarball: https://registry.npmmirror.com/markmap-html-parser/-/markmap-html-parser-0.16.1.tgz} + peerDependencies: + markmap-common: '*' + + markmap-lib@0.16.1: + resolution: {integrity: sha512-jD8VsB67m677IRehGSwwVJDlC6PS+xzDKsJOwdvjZ+ndfXrHa1lyqfvR6mIwvGGUIciF86YEITSKL9hQTHE4Rw==, tarball: https://registry.npmmirror.com/markmap-lib/-/markmap-lib-0.16.1.tgz} + peerDependencies: + markmap-common: '*' + + markmap-toolbar@0.17.0: + resolution: {integrity: sha512-zRkg+pYtjDefJ4lSG0KownAN3eqkJcrTei+HbobBWsWTsc7qdUMn2Ewd97SFHCkGoo1nrG0aW7dzDP6lHWuDkw==, tarball: https://registry.npmmirror.com/markmap-toolbar/-/markmap-toolbar-0.17.0.tgz} + peerDependencies: + markmap-common: '*' + + markmap-view@0.16.0: + resolution: {integrity: sha512-JOiSEThs8B4bAP9E6rcCWOz2SsMwCBFaR76wLARRVb04C/qLiLmvrm675kNPq4lRBAwtugHCYvjG0otpSlB4Cw==, tarball: https://registry.npmmirror.com/markmap-view/-/markmap-view-0.16.0.tgz} + peerDependencies: + markmap-common: '*' + + matches-selector@1.2.0: + resolution: {integrity: sha512-c4vLwYWyl+Ji+U43eU/G5FwxWd4ZH0ePUsFs5y0uwD9HUEFBXUQ1zUUan+78IpRD+y4pUfG0nAzNM292K7ItvA==, tarball: https://registry.npmmirror.com/matches-selector/-/matches-selector-1.2.0.tgz} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==, tarball: https://registry.npmmirror.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz} + + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==, tarball: https://registry.npmmirror.com/mdn-data/-/mdn-data-2.0.14.tgz} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==, tarball: https://registry.npmmirror.com/mdn-data/-/mdn-data-2.0.30.tgz} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==, tarball: https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==, tarball: https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz} + + meow@12.1.1: + resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==, tarball: https://registry.npmmirror.com/meow/-/meow-12.1.1.tgz} + engines: {node: '>=16.10'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==, tarball: https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz} + engines: {node: '>=18'} + + merge-options@1.0.1: + resolution: {integrity: sha512-iuPV41VWKWBIOpBsjoxjDZw8/GbSfZ2mk7N1453bwMrfzdrIk7EzBd+8UVR6rkw67th7xnk9Dytl3J+lHPdxvg==, tarball: https://registry.npmmirror.com/merge-options/-/merge-options-1.0.1.tgz} + engines: {node: '>=4'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==, tarball: https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, tarball: https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz} + engines: {node: '>= 8'} + + micromatch@3.1.0: + resolution: {integrity: sha512-3StSelAE+hnRvMs8IdVW7Uhk8CVed5tp+kLLGlBP6WiRAXS21GPGu/Nat4WNPXj2Eoc24B02SaeoyozPMfj0/g==, tarball: https://registry.npmmirror.com/micromatch/-/micromatch-3.1.0.tgz} + engines: {node: '>=0.10.0'} + + micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==, tarball: https://registry.npmmirror.com/micromatch/-/micromatch-4.0.5.tgz} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, tarball: https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz} + engines: {node: '>= 0.6'} + + mime-match@1.0.2: + resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==, tarball: https://registry.npmmirror.com/mime-match/-/mime-match-1.0.2.tgz} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, tarball: https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==, tarball: https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==, tarball: https://registry.npmmirror.com/mimic-fn/-/mimic-fn-4.0.0.tgz} + engines: {node: '>=12'} + + min-dash@3.8.1: + resolution: {integrity: sha512-evumdlmIlg9mbRVPbC4F5FuRhNmcMS5pvuBUbqb1G9v09Ro0ImPEgz5n3khir83lFok1inKqVDjnKEg3GpDxQg==, tarball: https://registry.npmmirror.com/min-dash/-/min-dash-3.8.1.tgz} + + min-dash@4.2.1: + resolution: {integrity: sha512-to+unsToePnm7cUeR9TrMzFlETHd/UXmU+ELTRfWZj5XGT41KF6X3L233o3E/GdEs3sk2Tbw/lOLD1avmWkg8A==, tarball: https://registry.npmmirror.com/min-dash/-/min-dash-4.2.1.tgz} + + min-document@2.19.0: + resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==, tarball: https://registry.npmmirror.com/min-document/-/min-document-2.19.0.tgz} + + min-dom@0.2.0: + resolution: {integrity: sha512-VmxugbnAcVZGqvepjhOA4d4apmrpX8mMaRS+/jo0dI5Yorzrr4Ru9zc9KVALlY/+XakVCb8iQ+PYXljihQcsNw==, tarball: https://registry.npmmirror.com/min-dom/-/min-dom-0.2.0.tgz} + + min-dom@3.2.1: + resolution: {integrity: sha512-v6YCmnDzxk4rRJntWTUiwggLupPw/8ZSRqUq0PDaBwVZEO/wYzCH4SKVBV+KkEvf3u0XaWHly5JEosPtqRATZA==, tarball: https://registry.npmmirror.com/min-dom/-/min-dom-3.2.1.tgz} + + min-dom@4.1.0: + resolution: {integrity: sha512-1lj1EyoSwY/UmTeT/hhPiZTsq+vK9D+8FAJ/53iK5jT1otkG9rJTixSKdjmTieEvdfES+sKbbTptzaQJhnacjA==, tarball: https://registry.npmmirror.com/min-dom/-/min-dom-4.1.0.tgz} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==, tarball: https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==, tarball: https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz} + engines: {node: '>=10'} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==, tarball: https://registry.npmmirror.com/minimatch/-/minimatch-9.0.3.tgz} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.4: + resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==, tarball: https://registry.npmmirror.com/minimatch/-/minimatch-9.0.4.tgz} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, tarball: https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz} + + minipass@7.0.4: + resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==, tarball: https://registry.npmmirror.com/minipass/-/minipass-7.0.4.tgz} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@1.2.0: + resolution: {integrity: sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==, tarball: https://registry.npmmirror.com/mitt/-/mitt-1.2.0.tgz} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==, tarball: https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz} + + mixin-deep@1.3.2: + resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==, tarball: https://registry.npmmirror.com/mixin-deep/-/mixin-deep-1.3.2.tgz} + engines: {node: '>=0.10.0'} + + mlly@1.6.1: + resolution: {integrity: sha512-vLgaHvaeunuOXHSmEbZ9izxPx3USsk8KCQ8iC+aTlp5sKRSoZvwhHh5L9VbKSaVC6sJDqbyohIS76E2VmHIPAA==, tarball: https://registry.npmmirror.com/mlly/-/mlly-1.6.1.tgz} + + moddle-xml@9.0.6: + resolution: {integrity: sha512-tl0reHpsY/aKlLGhXeFlQWlYAQHFxTkFqC8tq8jXRYpQSnLVw13T6swMaourLd7EXqHdWsc+5ggsB+fEep6xZQ==, tarball: https://registry.npmmirror.com/moddle-xml/-/moddle-xml-9.0.6.tgz} + + moddle@5.0.4: + resolution: {integrity: sha512-Kjb+hjuzO+YlojNGxEUXvdhLYTHTtAABDlDcJTtTcn5MbJF9Zkv4I1Fyvp3Ypmfgg1EfHDZ3PsCQTuML9JD6wg==, tarball: https://registry.npmmirror.com/moddle/-/moddle-5.0.4.tgz} + + mpd-parser@0.22.1: + resolution: {integrity: sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==, tarball: https://registry.npmmirror.com/mpd-parser/-/mpd-parser-0.22.1.tgz} + hasBin: true + + mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==, tarball: https://registry.npmmirror.com/mrmime/-/mrmime-2.0.0.tgz} + engines: {node: '>=10'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==, tarball: https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==, tarball: https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz} + + muggle-string@0.3.1: + resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==, tarball: https://registry.npmmirror.com/muggle-string/-/muggle-string-0.3.1.tgz} + + mux.js@6.0.1: + resolution: {integrity: sha512-22CHb59rH8pWGcPGW5Og7JngJ9s+z4XuSlYvnxhLuc58cA1WqGDQPzuG8I+sPm1/p0CdgpzVTaKW408k5DNn8w==, tarball: https://registry.npmmirror.com/mux.js/-/mux.js-6.0.1.tgz} + engines: {node: '>=8', npm: '>=5'} + hasBin: true + + namespace-emitter@2.0.1: + resolution: {integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==, tarball: https://registry.npmmirror.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz} + + nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==, tarball: https://registry.npmmirror.com/nanoid/-/nanoid-3.3.7.tgz} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanomatch@1.2.13: + resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==, tarball: https://registry.npmmirror.com/nanomatch/-/nanomatch-1.2.13.tgz} + engines: {node: '>=0.10.0'} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, tarball: https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz} + + next-tick@1.1.0: + resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==, tarball: https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz} + + node-fetch-native@1.6.4: + resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==, tarball: https://registry.npmmirror.com/node-fetch-native/-/node-fetch-native-1.6.4.tgz} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==, tarball: https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-releases@2.0.14: + resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==, tarball: https://registry.npmmirror.com/node-releases/-/node-releases-2.0.14.tgz} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==, tarball: https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==, tarball: https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz} + engines: {node: '>=0.10.0'} + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==, tarball: https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==, tarball: https://registry.npmmirror.com/npm-run-path/-/npm-run-path-4.0.1.tgz} + engines: {node: '>=8'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==, tarball: https://registry.npmmirror.com/npm-run-path/-/npm-run-path-5.3.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npm2url@0.2.4: + resolution: {integrity: sha512-arzGp/hQz0Ey+ZGhF64XVH7Xqwd+1Q/po5uGiBbzph8ebX6T0uvt3N7c1nBHQNsQVykQgHhqoRTX7JFcHecGuw==, tarball: https://registry.npmmirror.com/npm2url/-/npm2url-0.2.4.tgz} + + nprogress@0.2.0: + resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==, tarball: https://registry.npmmirror.com/nprogress/-/nprogress-0.2.0.tgz} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, tarball: https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz} + engines: {node: '>=0.10.0'} + + object-copy@0.1.0: + resolution: {integrity: sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==, tarball: https://registry.npmmirror.com/object-copy/-/object-copy-0.1.0.tgz} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.1: + resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==, tarball: https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.1.tgz} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==, tarball: https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz} + engines: {node: '>= 0.4'} + + object-refs@0.3.0: + resolution: {integrity: sha512-eP0ywuoWOaDoiake/6kTJlPJhs+k0qNm4nYRzXLNHj6vh+5M3i9R1epJTdxIPGlhWc4fNRQ7a6XJNCX+/L4FOQ==, tarball: https://registry.npmmirror.com/object-refs/-/object-refs-0.3.0.tgz} + + object-visit@1.0.1: + resolution: {integrity: sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==, tarball: https://registry.npmmirror.com/object-visit/-/object-visit-1.0.1.tgz} + engines: {node: '>=0.10.0'} + + object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==, tarball: https://registry.npmmirror.com/object.assign/-/object.assign-4.1.5.tgz} + engines: {node: '>= 0.4'} + + object.pick@1.3.0: + resolution: {integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==, tarball: https://registry.npmmirror.com/object.pick/-/object.pick-1.3.0.tgz} + engines: {node: '>=0.10.0'} + + ofetch@1.3.4: + resolution: {integrity: sha512-KLIET85ik3vhEfS+3fDlc/BAZiAp+43QEC/yCo5zkNoY2YaKvNkOaFr/6wCFgFH1kuYQM5pMNi0Tg8koiIemtw==, tarball: https://registry.npmmirror.com/ofetch/-/ofetch-1.3.4.tgz} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, tarball: https://registry.npmmirror.com/once/-/once-1.4.0.tgz} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==, tarball: https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==, tarball: https://registry.npmmirror.com/onetime/-/onetime-6.0.0.tgz} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, tarball: https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz} + engines: {node: '>= 0.8.0'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==, tarball: https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, tarball: https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz} + engines: {node: '>=10'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==, tarball: https://registry.npmmirror.com/p-limit/-/p-limit-4.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==, tarball: https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, tarball: https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz} + engines: {node: '>=10'} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==, tarball: https://registry.npmmirror.com/p-locate/-/p-locate-6.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==, tarball: https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz} + engines: {node: '>=6'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, tarball: https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, tarball: https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz} + engines: {node: '>=8'} + + parse5-htmlparser2-tree-adapter@7.0.0: + resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==, tarball: https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz} + + parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==, tarball: https://registry.npmmirror.com/parse5/-/parse5-7.1.2.tgz} + + pascalcase@0.1.1: + resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==, tarball: https://registry.npmmirror.com/pascalcase/-/pascalcase-0.1.1.tgz} + engines: {node: '>=0.10.0'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==, tarball: https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz} + engines: {node: '>=8'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==, tarball: https://registry.npmmirror.com/path-exists/-/path-exists-5.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-intersection@2.2.1: + resolution: {integrity: sha512-9u8xvMcSfuOiStv9bPdnRJQhGQXLKurew94n4GPQCdH1nj9QKC9ObbNoIpiRq8skiOBxKkt277PgOoFgAt3/rA==, tarball: https://registry.npmmirror.com/path-intersection/-/path-intersection-2.2.1.tgz} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==, tarball: https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, tarball: https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==, tarball: https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==, tarball: https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz} + + path-scurry@1.10.2: + resolution: {integrity: sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==, tarball: https://registry.npmmirror.com/path-scurry/-/path-scurry-1.10.2.tgz} + engines: {node: '>=16 || 14 >=14.17'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==, tarball: https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz} + engines: {node: '>=8'} + + pathe@0.2.0: + resolution: {integrity: sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==, tarball: https://registry.npmmirror.com/pathe/-/pathe-0.2.0.tgz} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==, tarball: https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==, tarball: https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz} + + picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==, tarball: https://registry.npmmirror.com/picocolors/-/picocolors-1.0.0.tgz} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==, tarball: https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz} + engines: {node: '>=8.6'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==, tarball: https://registry.npmmirror.com/pidtree/-/pidtree-0.6.0.tgz} + engines: {node: '>=0.10'} + hasBin: true + + pinia-plugin-persistedstate@3.2.1: + resolution: {integrity: sha512-MK++8LRUsGF7r45PjBFES82ISnPzyO6IZx3CH5vyPseFLZCk1g2kgx6l/nW8pEBKxxd4do0P6bJw+mUSZIEZUQ==, tarball: https://registry.npmmirror.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.1.tgz} + peerDependencies: + pinia: ^2.0.0 + + pinia@2.1.7: + resolution: {integrity: sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==, tarball: https://registry.npmmirror.com/pinia/-/pinia-2.1.7.tgz} + peerDependencies: + '@vue/composition-api': ^1.4.0 + typescript: '>=4.4.4' + vue: ^2.6.14 || ^3.3.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + typescript: + optional: true + + pkcs7@1.0.4: + resolution: {integrity: sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==, tarball: https://registry.npmmirror.com/pkcs7/-/pkcs7-1.0.4.tgz} + hasBin: true + + pkg-types@1.1.0: + resolution: {integrity: sha512-/RpmvKdxKf8uILTtoOhAgf30wYbP2Qw+L9p3Rvshx1JZVX+XQNZQFjlbmGHEGIm4CkVPlSn+NXmIM8+9oWQaSA==, tarball: https://registry.npmmirror.com/pkg-types/-/pkg-types-1.1.0.tgz} + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==, tarball: https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz} + engines: {node: '>=10.13.0'} + + posix-character-classes@0.1.1: + resolution: {integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==, tarball: https://registry.npmmirror.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz} + engines: {node: '>=0.10.0'} + + possible-typed-array-names@1.0.0: + resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==, tarball: https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz} + engines: {node: '>= 0.4'} + + postcss-html@1.6.0: + resolution: {integrity: sha512-OWgQ9/Pe23MnNJC0PL4uZp8k0EDaUvqpJFSiwFxOLClAhmD7UEisyhO3x5hVsD4xFrjReVTXydlrMes45dJ71w==, tarball: https://registry.npmmirror.com/postcss-html/-/postcss-html-1.6.0.tgz} + engines: {node: ^12 || >=14} + + postcss-prefix-selector@1.16.1: + resolution: {integrity: sha512-Umxu+FvKMwlY6TyDzGFoSUnzW+NOfMBLyC1tAkIjgX+Z/qGspJeRjVC903D7mx7TuBpJlwti2ibXtWuA7fKMeQ==, tarball: https://registry.npmmirror.com/postcss-prefix-selector/-/postcss-prefix-selector-1.16.1.tgz} + peerDependencies: + postcss: '>4 <9' + + postcss-resolve-nested-selector@0.1.1: + resolution: {integrity: sha512-HvExULSwLqHLgUy1rl3ANIqCsvMS0WHss2UOsXhXnQaZ9VCc2oBvIpXrl00IUFT5ZDITME0o6oiXeiHr2SAIfw==, tarball: https://registry.npmmirror.com/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz} + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==, tarball: https://registry.npmmirror.com/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-safe-parser@7.0.0: + resolution: {integrity: sha512-ovehqRNVCpuFzbXoTb4qLtyzK3xn3t/CUBxOs8LsnQjQrShaB4lKiHoVqY8ANaC0hBMHq5QVWk77rwGklFUDrg==, tarball: https://registry.npmmirror.com/postcss-safe-parser/-/postcss-safe-parser-7.0.0.tgz} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==, tarball: https://registry.npmmirror.com/postcss-scss/-/postcss-scss-4.0.9.tgz} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@6.0.16: + resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==, tarball: https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz} + engines: {node: '>=4'} + + postcss-sorting@8.0.2: + resolution: {integrity: sha512-M9dkSrmU00t/jK7rF6BZSZauA5MAaBW4i5EnJXspMwt4iqTh/L9j6fgMnbElEOfyRyfLfVbIHj/R52zHzAPe1Q==, tarball: https://registry.npmmirror.com/postcss-sorting/-/postcss-sorting-8.0.2.tgz} + peerDependencies: + postcss: ^8.4.20 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==, tarball: https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz} + + postcss@5.2.18: + resolution: {integrity: sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==, tarball: https://registry.npmmirror.com/postcss/-/postcss-5.2.18.tgz} + engines: {node: '>=0.12'} + + postcss@8.4.38: + resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==, tarball: https://registry.npmmirror.com/postcss/-/postcss-8.4.38.tgz} + engines: {node: ^10 || ^12 || >=14} + + posthtml-parser@0.2.1: + resolution: {integrity: sha512-nPC53YMqJnc/+1x4fRYFfm81KV2V+G9NZY+hTohpYg64Ay7NemWWcV4UWuy/SgMupqQ3kJ88M/iRfZmSnxT+pw==, tarball: https://registry.npmmirror.com/posthtml-parser/-/posthtml-parser-0.2.1.tgz} + + posthtml-rename-id@1.0.12: + resolution: {integrity: sha512-UKXf9OF/no8WZo9edRzvuMenb6AD5hDLzIepJW+a4oJT+T/Lx7vfMYWT4aWlGNQh0WMhnUx1ipN9OkZ9q+ddEw==, tarball: https://registry.npmmirror.com/posthtml-rename-id/-/posthtml-rename-id-1.0.12.tgz} + + posthtml-render@1.4.0: + resolution: {integrity: sha512-W1779iVHGfq0Fvh2PROhCe2QhB8mEErgqzo1wpIt36tCgChafP+hbXIhLDOM8ePJrZcFs0vkNEtdibEWVqChqw==, tarball: https://registry.npmmirror.com/posthtml-render/-/posthtml-render-1.4.0.tgz} + engines: {node: '>=10'} + + posthtml-svg-mode@1.0.3: + resolution: {integrity: sha512-hEqw9NHZ9YgJ2/0G7CECOeuLQKZi8HjWLkBaSVtOWjygQ9ZD8P7tqeowYs7WrFdKsWEKG7o+IlsPY8jrr0CJpQ==, tarball: https://registry.npmmirror.com/posthtml-svg-mode/-/posthtml-svg-mode-1.0.3.tgz} + + posthtml@0.9.2: + resolution: {integrity: sha512-spBB5sgC4cv2YcW03f/IAUN1pgDJWNWD8FzkyY4mArLUMJW+KlQhlmUdKAHQuPfb00Jl5xIfImeOsf6YL8QK7Q==, tarball: https://registry.npmmirror.com/posthtml/-/posthtml-0.9.2.tgz} + engines: {node: '>=0.10.0'} + + preact@10.20.2: + resolution: {integrity: sha512-S1d1ernz3KQ+Y2awUxKakpfOg2CEmJmwOP+6igPx6dgr6pgDvenqYviyokWso2rhHvGtTlWWnJDa7RaPbQerTg==, tarball: https://registry.npmmirror.com/preact/-/preact-10.20.2.tgz} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, tarball: https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz} + engines: {node: '>= 0.8.0'} + + prettier-eslint@16.3.0: + resolution: {integrity: sha512-Lh102TIFCr11PJKUMQ2kwNmxGhTsv/KzUg9QYF2Gkw259g/kPgndZDWavk7/ycbRvj2oz4BPZ1gCU8bhfZH/Xg==, tarball: https://registry.npmmirror.com/prettier-eslint/-/prettier-eslint-16.3.0.tgz} + engines: {node: '>=16.10.0'} + peerDependencies: + prettier-plugin-svelte: ^3.0.0 + svelte-eslint-parser: '*' + peerDependenciesMeta: + prettier-plugin-svelte: + optional: true + svelte-eslint-parser: + optional: true + + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==, tarball: https://registry.npmmirror.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz} + engines: {node: '>=6.0.0'} + + prettier@3.2.5: + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==, tarball: https://registry.npmmirror.com/prettier/-/prettier-3.2.5.tgz} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==, tarball: https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + prismjs@1.29.0: + resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==, tarball: https://registry.npmmirror.com/prismjs/-/prismjs-1.29.0.tgz} + engines: {node: '>=6'} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==, tarball: https://registry.npmmirror.com/process/-/process-0.11.10.tgz} + engines: {node: '>= 0.6.0'} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==, tarball: https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz} + engines: {node: '>=0.4.0'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, tarball: https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==, tarball: https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz} + engines: {node: '>=6'} + + punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==, tarball: https://registry.npmmirror.com/punycode/-/punycode-1.4.1.tgz} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, tarball: https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz} + engines: {node: '>=6'} + + qrcode@1.5.3: + resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==, tarball: https://registry.npmmirror.com/qrcode/-/qrcode-1.5.3.tgz} + engines: {node: '>=10.13.0'} + hasBin: true + + qs@6.12.1: + resolution: {integrity: sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==, tarball: https://registry.npmmirror.com/qs/-/qs-6.12.1.tgz} + engines: {node: '>=0.6'} + + query-string@4.3.4: + resolution: {integrity: sha512-O2XLNDBIg1DnTOa+2XrIwSiXEV8h2KImXUnjhhn2+UsvZ+Es2uyd5CCRTNQlDGbzUQOW3aYCBx9rVA6dzsiY7Q==, tarball: https://registry.npmmirror.com/query-string/-/query-string-4.3.4.tgz} + engines: {node: '>=0.10.0'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, tarball: https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz} + + rd@2.0.1: + resolution: {integrity: sha512-/XdKU4UazUZTXFmI0dpABt8jSXPWcEyaGdk340KdHnsEOdkTctlX23aAK7ChQDn39YGNlAJr1M5uvaKt4QnpNw==, tarball: https://registry.npmmirror.com/rd/-/rd-2.0.1.tgz} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, tarball: https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==, tarball: https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==, tarball: https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz} + engines: {node: '>=8.10.0'} + + regenerate-unicode-properties@10.1.1: + resolution: {integrity: sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==, tarball: https://registry.npmmirror.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==, tarball: https://registry.npmmirror.com/regenerate/-/regenerate-1.4.2.tgz} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==, tarball: https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz} + + regenerator-transform@0.15.2: + resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==, tarball: https://registry.npmmirror.com/regenerator-transform/-/regenerator-transform-0.15.2.tgz} + + regex-not@1.0.2: + resolution: {integrity: sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==, tarball: https://registry.npmmirror.com/regex-not/-/regex-not-1.0.2.tgz} + engines: {node: '>=0.10.0'} + + regexp.prototype.flags@1.5.2: + resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==, tarball: https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz} + engines: {node: '>= 0.4'} + + regexpu-core@5.3.2: + resolution: {integrity: sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==, tarball: https://registry.npmmirror.com/regexpu-core/-/regexpu-core-5.3.2.tgz} + engines: {node: '>=4'} + + regjsparser@0.9.1: + resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==, tarball: https://registry.npmmirror.com/regjsparser/-/regjsparser-0.9.1.tgz} + hasBin: true + + remarkable-katex@1.2.1: + resolution: {integrity: sha512-Y1VquJBZnaVsfsVcKW2hmjT+pDL7mp8l5WAVlvuvViltrdok2m1AIKmJv8SsH+mBY84PoMw67t3kTWw1dIm8+g==, tarball: https://registry.npmmirror.com/remarkable-katex/-/remarkable-katex-1.2.1.tgz} + + remarkable@2.0.1: + resolution: {integrity: sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA==, tarball: https://registry.npmmirror.com/remarkable/-/remarkable-2.0.1.tgz} + engines: {node: '>= 6.0.0'} + hasBin: true + + repeat-element@1.1.4: + resolution: {integrity: sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==, tarball: https://registry.npmmirror.com/repeat-element/-/repeat-element-1.1.4.tgz} + engines: {node: '>=0.10.0'} + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==, tarball: https://registry.npmmirror.com/repeat-string/-/repeat-string-1.6.1.tgz} + engines: {node: '>=0.10'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, tarball: https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==, tarball: https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==, tarball: https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz} + + require-relative@0.8.7: + resolution: {integrity: sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg==, tarball: https://registry.npmmirror.com/require-relative/-/require-relative-0.8.7.tgz} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, tarball: https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==, tarball: https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz} + engines: {node: '>=8'} + + resolve-url@0.2.1: + resolution: {integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==, tarball: https://registry.npmmirror.com/resolve-url/-/resolve-url-0.2.1.tgz} + deprecated: https://github.com/lydell/resolve-url#deprecated + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==, tarball: https://registry.npmmirror.com/resolve/-/resolve-1.22.8.tgz} + hasBin: true + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==, tarball: https://registry.npmmirror.com/restore-cursor/-/restore-cursor-4.0.0.tgz} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==, tarball: https://registry.npmmirror.com/ret/-/ret-0.1.15.tgz} + engines: {node: '>=0.12'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==, tarball: https://registry.npmmirror.com/reusify/-/reusify-1.0.4.tgz} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.3.1: + resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==, tarball: https://registry.npmmirror.com/rfdc/-/rfdc-1.3.1.tgz} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==, tarball: https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz} + hasBin: true + + rimraf@5.0.5: + resolution: {integrity: sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==, tarball: https://registry.npmmirror.com/rimraf/-/rimraf-5.0.5.tgz} + engines: {node: '>=14'} + hasBin: true + + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==, tarball: https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.2.tgz} + + rollup-plugin-purge-icons@0.10.0: + resolution: {integrity: sha512-GD2ftg4L9G/sagIhtCmBn5vdyzePOisniythubpbywP0Q3ix9rZuDeFvgXTPemOsc22pvH7t22ryYQIl0rwGog==, tarball: https://registry.npmmirror.com/rollup-plugin-purge-icons/-/rollup-plugin-purge-icons-0.10.0.tgz} + engines: {node: '>= 12'} + + rollup@2.79.1: + resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==, tarball: https://registry.npmmirror.com/rollup/-/rollup-2.79.1.tgz} + engines: {node: '>=10.0.0'} + hasBin: true + + rollup@4.17.1: + resolution: {integrity: sha512-0gG94inrUtg25sB2V/pApwiv1lUb0bQ25FPNuzO89Baa+B+c0ccaaBKM5zkZV/12pUUdH+lWCSm9wmHqyocuVQ==, tarball: https://registry.npmmirror.com/rollup/-/rollup-4.17.1.tgz} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, tarball: https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz} + + rust-result@1.0.0: + resolution: {integrity: sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==, tarball: https://registry.npmmirror.com/rust-result/-/rust-result-1.0.0.tgz} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==, tarball: https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz} + + safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==, tarball: https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.2.tgz} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, tarball: https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz} + + safe-json-parse@4.0.0: + resolution: {integrity: sha512-RjZPPHugjK0TOzFrLZ8inw44s9bKox99/0AZW9o/BEQVrJfhI+fIHMErnPyRa89/yRXUUr93q+tiN6zhoVV4wQ==, tarball: https://registry.npmmirror.com/safe-json-parse/-/safe-json-parse-4.0.0.tgz} + + safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==, tarball: https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz} + engines: {node: '>= 0.4'} + + safe-regex@1.1.0: + resolution: {integrity: sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==, tarball: https://registry.npmmirror.com/safe-regex/-/safe-regex-1.1.0.tgz} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, tarball: https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz} + + sass@1.75.0: + resolution: {integrity: sha512-ShMYi3WkrDWxExyxSZPst4/okE9ts46xZmJDSawJQrnte7M1V9fScVB+uNXOVKRBt0PggHOwoZcn8mYX4trnBw==, tarball: https://registry.npmmirror.com/sass/-/sass-1.75.0.tgz} + engines: {node: '>=14.0.0'} + hasBin: true + + sax@1.3.0: + resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==, tarball: https://registry.npmmirror.com/sax/-/sax-1.3.0.tgz} + + saxen@8.1.2: + resolution: {integrity: sha512-xUOiiFbc3Ow7p8KMxwsGICPx46ZQvy3+qfNVhrkwfz3Vvq45eGt98Ft5IQaA1R/7Tb5B5MKh9fUR9x3c3nDTxw==, tarball: https://registry.npmmirror.com/saxen/-/saxen-8.1.2.tgz} + + scroll-into-view-if-needed@2.2.31: + resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==, tarball: https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz} + + scroll-tabs@1.0.1: + resolution: {integrity: sha512-W4xjEwNS4QAyQnaJ450vQTcKpbnalBAfsTDV926WrxEMOqjyj2To8uv2d0Cp0oxMdk5TkygtzXmctPNc2zgBcg==, tarball: https://registry.npmmirror.com/scroll-tabs/-/scroll-tabs-1.0.1.tgz} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==, tarball: https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz} + + selection-update@0.1.2: + resolution: {integrity: sha512-4jzoJNh7VT2s2tvm/kUSskSw7pD0BVcrrGccbfOMK+3AXLBPz6nIy1yo+pbXgvNoTNII96Pq92+sAY+rF0LUAA==, tarball: https://registry.npmmirror.com/selection-update/-/selection-update-0.1.2.tgz} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==, tarball: https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz} + hasBin: true + + semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==, tarball: https://registry.npmmirror.com/semver/-/semver-7.6.0.tgz} + engines: {node: '>=10'} + hasBin: true + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==, tarball: https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==, tarball: https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==, tarball: https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz} + engines: {node: '>= 0.4'} + + set-value@2.0.1: + resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==, tarball: https://registry.npmmirror.com/set-value/-/set-value-2.0.1.tgz} + engines: {node: '>=0.10.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, tarball: https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, tarball: https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz} + engines: {node: '>=8'} + + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==, tarball: https://registry.npmmirror.com/side-channel/-/side-channel-1.0.6.tgz} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==, tarball: https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==, tarball: https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz} + engines: {node: '>=14'} + + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==, tarball: https://registry.npmmirror.com/sirv/-/sirv-2.0.4.tgz} + engines: {node: '>= 10'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==, tarball: https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz} + engines: {node: '>=8'} + + slate-history@0.66.0: + resolution: {integrity: sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==, tarball: https://registry.npmmirror.com/slate-history/-/slate-history-0.66.0.tgz} + peerDependencies: + slate: '>=0.65.3' + + slate@0.72.8: + resolution: {integrity: sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==, tarball: https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==, tarball: https://registry.npmmirror.com/slice-ansi/-/slice-ansi-4.0.0.tgz} + engines: {node: '>=10'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==, tarball: https://registry.npmmirror.com/slice-ansi/-/slice-ansi-5.0.0.tgz} + engines: {node: '>=12'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==, tarball: https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.0.tgz} + engines: {node: '>=18'} + + snabbdom@3.6.2: + resolution: {integrity: sha512-ig5qOnCDbugFntKi6c7Xlib8bA6xiJVk8O+WdFrV3wxbMqeHO0hXFQC4nAhPVWfZfi8255lcZkNhtIBINCc4+Q==, tarball: https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.2.tgz} + engines: {node: '>=12.17.0'} + + snapdragon-node@2.1.1: + resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==, tarball: https://registry.npmmirror.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz} + engines: {node: '>=0.10.0'} + + snapdragon-util@3.0.1: + resolution: {integrity: sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==, tarball: https://registry.npmmirror.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz} + engines: {node: '>=0.10.0'} + + snapdragon@0.8.2: + resolution: {integrity: sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==, tarball: https://registry.npmmirror.com/snapdragon/-/snapdragon-0.8.2.tgz} + engines: {node: '>=0.10.0'} + + sortablejs@1.14.0: + resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==, tarball: https://registry.npmmirror.com/sortablejs/-/sortablejs-1.14.0.tgz} + + source-map-js@1.2.0: + resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==, tarball: https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.0.tgz} + engines: {node: '>=0.10.0'} + + source-map-resolve@0.5.3: + resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==, tarball: https://registry.npmmirror.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz} + deprecated: See https://github.com/lydell/source-map-resolve#deprecated + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==, tarball: https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz} + + source-map-url@0.4.1: + resolution: {integrity: sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==, tarball: https://registry.npmmirror.com/source-map-url/-/source-map-url-0.4.1.tgz} + deprecated: See https://github.com/lydell/source-map-url#deprecated + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==, tarball: https://registry.npmmirror.com/source-map/-/source-map-0.5.7.tgz} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==, tarball: https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz} + engines: {node: '>=0.10.0'} + + split-string@3.1.0: + resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==, tarball: https://registry.npmmirror.com/split-string/-/split-string-3.1.0.tgz} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==, tarball: https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz} + engines: {node: '>= 10.x'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==, tarball: https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz} + + ssr-window@3.0.0: + resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==, tarball: https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz} + + stable@0.1.8: + resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==, tarball: https://registry.npmmirror.com/stable/-/stable-0.1.8.tgz} + deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' + + static-extend@0.1.2: + resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==, tarball: https://registry.npmmirror.com/static-extend/-/static-extend-0.1.2.tgz} + engines: {node: '>=0.10.0'} + + steady-xml@0.1.0: + resolution: {integrity: sha512-5sk17qO2wWRtonTNoBhoKAB35OSsGJOa3+NEa6D+1GS+de+ujDWxnflMkXBrviOfkNrPTUqduAdXhrMJs89nAw==, tarball: https://registry.npmmirror.com/steady-xml/-/steady-xml-0.1.0.tgz} + engines: {node: '>=12.0.0'} + + strict-uri-encode@1.1.0: + resolution: {integrity: sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==, tarball: https://registry.npmmirror.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz} + engines: {node: '>=0.10.0'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==, tarball: https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, tarball: https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==, tarball: https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz} + engines: {node: '>=12'} + + string-width@7.1.0: + resolution: {integrity: sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==, tarball: https://registry.npmmirror.com/string-width/-/string-width-7.1.0.tgz} + engines: {node: '>=18'} + + string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==, tarball: https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==, tarball: https://registry.npmmirror.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==, tarball: https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz} + engines: {node: '>= 0.4'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==, tarball: https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz} + + strip-ansi@3.0.1: + resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==, tarball: https://registry.npmmirror.com/strip-ansi/-/strip-ansi-3.0.1.tgz} + engines: {node: '>=0.10.0'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, tarball: https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==, tarball: https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz} + engines: {node: '>=12'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==, tarball: https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz} + engines: {node: '>=6'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==, tarball: https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==, tarball: https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz} + engines: {node: '>=8'} + + strip-literal@1.3.0: + resolution: {integrity: sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==, tarball: https://registry.npmmirror.com/strip-literal/-/strip-literal-1.3.0.tgz} + + strnum@1.0.5: + resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==, tarball: https://registry.npmmirror.com/strnum/-/strnum-1.0.5.tgz} + + stylelint-config-html@1.1.0: + resolution: {integrity: sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ==, tarball: https://registry.npmmirror.com/stylelint-config-html/-/stylelint-config-html-1.1.0.tgz} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended@14.0.0: + resolution: {integrity: sha512-jSkx290CglS8StmrLp2TxAppIajzIBZKYm3IxT89Kg6fGlxbPiTiyH9PS5YUuVAFwaJLl1ikiXX0QWjI0jmgZQ==, tarball: https://registry.npmmirror.com/stylelint-config-recommended/-/stylelint-config-recommended-14.0.0.tgz} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.0.0 + + stylelint-config-standard@36.0.0: + resolution: {integrity: sha512-3Kjyq4d62bYFp/Aq8PMKDwlgUyPU4nacXsjDLWJdNPRUgpuxALu1KnlAHIj36cdtxViVhXexZij65yM0uNIHug==, tarball: https://registry.npmmirror.com/stylelint-config-standard/-/stylelint-config-standard-36.0.0.tgz} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.1.0 + + stylelint-order@6.0.4: + resolution: {integrity: sha512-0UuKo4+s1hgQ/uAxlYU4h0o0HS4NiQDud0NAUNI0aa8FJdmYHA5ZZTFHiV5FpmE3071e9pZx5j0QpVJW5zOCUA==, tarball: https://registry.npmmirror.com/stylelint-order/-/stylelint-order-6.0.4.tgz} + peerDependencies: + stylelint: ^14.0.0 || ^15.0.0 || ^16.0.1 + + stylelint@16.4.0: + resolution: {integrity: sha512-uSx7VMuXwLuYcNSIg+0/fFNv0WinsfLAqsVVy7h7p80clKOHiGE8pfY6UjqwylTHiJrRIahTl6a8FPxGezhWoA==, tarball: https://registry.npmmirror.com/stylelint/-/stylelint-16.4.0.tgz} + engines: {node: '>=18.12.0'} + hasBin: true + + supports-color@2.0.0: + resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==, tarball: https://registry.npmmirror.com/supports-color/-/supports-color-2.0.0.tgz} + engines: {node: '>=0.8.0'} + + supports-color@3.2.3: + resolution: {integrity: sha512-Jds2VIYDrlp5ui7t8abHN2bjAu4LV/q4N2KivFPpGH0lrka0BMq/33AmECUXlKPcHigkNaqfXRENFju+rlcy+A==, tarball: https://registry.npmmirror.com/supports-color/-/supports-color-3.2.3.tgz} + engines: {node: '>=0.8.0'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==, tarball: https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, tarball: https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz} + engines: {node: '>=8'} + + supports-hyperlinks@3.0.0: + resolution: {integrity: sha512-QBDPHyPQDRTy9ku4URNGY5Lah8PAaXs6tAAwp55sL5WCsSW7GIfdf6W5ixfziW+t7wh3GVvHyHHyQ1ESsoRvaA==, tarball: https://registry.npmmirror.com/supports-hyperlinks/-/supports-hyperlinks-3.0.0.tgz} + engines: {node: '>=14.18'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, tarball: https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz} + engines: {node: '>= 0.4'} + + svg-baker@1.7.0: + resolution: {integrity: sha512-nibslMbkXOIkqKVrfcncwha45f97fGuAOn1G99YwnwTj8kF9YiM6XexPcUso97NxOm6GsP0SIvYVIosBis1xLg==, tarball: https://registry.npmmirror.com/svg-baker/-/svg-baker-1.7.0.tgz} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==, tarball: https://registry.npmmirror.com/svg-tags/-/svg-tags-1.0.0.tgz} + + svg.js@2.7.1: + resolution: {integrity: sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==, tarball: https://registry.npmmirror.com/svg.js/-/svg.js-2.7.1.tgz} + + svgo@2.8.0: + resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==, tarball: https://registry.npmmirror.com/svgo/-/svgo-2.8.0.tgz} + engines: {node: '>=10.13.0'} + hasBin: true + + synckit@0.8.8: + resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==, tarball: https://registry.npmmirror.com/synckit/-/synckit-0.8.8.tgz} + engines: {node: ^14.18.0 || >=16.0.0} + + systemjs@6.15.1: + resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==, tarball: https://registry.npmmirror.com/systemjs/-/systemjs-6.15.1.tgz} + + table@6.8.2: + resolution: {integrity: sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==, tarball: https://registry.npmmirror.com/table/-/table-6.8.2.tgz} + engines: {node: '>=10.0.0'} + + terser@5.30.4: + resolution: {integrity: sha512-xRdd0v64a8mFK9bnsKVdoNP9GQIKUAaJPTaqEQDL4w/J8WaW4sWXXoMZ+6SimPkfT5bElreXf8m9HnmPc3E1BQ==, tarball: https://registry.npmmirror.com/terser/-/terser-5.30.4.tgz} + engines: {node: '>=10'} + hasBin: true + + text-extensions@2.4.0: + resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==, tarball: https://registry.npmmirror.com/text-extensions/-/text-extensions-2.4.0.tgz} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==, tarball: https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==, tarball: https://registry.npmmirror.com/through/-/through-2.3.8.tgz} + + tiny-svg@2.2.4: + resolution: {integrity: sha512-NOi39lBknf4UdDEahNkbEAJnzhu1ZcN2j75IS2vLRmIhsfxdZpTChfLKBcN1ShplVmPIXJAIafk6YY5/Aa80lQ==, tarball: https://registry.npmmirror.com/tiny-svg/-/tiny-svg-2.2.4.tgz} + + tiny-svg@3.0.1: + resolution: {integrity: sha512-P8T4iwiW1t95vpHVHqrD36Brn7TqFYCPSHIWk9WLJtYK1X4aDd+5cgqcAADIWSjf1/i5idKnpCh9mim8hEdRBg==, tarball: https://registry.npmmirror.com/tiny-svg/-/tiny-svg-3.0.1.tgz} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==, tarball: https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz} + + to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==, tarball: https://registry.npmmirror.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz} + engines: {node: '>=4'} + + to-object-path@0.3.0: + resolution: {integrity: sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==, tarball: https://registry.npmmirror.com/to-object-path/-/to-object-path-0.3.0.tgz} + engines: {node: '>=0.10.0'} + + to-regex-range@2.1.1: + resolution: {integrity: sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==, tarball: https://registry.npmmirror.com/to-regex-range/-/to-regex-range-2.1.1.tgz} + engines: {node: '>=0.10.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, tarball: https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz} + engines: {node: '>=8.0'} + + to-regex@3.0.2: + resolution: {integrity: sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==, tarball: https://registry.npmmirror.com/to-regex/-/to-regex-3.0.2.tgz} + engines: {node: '>=0.10.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==, tarball: https://registry.npmmirror.com/totalist/-/totalist-3.0.1.tgz} + engines: {node: '>=6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==, tarball: https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz} + + traverse@0.6.9: + resolution: {integrity: sha512-7bBrcF+/LQzSgFmT0X5YclVqQxtv7TDJ1f8Wj7ibBu/U6BMLeOpUxuZjV7rMc44UtKxlnMFigdhFAIszSX1DMg==, tarball: https://registry.npmmirror.com/traverse/-/traverse-0.6.9.tgz} + engines: {node: '>= 0.4'} + + ts-api-utils@1.3.0: + resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==, tarball: https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==, tarball: https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz} + + tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==, tarball: https://registry.npmmirror.com/tslib/-/tslib-2.6.2.tgz} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, tarball: https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==, tarball: https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz} + engines: {node: '>=10'} + + type@2.7.2: + resolution: {integrity: sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==, tarball: https://registry.npmmirror.com/type/-/type-2.7.2.tgz} + + typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==, tarball: https://registry.npmmirror.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==, tarball: https://registry.npmmirror.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==, tarball: https://registry.npmmirror.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==, tarball: https://registry.npmmirror.com/typed-array-length/-/typed-array-length-1.0.6.tgz} + engines: {node: '>= 0.4'} + + typedarray.prototype.slice@1.0.3: + resolution: {integrity: sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==, tarball: https://registry.npmmirror.com/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.3.tgz} + engines: {node: '>= 0.4'} + + typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==, tarball: https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==, tarball: https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz} + + ufo@1.5.3: + resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==, tarball: https://registry.npmmirror.com/ufo/-/ufo-1.5.3.tgz} + + unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==, tarball: https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz} + + unconfig@0.3.13: + resolution: {integrity: sha512-N9Ph5NC4+sqtcOjPfHrRcHekBCadCXWTBzp2VYYbySOHW0PfD9XLCeXshTXjkPYwLrBr9AtSeU0CZmkYECJhng==, tarball: https://registry.npmmirror.com/unconfig/-/unconfig-0.3.13.tgz} + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==, tarball: https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz} + + unicode-canonical-property-names-ecmascript@2.0.0: + resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==, tarball: https://registry.npmmirror.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==, tarball: https://registry.npmmirror.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.1.0: + resolution: {integrity: sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==, tarball: https://registry.npmmirror.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.1.0: + resolution: {integrity: sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==, tarball: https://registry.npmmirror.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz} + engines: {node: '>=4'} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==, tarball: https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz} + engines: {node: '>=18'} + + unimport@3.7.1: + resolution: {integrity: sha512-V9HpXYfsZye5bPPYUgs0Otn3ODS1mDUciaBlXljI4C2fTwfFpvFZRywmlOu943puN9sncxROMZhsZCjNXEpzEQ==, tarball: https://registry.npmmirror.com/unimport/-/unimport-3.7.1.tgz} + + union-value@1.0.1: + resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==, tarball: https://registry.npmmirror.com/union-value/-/union-value-1.0.1.tgz} + engines: {node: '>=0.10.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==, tarball: https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz} + engines: {node: '>= 10.0.0'} + + unocss@0.58.9: + resolution: {integrity: sha512-aqANXXP0RrtN4kSaTLn/7I6wh8o45LUdVgPzGu7Fan2DfH2+wpIs6frlnlHlOymnb+52dp6kXluQinddaUKW1A==, tarball: https://registry.npmmirror.com/unocss/-/unocss-0.58.9.tgz} + engines: {node: '>=14'} + peerDependencies: + '@unocss/webpack': 0.58.9 + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 + peerDependenciesMeta: + '@unocss/webpack': + optional: true + vite: + optional: true + + unplugin-auto-import@0.16.7: + resolution: {integrity: sha512-w7XmnRlchq6YUFJVFGSvG1T/6j8GrdYN6Em9Wf0Ye+HXgD/22kont+WnuCAA0UaUoxtuvRR1u/mXKy63g/hfqQ==, tarball: https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-0.16.7.tgz} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^3.2.2 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-element-plus@0.8.0: + resolution: {integrity: sha512-jByUGY3FG2B8RJKFryqxx4eNtSTj+Hjlo8edcOdJymewndDQjThZ1pRUQHRjQsbKhTV2jEctJV7t7RJ405UL4g==, tarball: https://registry.npmmirror.com/unplugin-element-plus/-/unplugin-element-plus-0.8.0.tgz} + engines: {node: '>=14.19.0'} + + unplugin-vue-components@0.25.2: + resolution: {integrity: sha512-OVmLFqILH6w+eM8fyt/d/eoJT9A6WO51NZLf1vC5c1FZ4rmq2bbGxTy8WP2Jm7xwFdukaIdv819+UI7RClPyCA==, tarball: https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-0.25.2.tgz} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 + vue: 2 || 3 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + + unplugin@1.10.1: + resolution: {integrity: sha512-d6Mhq8RJeGA8UfKCu54Um4lFA0eSaRa3XxdAJg8tIdxbu1ubW0hBCZUL7yI2uGyYCRndvbK8FLHzqy2XKfeMsg==, tarball: https://registry.npmmirror.com/unplugin/-/unplugin-1.10.1.tgz} + engines: {node: '>=14.0.0'} + + unset-value@1.0.0: + resolution: {integrity: sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==, tarball: https://registry.npmmirror.com/unset-value/-/unset-value-1.0.0.tgz} + engines: {node: '>=0.10.0'} + + update-browserslist-db@1.0.13: + resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==, tarball: https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, tarball: https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz} + + urix@0.1.0: + resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==, tarball: https://registry.npmmirror.com/urix/-/urix-0.1.0.tgz} + deprecated: Please see https://github.com/lydell/urix#deprecated + + url-toolkit@2.2.5: + resolution: {integrity: sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==, tarball: https://registry.npmmirror.com/url-toolkit/-/url-toolkit-2.2.5.tgz} + + url@0.11.3: + resolution: {integrity: sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==, tarball: https://registry.npmmirror.com/url/-/url-0.11.3.tgz} + + use@3.1.1: + resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==, tarball: https://registry.npmmirror.com/use/-/use-3.1.1.tgz} + engines: {node: '>=0.10.0'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, tarball: https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz} + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==, tarball: https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz} + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, tarball: https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz} + engines: {node: '>= 0.8'} + + video.js@7.21.5: + resolution: {integrity: sha512-WRq86tXZKrThA9mK+IR+v4tIQVVvnb5LhvL71fD2AX7TxVOPdaeK1X/wyuUruBqWaOG3w2sZXoMY6HF2Jlo9qA==, tarball: https://registry.npmmirror.com/video.js/-/video.js-7.21.5.tgz} + + videojs-font@3.2.0: + resolution: {integrity: sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==, tarball: https://registry.npmmirror.com/videojs-font/-/videojs-font-3.2.0.tgz} + + videojs-vtt.js@0.15.5: + resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==, tarball: https://registry.npmmirror.com/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz} + + vite-plugin-compression@0.5.1: + resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==, tarball: https://registry.npmmirror.com/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz} + peerDependencies: + vite: '>=2.0.0' + + vite-plugin-ejs@1.7.0: + resolution: {integrity: sha512-JNP3zQDC4mSbfoJ3G73s5mmZITD8NGjUmLkq4swxyahy/W0xuokK9U9IJGXw7KCggq6UucT6hJ0p+tQrNtqTZw==, tarball: https://registry.npmmirror.com/vite-plugin-ejs/-/vite-plugin-ejs-1.7.0.tgz} + peerDependencies: + vite: '>=5.0.0' + + vite-plugin-eslint@1.8.1: + resolution: {integrity: sha512-PqdMf3Y2fLO9FsNPmMX+//2BF5SF8nEWspZdgl4kSt7UvHDRHVVfHvxsD7ULYzZrJDGRxR81Nq7TOFgwMnUang==, tarball: https://registry.npmmirror.com/vite-plugin-eslint/-/vite-plugin-eslint-1.8.1.tgz} + peerDependencies: + eslint: '>=7' + vite: '>=2' + + vite-plugin-progress@0.0.7: + resolution: {integrity: sha512-zyvKdcc/X+6hnw3J1HVV1TKrlFKC4Rh8GnDnWG/2qhRXjqytTcM++xZ+SAPnoDsSyWl8O93ymK0wZRgHAoglEQ==, tarball: https://registry.npmmirror.com/vite-plugin-progress/-/vite-plugin-progress-0.0.7.tgz} + engines: {node: '>=14', pnpm: '>=7.0.0'} + peerDependencies: + vite: '>2.0.0-0' + + vite-plugin-purge-icons@0.10.0: + resolution: {integrity: sha512-4fMJKQuBu9lAPJWjqGEytRaxty1pP9bWgQLA68dwbbaCXu6NBrOUb/3kMaUc7TP09kerEk+qTriCk05OZXpjwA==, tarball: https://registry.npmmirror.com/vite-plugin-purge-icons/-/vite-plugin-purge-icons-0.10.0.tgz} + engines: {node: '>= 12'} + peerDependencies: + vite: '>=2' + + vite-plugin-svg-icons@2.0.1: + resolution: {integrity: sha512-6ktD+DhV6Rz3VtedYvBKKVA2eXF+sAQVaKkKLDSqGUfnhqXl3bj5PPkVTl3VexfTuZy66PmINi8Q6eFnVfRUmA==, tarball: https://registry.npmmirror.com/vite-plugin-svg-icons/-/vite-plugin-svg-icons-2.0.1.tgz} + peerDependencies: + vite: '>=2.0.0' + + vite-plugin-top-level-await@1.4.1: + resolution: {integrity: sha512-hogbZ6yT7+AqBaV6lK9JRNvJDn4/IJvHLu6ET06arNfo0t2IsyCaon7el9Xa8OumH+ESuq//SDf8xscZFE0rWw==, tarball: https://registry.npmmirror.com/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.4.1.tgz} + peerDependencies: + vite: '>=2.8' + + vite@5.1.4: + resolution: {integrity: sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==, tarball: https://registry.npmmirror.com/vite/-/vite-5.1.4.tgz} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vue-demi@0.14.7: + resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==, tarball: https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.7.tgz} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-dompurify-html@4.1.4: + resolution: {integrity: sha512-K0XDSZA4dmMMvAgW8yaCx1kAYQldmgXeHJaLPS0mlSKOu8B+onE06X4KfB5LGyX4jR3rlVosyWJczRBzR0sZ/g==, tarball: https://registry.npmmirror.com/vue-dompurify-html/-/vue-dompurify-html-4.1.4.tgz} + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + + vue-eslint-parser@9.4.2: + resolution: {integrity: sha512-Ry9oiGmCAK91HrKMtCrKFWmSFWvYkpGglCeFAIqDdr9zdXmMMpJOmUJS7WWsW7fX81h6mwHmUZCQQ1E0PkSwYQ==, tarball: https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '>=6.0.0' + + vue-i18n@9.10.2: + resolution: {integrity: sha512-ECJ8RIFd+3c1d3m1pctQ6ywG5Yj8Efy1oYoAKQ9neRdkLbuKLVeW4gaY5HPkD/9ssf1pOnUrmIFjx2/gkGxmEw==, tarball: https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.10.2.tgz} + engines: {node: '>= 16'} + peerDependencies: + vue: ^3.0.0 + + vue-router@4.3.2: + resolution: {integrity: sha512-hKQJ1vDAZ5LVkKEnHhmm1f9pMiWIBNGF5AwU67PdH7TyXCj/a4hTccuUuYCAMgJK6rO/NVYtQIEN3yL8CECa7Q==, tarball: https://registry.npmmirror.com/vue-router/-/vue-router-4.3.2.tgz} + peerDependencies: + vue: ^3.2.0 + + vue-template-compiler@2.7.16: + resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==, tarball: https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz} + + vue-tsc@1.8.27: + resolution: {integrity: sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==, tarball: https://registry.npmmirror.com/vue-tsc/-/vue-tsc-1.8.27.tgz} + hasBin: true + peerDependencies: + typescript: '*' + + vue-types@5.1.1: + resolution: {integrity: sha512-FMY/JCLWePXgGIcMDqYdJsQm1G0CDxEjq6W0+tZMJZlX37q/61eSGSIa/XFRwa9T7kkKXuxxl94/2kgxyWQqKw==, tarball: https://registry.npmmirror.com/vue-types/-/vue-types-5.1.1.tgz} + engines: {node: '>=14.0.0'} + peerDependencies: + vue: ^2.0.0 || ^3.0.0 + peerDependenciesMeta: + vue: + optional: true + + vue@3.4.21: + resolution: {integrity: sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==, tarball: https://registry.npmmirror.com/vue/-/vue-3.4.21.tgz} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vuedraggable@4.1.0: + resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==, tarball: https://registry.npmmirror.com/vuedraggable/-/vuedraggable-4.1.0.tgz} + peerDependencies: + vue: ^3.0.1 + + wangeditor@4.7.15: + resolution: {integrity: sha512-aPTdREd8BxXVyJ5MI+LU83FQ7u1EPd341iXIorRNYSOvoimNoZ4nPg+yn3FGbB93/owEa6buLw8wdhYnMCJQLg==, tarball: https://registry.npmmirror.com/wangeditor/-/wangeditor-4.7.15.tgz} + + web-storage-cache@1.1.1: + resolution: {integrity: sha512-D0MieGooOs8RpsrK+vnejXnvh4OOv/+lTFB35JRkJJQt+uOjPE08XpaE0QBLMTRu47B1KGT/Nq3Gbag3Orinzw==, tarball: https://registry.npmmirror.com/web-storage-cache/-/web-storage-cache-1.1.1.tgz} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==, tarball: https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz} + + webpack-sources@3.2.3: + resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==, tarball: https://registry.npmmirror.com/webpack-sources/-/webpack-sources-3.2.3.tgz} + engines: {node: '>=10.13.0'} + + webpack-virtual-modules@0.6.1: + resolution: {integrity: sha512-poXpCylU7ExuvZK8z+On3kX+S8o/2dQ/SVYueKA0D4WEMXROXgY8Ez50/bQEUmvoSMMrWcrJqCHuhAbsiwg7Dg==, tarball: https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.1.tgz} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==, tarball: https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz} + + which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==, tarball: https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==, tarball: https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz} + + which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==, tarball: https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.15.tgz} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==, tarball: https://registry.npmmirror.com/which/-/which-1.3.1.tgz} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, tarball: https://registry.npmmirror.com/which/-/which-2.0.2.tgz} + engines: {node: '>= 8'} + hasBin: true + + wildcard@1.1.2: + resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==, tarball: https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, tarball: https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==, tarball: https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, tarball: https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==, tarball: https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==, tarball: https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.0.tgz} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, tarball: https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==, tarball: https://registry.npmmirror.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==, tarball: https://registry.npmmirror.com/xml-js/-/xml-js-1.6.11.tgz} + hasBin: true + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==, tarball: https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz} + engines: {node: '>=12'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==, tarball: https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, tarball: https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==, tarball: https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==, tarball: https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz} + + yaml-eslint-parser@1.2.2: + resolution: {integrity: sha512-pEwzfsKbTrB8G3xc/sN7aw1v6A6c/pKxLAkjclnAyo5g5qOh6eL9WGu0o3cSDQZKrTNk4KL4lQSwZW+nBkANEg==, tarball: https://registry.npmmirror.com/yaml-eslint-parser/-/yaml-eslint-parser-1.2.2.tgz} + engines: {node: ^14.17.0 || >=16.0.0} + + yaml@2.3.4: + resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==, tarball: https://registry.npmmirror.com/yaml/-/yaml-2.3.4.tgz} + engines: {node: '>= 14'} + + yaml@2.4.2: + resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==, tarball: https://registry.npmmirror.com/yaml/-/yaml-2.4.2.tgz} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==, tarball: https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz} + engines: {node: '>=6'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==, tarball: https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz} + engines: {node: '>=12'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==, tarball: https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz} + engines: {node: '>=8'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==, tarball: https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, tarball: https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz} + engines: {node: '>=10'} + + yocto-queue@1.0.0: + resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==, tarball: https://registry.npmmirror.com/yocto-queue/-/yocto-queue-1.0.0.tgz} + engines: {node: '>=12.20'} + + zrender@5.5.0: + resolution: {integrity: sha512-O3MilSi/9mwoovx77m6ROZM7sXShR/O/JIanvzTwjN3FORfLSr81PsUGd7jlaYOeds9d8tw82oP44+3YucVo+w==, tarball: https://registry.npmmirror.com/zrender/-/zrender-5.5.0.tgz} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@antfu/install-pkg@0.1.1': + dependencies: + execa: 5.1.1 + find-up: 5.0.0 + + '@antfu/utils@0.7.7': {} + + '@babel/code-frame@7.24.2': + dependencies: + '@babel/highlight': 7.24.2 + picocolors: 1.0.0 + + '@babel/compat-data@7.24.4': {} + + '@babel/core@7.24.4': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helpers': 7.24.4 + '@babel/parser': 7.24.4 + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + convert-source-map: 2.0.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.24.4': + dependencies: + '@babel/types': 7.24.0 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + + '@babel/helper-annotate-as-pure@7.22.5': + dependencies: + '@babel/types': 7.24.0 + + '@babel/helper-builder-binary-assignment-operator-visitor@7.22.15': + dependencies: + '@babel/types': 7.24.0 + + '@babel/helper-compilation-targets@7.23.6': + dependencies: + '@babel/compat-data': 7.24.4 + '@babel/helper-validator-option': 7.23.5 + browserslist: 4.23.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.24.4(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-member-expression-to-functions': 7.23.0 + '@babel/helper-optimise-call-expression': 7.22.5 + '@babel/helper-replace-supers': 7.24.1(@babel/core@7.24.4) + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + semver: 6.3.1 + + '@babel/helper-create-regexp-features-plugin@7.22.15(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + regexpu-core: 5.3.2 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.2(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-plugin-utils': 7.24.0 + debug: 4.3.4 + lodash.debounce: 4.0.8 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-environment-visitor@7.22.20': {} + + '@babel/helper-function-name@7.23.0': + dependencies: + '@babel/template': 7.24.0 + '@babel/types': 7.24.0 + + '@babel/helper-hoist-variables@7.22.5': + dependencies: + '@babel/types': 7.24.0 + + '@babel/helper-member-expression-to-functions@7.23.0': + dependencies: + '@babel/types': 7.24.0 + + '@babel/helper-module-imports@7.22.15': + dependencies: + '@babel/types': 7.24.0 + + '@babel/helper-module-imports@7.24.3': + dependencies: + '@babel/types': 7.24.0 + + '@babel/helper-module-transforms@7.23.3(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-simple-access': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/helper-validator-identifier': 7.22.20 + + '@babel/helper-optimise-call-expression@7.22.5': + dependencies: + '@babel/types': 7.24.0 + + '@babel/helper-plugin-utils@7.24.0': {} + + '@babel/helper-remap-async-to-generator@7.22.20(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-wrap-function': 7.22.20 + + '@babel/helper-replace-supers@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-member-expression-to-functions': 7.23.0 + '@babel/helper-optimise-call-expression': 7.22.5 + + '@babel/helper-simple-access@7.22.5': + dependencies: + '@babel/types': 7.24.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.22.5': + dependencies: + '@babel/types': 7.24.0 + + '@babel/helper-split-export-declaration@7.22.6': + dependencies: + '@babel/types': 7.24.0 + + '@babel/helper-string-parser@7.24.1': {} + + '@babel/helper-validator-identifier@7.22.20': {} + + '@babel/helper-validator-option@7.23.5': {} + + '@babel/helper-wrap-function@7.22.20': + dependencies: + '@babel/helper-function-name': 7.23.0 + '@babel/template': 7.24.0 + '@babel/types': 7.24.0 + + '@babel/helpers@7.24.4': + dependencies: + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + transitivePeerDependencies: + - supports-color + + '@babel/highlight@7.24.2': + dependencies: + '@babel/helper-validator-identifier': 7.22.20 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.0.0 + + '@babel/parser@7.24.4': + dependencies: + '@babel/types': 7.24.0 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.24.4(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/plugin-transform-optional-chaining': 7.24.1(@babel/core@7.24.4) + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-import-assertions@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-import-attributes@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-jsx@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-typescript@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-arrow-functions@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-async-generator-functions@7.24.3(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.24.4) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.4) + + '@babel/plugin-transform-async-to-generator@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.24.4) + + '@babel/plugin-transform-block-scoped-functions@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-block-scoping@7.24.4(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-class-properties@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-class-static-block@7.24.4(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.24.4) + + '@babel/plugin-transform-classes@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-replace-supers': 7.24.1(@babel/core@7.24.4) + '@babel/helper-split-export-declaration': 7.22.6 + globals: 11.12.0 + + '@babel/plugin-transform-computed-properties@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/template': 7.24.0 + + '@babel/plugin-transform-destructuring@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-dotall-regex@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-duplicate-keys@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-dynamic-import@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.24.4) + + '@babel/plugin-transform-exponentiation-operator@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-builder-binary-assignment-operator-visitor': 7.22.15 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-export-namespace-from@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.24.4) + + '@babel/plugin-transform-for-of@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + + '@babel/plugin-transform-function-name@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-json-strings@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.4) + + '@babel/plugin-transform-literals@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-logical-assignment-operators@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.4) + + '@babel/plugin-transform-member-expression-literals@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-modules-amd@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-modules-commonjs@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-simple-access': 7.22.5 + + '@babel/plugin-transform-modules-systemjs@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-validator-identifier': 7.22.20 + + '@babel/plugin-transform-modules-umd@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.22.5(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-new-target@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.4) + + '@babel/plugin-transform-numeric-separator@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.4) + + '@babel/plugin-transform-object-rest-spread@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-transform-parameters': 7.24.1(@babel/core@7.24.4) + + '@babel/plugin-transform-object-super@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-replace-supers': 7.24.1(@babel/core@7.24.4) + + '@babel/plugin-transform-optional-catch-binding@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.4) + + '@babel/plugin-transform-optional-chaining@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.4) + + '@babel/plugin-transform-parameters@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-private-methods@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-private-property-in-object@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.24.4) + + '@babel/plugin-transform-property-literals@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-regenerator@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + regenerator-transform: 0.15.2 + + '@babel/plugin-transform-reserved-words@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-shorthand-properties@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-spread@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + + '@babel/plugin-transform-sticky-regex@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-template-literals@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-typeof-symbol@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-typescript@7.24.4(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-typescript': 7.24.1(@babel/core@7.24.4) + + '@babel/plugin-transform-unicode-escapes@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-unicode-property-regex@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-unicode-regex@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/plugin-transform-unicode-sets-regex@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + + '@babel/preset-env@7.24.4(@babel/core@7.24.4)': + dependencies: + '@babel/compat-data': 7.24.4 + '@babel/core': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-validator-option': 7.23.5 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.24.4(@babel/core@7.24.4) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.24.4) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.4) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.4) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.24.4) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-import-assertions': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-syntax-import-attributes': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.4) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.4) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.4) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.24.4) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.4) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.24.4) + '@babel/plugin-transform-arrow-functions': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-async-generator-functions': 7.24.3(@babel/core@7.24.4) + '@babel/plugin-transform-async-to-generator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-block-scoped-functions': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-block-scoping': 7.24.4(@babel/core@7.24.4) + '@babel/plugin-transform-class-properties': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-class-static-block': 7.24.4(@babel/core@7.24.4) + '@babel/plugin-transform-classes': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-computed-properties': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-destructuring': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-dotall-regex': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-duplicate-keys': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-dynamic-import': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-exponentiation-operator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-export-namespace-from': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-for-of': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-function-name': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-json-strings': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-literals': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-logical-assignment-operators': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-member-expression-literals': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-amd': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-commonjs': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-systemjs': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-umd': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-named-capturing-groups-regex': 7.22.5(@babel/core@7.24.4) + '@babel/plugin-transform-new-target': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-nullish-coalescing-operator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-numeric-separator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-object-rest-spread': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-object-super': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-optional-catch-binding': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-optional-chaining': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-parameters': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-private-methods': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-private-property-in-object': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-property-literals': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-regenerator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-reserved-words': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-shorthand-properties': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-spread': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-sticky-regex': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-template-literals': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-typeof-symbol': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-unicode-escapes': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-unicode-property-regex': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-unicode-regex': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-unicode-sets-regex': 7.24.1(@babel/core@7.24.4) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.24.4) + babel-plugin-polyfill-corejs2: 0.4.11(@babel/core@7.24.4) + babel-plugin-polyfill-corejs3: 0.10.4(@babel/core@7.24.4) + babel-plugin-polyfill-regenerator: 0.6.2(@babel/core@7.24.4) + core-js-compat: 3.37.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/types': 7.24.0 + esutils: 2.0.3 + + '@babel/preset-typescript@7.24.1(@babel/core@7.24.4)': + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-validator-option': 7.23.5 + '@babel/plugin-syntax-jsx': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-commonjs': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-typescript': 7.24.4(@babel/core@7.24.4) + + '@babel/regjsgen@0.8.0': {} + + '@babel/runtime-corejs3@7.24.4': + dependencies: + core-js-pure: 3.37.0 + regenerator-runtime: 0.14.1 + + '@babel/runtime@7.24.4': + dependencies: + regenerator-runtime: 0.14.1 + + '@babel/template@7.24.0': + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + + '@babel/traverse@7.24.1': + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.24.0': + dependencies: + '@babel/helper-string-parser': 7.24.1 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + + '@bpmn-io/diagram-js-ui@0.2.3': + dependencies: + htm: 3.1.1 + preact: 10.20.2 + + '@bpmn-io/element-templates-validator@0.2.0': + dependencies: + '@camunda/element-templates-json-schema': 0.4.0 + json-source-map: 0.6.1 + min-dash: 3.8.1 + + '@bpmn-io/extract-process-variables@0.4.5': + dependencies: + min-dash: 3.8.1 + + '@camunda/element-templates-json-schema@0.4.0': {} + + '@commitlint/cli@19.3.0(@types/node@20.12.7)(typescript@5.3.3)': + dependencies: + '@commitlint/format': 19.3.0 + '@commitlint/lint': 19.2.2 + '@commitlint/load': 19.2.0(@types/node@20.12.7)(typescript@5.3.3) + '@commitlint/read': 19.2.1 + '@commitlint/types': 19.0.3 + execa: 8.0.1 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/config-conventional@19.2.2': + dependencies: + '@commitlint/types': 19.0.3 + conventional-changelog-conventionalcommits: 7.0.2 + + '@commitlint/config-validator@19.0.3': + dependencies: + '@commitlint/types': 19.0.3 + ajv: 8.12.0 + + '@commitlint/ensure@19.0.3': + dependencies: + '@commitlint/types': 19.0.3 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + + '@commitlint/execute-rule@19.0.0': {} + + '@commitlint/format@19.3.0': + dependencies: + '@commitlint/types': 19.0.3 + chalk: 5.3.0 + + '@commitlint/is-ignored@19.2.2': + dependencies: + '@commitlint/types': 19.0.3 + semver: 7.6.0 + + '@commitlint/lint@19.2.2': + dependencies: + '@commitlint/is-ignored': 19.2.2 + '@commitlint/parse': 19.0.3 + '@commitlint/rules': 19.0.3 + '@commitlint/types': 19.0.3 + + '@commitlint/load@19.2.0(@types/node@20.12.7)(typescript@5.3.3)': + dependencies: + '@commitlint/config-validator': 19.0.3 + '@commitlint/execute-rule': 19.0.0 + '@commitlint/resolve-extends': 19.1.0 + '@commitlint/types': 19.0.3 + chalk: 5.3.0 + cosmiconfig: 9.0.0(typescript@5.3.3) + cosmiconfig-typescript-loader: 5.0.0(@types/node@20.12.7)(cosmiconfig@9.0.0(typescript@5.3.3))(typescript@5.3.3) + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + lodash.uniq: 4.5.0 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@19.0.0': {} + + '@commitlint/parse@19.0.3': + dependencies: + '@commitlint/types': 19.0.3 + conventional-changelog-angular: 7.0.0 + conventional-commits-parser: 5.0.0 + + '@commitlint/read@19.2.1': + dependencies: + '@commitlint/top-level': 19.0.0 + '@commitlint/types': 19.0.3 + execa: 8.0.1 + git-raw-commits: 4.0.0 + minimist: 1.2.8 + + '@commitlint/resolve-extends@19.1.0': + dependencies: + '@commitlint/config-validator': 19.0.3 + '@commitlint/types': 19.0.3 + global-directory: 4.0.1 + import-meta-resolve: 4.0.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + + '@commitlint/rules@19.0.3': + dependencies: + '@commitlint/ensure': 19.0.3 + '@commitlint/message': 19.0.0 + '@commitlint/to-lines': 19.0.0 + '@commitlint/types': 19.0.3 + execa: 8.0.1 + + '@commitlint/to-lines@19.0.0': {} + + '@commitlint/top-level@19.0.0': + dependencies: + find-up: 7.0.0 + + '@commitlint/types@19.0.3': + dependencies: + '@types/conventional-commits-parser': 5.0.0 + chalk: 5.3.0 + + '@csstools/css-parser-algorithms@2.6.1(@csstools/css-tokenizer@2.2.4)': + dependencies: + '@csstools/css-tokenizer': 2.2.4 + + '@csstools/css-tokenizer@2.2.4': {} + + '@csstools/media-query-list-parser@2.1.9(@csstools/css-parser-algorithms@2.6.1(@csstools/css-tokenizer@2.2.4))(@csstools/css-tokenizer@2.2.4)': + dependencies: + '@csstools/css-parser-algorithms': 2.6.1(@csstools/css-tokenizer@2.2.4) + '@csstools/css-tokenizer': 2.2.4 + + '@csstools/selector-specificity@3.0.3(postcss-selector-parser@6.0.16)': + dependencies: + postcss-selector-parser: 6.0.16 + + '@ctrl/tinycolor@3.6.1': {} + + '@dual-bundle/import-meta-resolve@4.0.0': {} + + '@element-plus/icons-vue@2.3.1(vue@3.4.21(typescript@5.3.3))': + dependencies: + vue: 3.4.21(typescript@5.3.3) + + '@esbuild/aix-ppc64@0.19.12': + optional: true + + '@esbuild/android-arm64@0.19.12': + optional: true + + '@esbuild/android-arm@0.19.12': + optional: true + + '@esbuild/android-x64@0.19.12': + optional: true + + '@esbuild/darwin-arm64@0.19.12': + optional: true + + '@esbuild/darwin-x64@0.19.12': + optional: true + + '@esbuild/freebsd-arm64@0.19.12': + optional: true + + '@esbuild/freebsd-x64@0.19.12': + optional: true + + '@esbuild/linux-arm64@0.19.12': + optional: true + + '@esbuild/linux-arm@0.19.12': + optional: true + + '@esbuild/linux-ia32@0.19.12': + optional: true + + '@esbuild/linux-loong64@0.19.12': + optional: true + + '@esbuild/linux-mips64el@0.19.12': + optional: true + + '@esbuild/linux-ppc64@0.19.12': + optional: true + + '@esbuild/linux-riscv64@0.19.12': + optional: true + + '@esbuild/linux-s390x@0.19.12': + optional: true + + '@esbuild/linux-x64@0.19.12': + optional: true + + '@esbuild/netbsd-x64@0.19.12': + optional: true + + '@esbuild/openbsd-x64@0.19.12': + optional: true + + '@esbuild/sunos-x64@0.19.12': + optional: true + + '@esbuild/win32-arm64@0.19.12': + optional: true + + '@esbuild/win32-ia32@0.19.12': + optional: true + + '@esbuild/win32-x64@0.19.12': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': + dependencies: + eslint: 8.57.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.10.0': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.0': {} + + '@floating-ui/core@1.6.1': + dependencies: + '@floating-ui/utils': 0.2.2 + + '@floating-ui/dom@1.6.4': + dependencies: + '@floating-ui/core': 1.6.1 + '@floating-ui/utils': 0.2.2 + + '@floating-ui/utils@0.2.2': {} + + '@form-create/component-elm-checkbox@3.1.29': + dependencies: + '@form-create/utils': 3.1.29 + + '@form-create/component-elm-frame@3.1.29': + dependencies: + '@form-create/utils': 3.1.29 + + '@form-create/component-elm-group@3.1.29': + dependencies: + '@form-create/utils': 3.1.29 + + '@form-create/component-elm-radio@3.1.29': + dependencies: + '@form-create/utils': 3.1.29 + + '@form-create/component-elm-select@3.1.29': + dependencies: + '@form-create/utils': 3.1.29 + + '@form-create/component-elm-tree@3.1.29': + dependencies: + '@form-create/utils': 3.1.29 + + '@form-create/component-elm-upload@3.1.29': + dependencies: + '@form-create/utils': 3.1.29 + + '@form-create/component-subform@3.1.5': {} + + '@form-create/component-wangeditor@3.1.20': + dependencies: + wangeditor: 4.7.15 + + '@form-create/core@3.1.29(vue@3.4.21(typescript@5.3.3))': + dependencies: + '@form-create/utils': 3.1.29 + vue: 3.4.21(typescript@5.3.3) + + '@form-create/designer@3.1.5(vue@3.4.21(typescript@5.3.3))': + dependencies: + '@form-create/component-wangeditor': 3.1.20 + '@form-create/element-ui': 3.1.29(vue@3.4.21(typescript@5.3.3)) + '@form-create/utils': 3.1.29 + vuedraggable: 4.1.0(vue@3.4.21(typescript@5.3.3)) + transitivePeerDependencies: + - vue + + '@form-create/element-ui@3.1.29(vue@3.4.21(typescript@5.3.3))': + dependencies: + '@form-create/component-elm-checkbox': 3.1.29 + '@form-create/component-elm-frame': 3.1.29 + '@form-create/component-elm-group': 3.1.29 + '@form-create/component-elm-radio': 3.1.29 + '@form-create/component-elm-select': 3.1.29 + '@form-create/component-elm-tree': 3.1.29 + '@form-create/component-elm-upload': 3.1.29 + '@form-create/component-subform': 3.1.5 + '@form-create/core': 3.1.29(vue@3.4.21(typescript@5.3.3)) + '@form-create/utils': 3.1.29 + vue: 3.4.21(typescript@5.3.3) + + '@form-create/utils@3.1.29': {} + + '@gera2ld/jsx-dom@2.2.2': + dependencies: + '@babel/runtime': 7.24.4 + + '@humanwhocodes/config-array@0.11.14': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@iconify/iconify@2.1.2': + dependencies: + cross-fetch: 3.1.8 + transitivePeerDependencies: + - encoding + + '@iconify/iconify@3.1.1': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/json@2.2.205': + dependencies: + '@iconify/types': 2.0.0 + pathe: 1.1.2 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@2.1.23': + dependencies: + '@antfu/install-pkg': 0.1.1 + '@antfu/utils': 0.7.7 + '@iconify/types': 2.0.0 + debug: 4.3.4 + kolorist: 1.8.0 + local-pkg: 0.5.0 + mlly: 1.6.1 + transitivePeerDependencies: + - supports-color + + '@intlify/bundle-utils@7.5.1(vue-i18n@9.10.2(vue@3.4.21(typescript@5.3.3)))': + dependencies: + '@intlify/message-compiler': 9.13.1 + '@intlify/shared': 9.13.1 + acorn: 8.11.3 + escodegen: 2.1.0 + estree-walker: 2.0.2 + jsonc-eslint-parser: 2.4.0 + magic-string: 0.30.10 + mlly: 1.6.1 + source-map-js: 1.2.0 + yaml-eslint-parser: 1.2.2 + optionalDependencies: + vue-i18n: 9.10.2(vue@3.4.21(typescript@5.3.3)) + + '@intlify/core-base@9.10.2': + dependencies: + '@intlify/message-compiler': 9.10.2 + '@intlify/shared': 9.10.2 + + '@intlify/message-compiler@9.10.2': + dependencies: + '@intlify/shared': 9.10.2 + source-map-js: 1.2.0 + + '@intlify/message-compiler@9.13.1': + dependencies: + '@intlify/shared': 9.13.1 + source-map-js: 1.2.0 + + '@intlify/shared@9.10.2': {} + + '@intlify/shared@9.13.1': {} + + '@intlify/unplugin-vue-i18n@2.0.0(rollup@4.17.1)(vue-i18n@9.10.2(vue@3.4.21(typescript@5.3.3)))': + dependencies: + '@intlify/bundle-utils': 7.5.1(vue-i18n@9.10.2(vue@3.4.21(typescript@5.3.3))) + '@intlify/shared': 9.13.1 + '@rollup/pluginutils': 5.1.0(rollup@4.17.1) + '@vue/compiler-sfc': 3.4.26 + debug: 4.3.4 + fast-glob: 3.3.2 + js-yaml: 4.1.0 + json5: 2.2.3 + pathe: 1.1.2 + picocolors: 1.0.0 + source-map-js: 1.2.0 + unplugin: 1.10.1 + optionalDependencies: + vue-i18n: 9.10.2(vue@3.4.21(typescript@5.3.3)) + transitivePeerDependencies: + - rollup + - supports-color + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/source-map@0.3.6': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/sourcemap-codec@1.4.15': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + + '@microsoft/fetch-event-source@2.0.1': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.1.1': {} + + '@polka/url@1.0.0-next.25': {} + + '@purge-icons/core@0.10.0': + dependencies: + '@iconify/iconify': 2.1.2 + axios: 0.26.1(debug@4.3.4) + debug: 4.3.4 + fast-glob: 3.3.2 + fs-extra: 10.1.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@purge-icons/generated@0.10.0': + dependencies: + '@iconify/iconify': 3.1.1 + + '@purge-icons/generated@0.9.0': + dependencies: + '@iconify/iconify': 3.1.1 + + '@rollup/plugin-virtual@3.0.2(rollup@4.17.1)': + optionalDependencies: + rollup: 4.17.1 + + '@rollup/pluginutils@4.2.1': + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + + '@rollup/pluginutils@5.1.0(rollup@4.17.1)': + dependencies: + '@types/estree': 1.0.5 + estree-walker: 2.0.2 + picomatch: 2.3.1 + optionalDependencies: + rollup: 4.17.1 + + '@rollup/rollup-android-arm-eabi@4.17.1': + optional: true + + '@rollup/rollup-android-arm64@4.17.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.17.1': + optional: true + + '@rollup/rollup-darwin-x64@4.17.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.17.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.17.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.17.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.17.1': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.17.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.17.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.17.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.17.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.17.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.17.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.17.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.17.1': + optional: true + + '@sinclair/typebox@0.27.8': {} + + '@swc/core-darwin-arm64@1.4.17': + optional: true + + '@swc/core-darwin-x64@1.4.17': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.4.17': + optional: true + + '@swc/core-linux-arm64-gnu@1.4.17': + optional: true + + '@swc/core-linux-arm64-musl@1.4.17': + optional: true + + '@swc/core-linux-x64-gnu@1.4.17': + optional: true + + '@swc/core-linux-x64-musl@1.4.17': + optional: true + + '@swc/core-win32-arm64-msvc@1.4.17': + optional: true + + '@swc/core-win32-ia32-msvc@1.4.17': + optional: true + + '@swc/core-win32-x64-msvc@1.4.17': + optional: true + + '@swc/core@1.4.17': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.6 + optionalDependencies: + '@swc/core-darwin-arm64': 1.4.17 + '@swc/core-darwin-x64': 1.4.17 + '@swc/core-linux-arm-gnueabihf': 1.4.17 + '@swc/core-linux-arm64-gnu': 1.4.17 + '@swc/core-linux-arm64-musl': 1.4.17 + '@swc/core-linux-x64-gnu': 1.4.17 + '@swc/core-linux-x64-musl': 1.4.17 + '@swc/core-win32-arm64-msvc': 1.4.17 + '@swc/core-win32-ia32-msvc': 1.4.17 + '@swc/core-win32-x64-msvc': 1.4.17 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.6': + dependencies: + '@swc/counter': 0.1.3 + + '@sxzz/popperjs-es@2.11.7': {} + + '@transloadit/prettier-bytes@0.0.7': {} + + '@trysound/sax@0.2.0': {} + + '@types/conventional-commits-parser@5.0.0': + dependencies: + '@types/node': 20.12.7 + + '@types/d3-array@3.2.1': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.10 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.10 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.1 + '@types/geojson': 7946.0.14 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.6': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.10 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.14 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.0': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.0.3': {} + + '@types/d3-scale@4.0.8': + dependencies: + '@types/d3-time': 3.0.3 + + '@types/d3-selection@3.0.10': {} + + '@types/d3-shape@3.1.6': + dependencies: + '@types/d3-path': 3.1.0 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.3': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.8': + dependencies: + '@types/d3-selection': 3.0.10 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.10 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.6 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.0 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.8 + '@types/d3-scale-chromatic': 3.0.3 + '@types/d3-selection': 3.0.10 + '@types/d3-shape': 3.1.6 + '@types/d3-time': 3.0.3 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.8 + '@types/d3-zoom': 3.0.8 + + '@types/eslint@8.56.10': + dependencies: + '@types/estree': 1.0.5 + '@types/json-schema': 7.0.15 + + '@types/estree@1.0.5': {} + + '@types/event-emitter@0.3.5': {} + + '@types/geojson@7946.0.14': {} + + '@types/json-schema@7.0.15': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.0 + + '@types/lodash@4.17.0': {} + + '@types/node@10.17.60': {} + + '@types/node@20.12.7': + dependencies: + undici-types: 5.26.5 + + '@types/nprogress@0.2.3': {} + + '@types/qrcode@1.5.5': + dependencies: + '@types/node': 20.12.7 + + '@types/qs@6.9.15': {} + + '@types/semver@7.5.8': {} + + '@types/svgo@2.6.4': + dependencies: + '@types/node': 20.12.7 + + '@types/video.js@7.3.58': {} + + '@types/web-bluetooth@0.0.16': {} + + '@types/web-bluetooth@0.0.20': {} + + '@typescript-eslint/eslint-plugin@7.7.1(@typescript-eslint/parser@7.7.1(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3)': + dependencies: + '@eslint-community/regexpp': 4.10.0 + '@typescript-eslint/parser': 7.7.1(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/scope-manager': 7.7.1 + '@typescript-eslint/type-utils': 7.7.1(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/utils': 7.7.1(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 7.7.1 + debug: 4.3.4 + eslint: 8.57.0 + graphemer: 1.4.0 + ignore: 5.3.1 + natural-compare: 1.4.0 + semver: 7.6.0 + ts-api-utils: 1.3.0(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.4 + eslint: 8.57.0 + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.7.1(eslint@8.57.0)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.7.1 + '@typescript-eslint/types': 7.7.1 + '@typescript-eslint/typescript-estree': 7.7.1(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 7.7.1 + debug: 4.3.4 + eslint: 8.57.0 + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/scope-manager@7.7.1': + dependencies: + '@typescript-eslint/types': 7.7.1 + '@typescript-eslint/visitor-keys': 7.7.1 + + '@typescript-eslint/type-utils@7.7.1(eslint@8.57.0)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/typescript-estree': 7.7.1(typescript@5.3.3) + '@typescript-eslint/utils': 7.7.1(eslint@8.57.0)(typescript@5.3.3) + debug: 4.3.4 + eslint: 8.57.0 + ts-api-utils: 1.3.0(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/types@7.7.1': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.3.3)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.6.0 + ts-api-utils: 1.3.0(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@7.7.1(typescript@5.3.3)': + dependencies: + '@typescript-eslint/types': 7.7.1 + '@typescript-eslint/visitor-keys': 7.7.1 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.4 + semver: 7.6.0 + ts-api-utils: 1.3.0(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@8.57.0)(typescript@5.3.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.5.8 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + eslint: 8.57.0 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/utils@7.7.1(eslint@8.57.0)(typescript@5.3.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.5.8 + '@typescript-eslint/scope-manager': 7.7.1 + '@typescript-eslint/types': 7.7.1 + '@typescript-eslint/typescript-estree': 7.7.1(typescript@5.3.3) + eslint: 8.57.0 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@typescript-eslint/visitor-keys@7.7.1': + dependencies: + '@typescript-eslint/types': 7.7.1 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.2.0': {} + + '@unocss/astro@0.58.9(rollup@4.17.1)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4))': + dependencies: + '@unocss/core': 0.58.9 + '@unocss/reset': 0.58.9 + '@unocss/vite': 0.58.9(rollup@4.17.1)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + optionalDependencies: + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + transitivePeerDependencies: + - rollup + + '@unocss/cli@0.58.9(rollup@4.17.1)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@rollup/pluginutils': 5.1.0(rollup@4.17.1) + '@unocss/config': 0.58.9 + '@unocss/core': 0.58.9 + '@unocss/preset-uno': 0.58.9 + cac: 6.7.14 + chokidar: 3.6.0 + colorette: 2.0.20 + consola: 3.2.3 + fast-glob: 3.3.2 + magic-string: 0.30.10 + pathe: 1.1.2 + perfect-debounce: 1.0.0 + transitivePeerDependencies: + - rollup + + '@unocss/config@0.57.7': + dependencies: + '@unocss/core': 0.57.7 + unconfig: 0.3.13 + + '@unocss/config@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + unconfig: 0.3.13 + + '@unocss/core@0.57.7': {} + + '@unocss/core@0.58.9': {} + + '@unocss/eslint-config@0.57.7(eslint@8.57.0)(typescript@5.3.3)': + dependencies: + '@unocss/eslint-plugin': 0.57.7(eslint@8.57.0)(typescript@5.3.3) + transitivePeerDependencies: + - eslint + - supports-color + - typescript + + '@unocss/eslint-plugin@0.57.7(eslint@8.57.0)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3) + '@unocss/config': 0.57.7 + '@unocss/core': 0.57.7 + magic-string: 0.30.10 + synckit: 0.8.8 + transitivePeerDependencies: + - eslint + - supports-color + - typescript + + '@unocss/extractor-arbitrary-variants@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + + '@unocss/inspector@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + '@unocss/rule-utils': 0.58.9 + gzip-size: 6.0.0 + sirv: 2.0.4 + + '@unocss/postcss@0.58.9(postcss@8.4.38)': + dependencies: + '@unocss/config': 0.58.9 + '@unocss/core': 0.58.9 + '@unocss/rule-utils': 0.58.9 + css-tree: 2.3.1 + fast-glob: 3.3.2 + magic-string: 0.30.10 + postcss: 8.4.38 + + '@unocss/preset-attributify@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + + '@unocss/preset-icons@0.58.9': + dependencies: + '@iconify/utils': 2.1.23 + '@unocss/core': 0.58.9 + ofetch: 1.3.4 + transitivePeerDependencies: + - supports-color + + '@unocss/preset-mini@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + '@unocss/extractor-arbitrary-variants': 0.58.9 + '@unocss/rule-utils': 0.58.9 + + '@unocss/preset-tagify@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + + '@unocss/preset-typography@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + '@unocss/preset-mini': 0.58.9 + + '@unocss/preset-uno@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + '@unocss/preset-mini': 0.58.9 + '@unocss/preset-wind': 0.58.9 + '@unocss/rule-utils': 0.58.9 + + '@unocss/preset-web-fonts@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + ofetch: 1.3.4 + + '@unocss/preset-wind@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + '@unocss/preset-mini': 0.58.9 + '@unocss/rule-utils': 0.58.9 + + '@unocss/reset@0.58.9': {} + + '@unocss/rule-utils@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + magic-string: 0.30.10 + + '@unocss/scope@0.58.9': {} + + '@unocss/transformer-attributify-jsx-babel@0.58.9': + dependencies: + '@babel/core': 7.24.4 + '@babel/plugin-syntax-jsx': 7.24.1(@babel/core@7.24.4) + '@babel/preset-typescript': 7.24.1(@babel/core@7.24.4) + '@unocss/core': 0.58.9 + transitivePeerDependencies: + - supports-color + + '@unocss/transformer-attributify-jsx@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + + '@unocss/transformer-compile-class@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + + '@unocss/transformer-directives@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + '@unocss/rule-utils': 0.58.9 + css-tree: 2.3.1 + + '@unocss/transformer-variant-group@0.58.9': + dependencies: + '@unocss/core': 0.58.9 + + '@unocss/vite@0.58.9(rollup@4.17.1)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@rollup/pluginutils': 5.1.0(rollup@4.17.1) + '@unocss/config': 0.58.9 + '@unocss/core': 0.58.9 + '@unocss/inspector': 0.58.9 + '@unocss/scope': 0.58.9 + '@unocss/transformer-directives': 0.58.9 + chokidar: 3.6.0 + fast-glob: 3.3.2 + magic-string: 0.30.10 + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + transitivePeerDependencies: + - rollup + + '@uppy/companion-client@2.2.2': + dependencies: + '@uppy/utils': 4.1.3 + namespace-emitter: 2.0.1 + + '@uppy/core@2.3.4': + dependencies: + '@transloadit/prettier-bytes': 0.0.7 + '@uppy/store-default': 2.1.1 + '@uppy/utils': 4.1.3 + lodash.throttle: 4.1.1 + mime-match: 1.0.2 + namespace-emitter: 2.0.1 + nanoid: 3.3.7 + preact: 10.20.2 + + '@uppy/store-default@2.1.1': {} + + '@uppy/utils@4.1.3': + dependencies: + lodash.throttle: 4.1.1 + + '@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4)': + dependencies: + '@uppy/companion-client': 2.2.2 + '@uppy/core': 2.3.4 + '@uppy/utils': 4.1.3 + nanoid: 3.3.7 + + '@videojs-player/vue@1.0.0(@types/video.js@7.3.58)(video.js@7.21.5)(vue@3.4.21(typescript@5.3.3))': + dependencies: + '@types/video.js': 7.3.58 + video.js: 7.21.5 + vue: 3.4.21(typescript@5.3.3) + + '@videojs/http-streaming@2.16.2(video.js@7.21.5)': + dependencies: + '@babel/runtime': 7.24.4 + '@videojs/vhs-utils': 3.0.5 + aes-decrypter: 3.1.3 + global: 4.4.0 + m3u8-parser: 4.8.0 + mpd-parser: 0.22.1 + mux.js: 6.0.1 + video.js: 7.21.5 + + '@videojs/vhs-utils@3.0.5': + dependencies: + '@babel/runtime': 7.24.4 + global: 4.4.0 + url-toolkit: 2.2.5 + + '@videojs/xhr@2.6.0': + dependencies: + '@babel/runtime': 7.24.4 + global: 4.4.0 + is-function: 1.0.2 + + '@vitejs/plugin-legacy@5.3.2(terser@5.30.4)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4))': + dependencies: + '@babel/core': 7.24.4 + '@babel/preset-env': 7.24.4(@babel/core@7.24.4) + browserslist: 4.23.0 + browserslist-to-esbuild: 2.1.1(browserslist@4.23.0) + core-js: 3.37.0 + magic-string: 0.30.10 + regenerator-runtime: 0.14.1 + systemjs: 6.15.1 + terser: 5.30.4 + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-vue-jsx@3.1.0(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4))(vue@3.4.21(typescript@5.3.3))': + dependencies: + '@babel/core': 7.24.4 + '@babel/plugin-transform-typescript': 7.24.4(@babel/core@7.24.4) + '@vue/babel-plugin-jsx': 1.2.2(@babel/core@7.24.4) + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + vue: 3.4.21(typescript@5.3.3) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-vue@5.0.4(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4))(vue@3.4.21(typescript@5.3.3))': + dependencies: + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + vue: 3.4.21(typescript@5.3.3) + + '@volar/language-core@1.11.1': + dependencies: + '@volar/source-map': 1.11.1 + + '@volar/source-map@1.11.1': + dependencies: + muggle-string: 0.3.1 + + '@volar/typescript@1.11.1': + dependencies: + '@volar/language-core': 1.11.1 + path-browserify: 1.0.1 + + '@vue/babel-helper-vue-transform-on@1.2.2': {} + + '@vue/babel-plugin-jsx@1.2.2(@babel/core@7.24.4)': + dependencies: + '@babel/helper-module-imports': 7.22.15 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-jsx': 7.24.1(@babel/core@7.24.4) + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + '@vue/babel-helper-vue-transform-on': 1.2.2 + '@vue/babel-plugin-resolve-type': 1.2.2(@babel/core@7.24.4) + camelcase: 6.3.0 + html-tags: 3.3.1 + svg-tags: 1.0.0 + optionalDependencies: + '@babel/core': 7.24.4 + transitivePeerDependencies: + - supports-color + + '@vue/babel-plugin-resolve-type@1.2.2(@babel/core@7.24.4)': + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/core': 7.24.4 + '@babel/helper-module-imports': 7.22.15 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/parser': 7.24.4 + '@vue/compiler-sfc': 3.4.26 + + '@vue/compiler-core@3.4.21': + dependencies: + '@babel/parser': 7.24.4 + '@vue/shared': 3.4.21 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.0 + + '@vue/compiler-core@3.4.26': + dependencies: + '@babel/parser': 7.24.4 + '@vue/shared': 3.4.26 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.0 + + '@vue/compiler-dom@3.4.21': + dependencies: + '@vue/compiler-core': 3.4.21 + '@vue/shared': 3.4.21 + + '@vue/compiler-dom@3.4.26': + dependencies: + '@vue/compiler-core': 3.4.26 + '@vue/shared': 3.4.26 + + '@vue/compiler-sfc@3.4.21': + dependencies: + '@babel/parser': 7.24.4 + '@vue/compiler-core': 3.4.21 + '@vue/compiler-dom': 3.4.21 + '@vue/compiler-ssr': 3.4.21 + '@vue/shared': 3.4.21 + estree-walker: 2.0.2 + magic-string: 0.30.10 + postcss: 8.4.38 + source-map-js: 1.2.0 + + '@vue/compiler-sfc@3.4.26': + dependencies: + '@babel/parser': 7.24.4 + '@vue/compiler-core': 3.4.26 + '@vue/compiler-dom': 3.4.26 + '@vue/compiler-ssr': 3.4.26 + '@vue/shared': 3.4.26 + estree-walker: 2.0.2 + magic-string: 0.30.10 + postcss: 8.4.38 + source-map-js: 1.2.0 + + '@vue/compiler-ssr@3.4.21': + dependencies: + '@vue/compiler-dom': 3.4.21 + '@vue/shared': 3.4.21 + + '@vue/compiler-ssr@3.4.26': + dependencies: + '@vue/compiler-dom': 3.4.26 + '@vue/shared': 3.4.26 + + '@vue/devtools-api@6.6.1': {} + + '@vue/language-core@1.8.27(typescript@5.3.3)': + dependencies: + '@volar/language-core': 1.11.1 + '@volar/source-map': 1.11.1 + '@vue/compiler-dom': 3.4.26 + '@vue/shared': 3.4.26 + computeds: 0.0.1 + minimatch: 9.0.4 + muggle-string: 0.3.1 + path-browserify: 1.0.1 + vue-template-compiler: 2.7.16 + optionalDependencies: + typescript: 5.3.3 + + '@vue/reactivity@3.4.21': + dependencies: + '@vue/shared': 3.4.21 + + '@vue/runtime-core@3.4.21': + dependencies: + '@vue/reactivity': 3.4.21 + '@vue/shared': 3.4.21 + + '@vue/runtime-dom@3.4.21': + dependencies: + '@vue/runtime-core': 3.4.21 + '@vue/shared': 3.4.21 + csstype: 3.1.3 + + '@vue/server-renderer@3.4.21(vue@3.4.21(typescript@5.3.3))': + dependencies: + '@vue/compiler-ssr': 3.4.21 + '@vue/shared': 3.4.21 + vue: 3.4.21(typescript@5.3.3) + + '@vue/shared@3.4.21': {} + + '@vue/shared@3.4.26': {} + + '@vueuse/core@10.9.0(vue@3.4.21(typescript@5.3.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.9.0 + '@vueuse/shared': 10.9.0(vue@3.4.21(typescript@5.3.3)) + vue-demi: 0.14.7(vue@3.4.21(typescript@5.3.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/core@9.13.0(vue@3.4.21(typescript@5.3.3))': + dependencies: + '@types/web-bluetooth': 0.0.16 + '@vueuse/metadata': 9.13.0 + '@vueuse/shared': 9.13.0(vue@3.4.21(typescript@5.3.3)) + vue-demi: 0.14.7(vue@3.4.21(typescript@5.3.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/metadata@10.9.0': {} + + '@vueuse/metadata@9.13.0': {} + + '@vueuse/shared@10.9.0(vue@3.4.21(typescript@5.3.3))': + dependencies: + vue-demi: 0.14.7(vue@3.4.21(typescript@5.3.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/shared@9.13.0(vue@3.4.21(typescript@5.3.3))': + dependencies: + vue-demi: 0.14.7(vue@3.4.21(typescript@5.3.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2)': + dependencies: + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + dom7: 3.0.0 + is-url: 1.2.4 + lodash.throttle: 4.1.1 + nanoid: 3.3.7 + slate: 0.72.8 + snabbdom: 3.6.2 + + '@wangeditor/code-highlight@1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.2)': + dependencies: + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + dom7: 3.0.0 + prismjs: 1.29.0 + slate: 0.72.8 + snabbdom: 3.6.2 + + '@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2)': + dependencies: + '@types/event-emitter': 0.3.5 + '@uppy/core': 2.3.4 + '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4) + dom7: 3.0.0 + event-emitter: 0.3.5 + html-void-elements: 2.0.1 + i18next: 20.6.1 + is-hotkey: 0.2.0 + lodash.camelcase: 4.3.0 + lodash.clonedeep: 4.5.0 + lodash.debounce: 4.0.8 + lodash.foreach: 4.5.0 + lodash.isequal: 4.5.0 + lodash.throttle: 4.1.1 + lodash.toarray: 4.4.0 + nanoid: 3.3.7 + scroll-into-view-if-needed: 2.2.31 + slate: 0.72.8 + slate-history: 0.66.0(slate@0.72.8) + snabbdom: 3.6.2 + + '@wangeditor/editor-for-vue@5.1.12(@wangeditor/editor@5.1.23)(vue@3.4.21(typescript@5.3.3))': + dependencies: + '@wangeditor/editor': 5.1.23 + vue: 3.4.21(typescript@5.3.3) + + '@wangeditor/editor@5.1.23': + dependencies: + '@uppy/core': 2.3.4 + '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4) + '@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + '@wangeditor/code-highlight': 1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.2) + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + '@wangeditor/list-module': 1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.2) + '@wangeditor/table-module': 1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + '@wangeditor/upload-image-module': 1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.2) + '@wangeditor/video-module': 1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + dom7: 3.0.0 + is-hotkey: 0.2.0 + lodash.camelcase: 4.3.0 + lodash.clonedeep: 4.5.0 + lodash.debounce: 4.0.8 + lodash.foreach: 4.5.0 + lodash.isequal: 4.5.0 + lodash.throttle: 4.1.1 + lodash.toarray: 4.4.0 + nanoid: 3.3.7 + slate: 0.72.8 + snabbdom: 3.6.2 + + '@wangeditor/list-module@1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.2)': + dependencies: + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + dom7: 3.0.0 + slate: 0.72.8 + snabbdom: 3.6.2 + + '@wangeditor/table-module@1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2)': + dependencies: + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + dom7: 3.0.0 + lodash.isequal: 4.5.0 + lodash.throttle: 4.1.1 + nanoid: 3.3.7 + slate: 0.72.8 + snabbdom: 3.6.2 + + '@wangeditor/upload-image-module@1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.2)': + dependencies: + '@uppy/core': 2.3.4 + '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4) + '@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + dom7: 3.0.0 + lodash.foreach: 4.5.0 + slate: 0.72.8 + snabbdom: 3.6.2 + + '@wangeditor/video-module@1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2)': + dependencies: + '@uppy/core': 2.3.4 + '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4) + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.7)(slate@0.72.8)(snabbdom@3.6.2) + dom7: 3.0.0 + nanoid: 3.3.7 + slate: 0.72.8 + snabbdom: 3.6.2 + + '@xmldom/xmldom@0.8.10': {} + + '@zxcvbn-ts/core@3.0.4': + dependencies: + fastest-levenshtein: 1.0.16 + + JSONStream@1.3.5: + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 + + acorn-jsx@5.3.2(acorn@8.11.3): + dependencies: + acorn: 8.11.3 + + acorn@8.11.3: {} + + aes-decrypter@3.1.3: + dependencies: + '@babel/runtime': 7.24.4 + '@videojs/vhs-utils': 3.0.5 + global: 4.4.0 + pkcs7: 1.0.4 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + animate.css@4.1.1: {} + + ansi-escapes@6.2.1: {} + + ansi-regex@2.1.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.0.1: {} + + ansi-styles@2.2.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.1: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + arr-diff@4.0.0: {} + + arr-flatten@1.1.0: {} + + arr-union@3.1.0: {} + + array-buffer-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + is-array-buffer: 3.0.4 + + array-ify@1.0.0: {} + + array-union@2.1.0: {} + + array-unique@0.3.2: {} + + arraybuffer.prototype.slice@1.0.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + + assign-symbols@1.0.0: {} + + astral-regex@2.0.0: {} + + async-validator@4.2.5: {} + + async@3.2.5: {} + + asynckit@0.4.0: {} + + atob@2.1.2: {} + + autolinker@3.16.2: + dependencies: + tslib: 2.6.2 + + autoprefixer@10.4.19(postcss@8.4.38): + dependencies: + browserslist: 4.23.0 + caniuse-lite: 1.0.30001614 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.0.0 + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.0.0 + + axios@0.26.1(debug@4.3.4): + dependencies: + follow-redirects: 1.15.6(debug@4.3.4) + transitivePeerDependencies: + - debug + + axios@1.6.8: + dependencies: + follow-redirects: 1.15.6(debug@4.3.4) + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-plugin-polyfill-corejs2@0.4.11(@babel/core@7.24.4): + dependencies: + '@babel/compat-data': 7.24.4 + '@babel/core': 7.24.4 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.24.4) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.10.4(@babel/core@7.24.4): + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.24.4) + core-js-compat: 3.37.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.2(@babel/core@7.24.4): + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.24.4) + transitivePeerDependencies: + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@2.0.0: {} + + base@0.11.2: + dependencies: + cache-base: 1.0.1 + class-utils: 0.3.6 + component-emitter: 1.3.1 + define-property: 1.0.0 + isobject: 3.0.1 + mixin-deep: 1.3.2 + pascalcase: 0.1.1 + + benz-amr-recorder@1.1.5: + dependencies: + benz-recorderjs: 1.0.5 + + benz-recorderjs@1.0.5: {} + + big.js@5.2.2: {} + + binary-extensions@2.3.0: {} + + bluebird@3.7.2: {} + + boolbase@1.0.0: {} + + bpmn-js-properties-panel@0.46.0(bpmn-js@8.9.0): + dependencies: + '@bpmn-io/element-templates-validator': 0.2.0 + '@bpmn-io/extract-process-variables': 0.4.5 + bpmn-js: 8.9.0 + ids: 1.0.5 + inherits: 2.0.4 + lodash: 4.17.21 + min-dom: 3.2.1 + scroll-tabs: 1.0.1 + selection-update: 0.1.2 + semver: 6.3.1 + + bpmn-js-token-simulation@0.10.0: + dependencies: + min-dash: 3.8.1 + min-dom: 0.2.0 + svg.js: 2.7.1 + + bpmn-js@8.9.0: + dependencies: + bpmn-moddle: 7.1.3 + css.escape: 1.5.1 + diagram-js: 7.9.0 + diagram-js-direct-editing: 1.8.0(diagram-js@7.9.0) + ids: 1.0.5 + inherits: 2.0.4 + min-dash: 3.8.1 + min-dom: 3.2.1 + object-refs: 0.3.0 + tiny-svg: 2.2.4 + + bpmn-moddle@7.1.3: + dependencies: + min-dash: 3.8.1 + moddle: 5.0.4 + moddle-xml: 9.0.6 + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@2.3.2: + dependencies: + arr-flatten: 1.1.0 + array-unique: 0.3.2 + extend-shallow: 2.0.1 + fill-range: 4.0.0 + isobject: 3.0.1 + repeat-element: 1.1.4 + snapdragon: 0.8.2 + snapdragon-node: 2.1.1 + split-string: 3.1.0 + to-regex: 3.0.2 + transitivePeerDependencies: + - supports-color + + braces@3.0.2: + dependencies: + fill-range: 7.0.1 + + browserslist-to-esbuild@2.1.1(browserslist@4.23.0): + dependencies: + browserslist: 4.23.0 + meow: 13.2.0 + + browserslist@4.23.0: + dependencies: + caniuse-lite: 1.0.30001614 + electron-to-chromium: 1.4.750 + node-releases: 2.0.14 + update-browserslist-db: 1.0.13(browserslist@4.23.0) + + buffer-from@1.1.2: {} + + cac@6.7.14: {} + + cache-base@1.0.1: + dependencies: + collection-visit: 1.0.0 + component-emitter: 1.3.1 + get-value: 2.0.6 + has-value: 1.0.0 + isobject: 3.0.1 + set-value: 2.0.1 + to-object-path: 0.3.0 + union-value: 1.0.1 + unset-value: 1.0.0 + + call-bind@1.0.7: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + camunda-bpmn-moddle@7.0.1: {} + + caniuse-lite@1.0.30001614: {} + + chalk@1.1.3: + dependencies: + ansi-styles: 2.2.1 + escape-string-regexp: 1.0.5 + has-ansi: 2.0.0 + strip-ansi: 3.0.1 + supports-color: 2.0.0 + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.3.0: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + + cheerio@1.0.0-rc.12: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + htmlparser2: 8.0.2 + parse5: 7.1.2 + parse5-htmlparser2-tree-adapter: 7.0.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-utils@0.3.6: + dependencies: + arr-union: 3.1.0 + define-property: 0.2.5 + isobject: 3.0.1 + static-extend: 0.1.2 + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.1.0 + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@2.1.2: {} + + clsx@2.1.1: {} + + collection-visit@1.0.0: + dependencies: + map-visit: 1.0.0 + object-visit: 1.0.1 + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + colord@2.9.3: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@11.1.0: {} + + commander@2.20.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + common-tags@1.8.2: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + component-classes@1.2.6: + dependencies: + component-indexof: 0.0.3 + + component-closest@0.1.4: + dependencies: + component-matches-selector: 0.1.7 + + component-delegate@0.2.4: + dependencies: + component-closest: 0.1.4 + component-event: 0.1.4 + + component-emitter@1.3.1: {} + + component-event@0.1.4: {} + + component-event@0.2.1: {} + + component-indexof@0.0.3: {} + + component-matches-selector@0.1.7: + dependencies: + component-query: 0.0.3 + global-object: 1.0.0 + + component-query@0.0.3: {} + + compute-scroll-into-view@1.0.20: {} + + computeds@0.0.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.7: {} + + consola@3.2.3: {} + + conventional-changelog-angular@7.0.0: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@7.0.2: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@5.0.0: + dependencies: + JSONStream: 1.3.5 + is-text-path: 2.0.0 + meow: 12.1.1 + split2: 4.2.0 + + convert-source-map@2.0.0: {} + + copy-descriptor@0.1.1: {} + + core-js-compat@3.37.0: + dependencies: + browserslist: 4.23.0 + + core-js-pure@3.37.0: {} + + core-js@3.37.0: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig-typescript-loader@5.0.0(@types/node@20.12.7)(cosmiconfig@9.0.0(typescript@5.3.3))(typescript@5.3.3): + dependencies: + '@types/node': 20.12.7 + cosmiconfig: 9.0.0(typescript@5.3.3) + jiti: 1.21.0 + typescript: 5.3.3 + + cosmiconfig@9.0.0(typescript@5.3.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.3.3 + + cropperjs@1.6.2: {} + + cross-fetch@3.1.8: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-js@4.2.0: {} + + css-functions-list@3.2.2: {} + + css-select@4.3.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.0 + + css-what@6.1.0: {} + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + csso@4.2.0: + dependencies: + css-tree: 1.1.3 + + csstype@3.1.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-flextree@2.1.2: + dependencies: + d3-hierarchy: 1.1.9 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.0: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@1.1.9: {} + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.0 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + d@1.0.2: + dependencies: + es5-ext: 0.10.64 + type: 2.7.2 + + dargs@8.1.0: {} + + data-view-buffer@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-offset@1.0.0: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + dayjs@1.11.11: {} + + de-indent@1.0.2: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + decamelize@1.2.0: {} + + decode-uri-component@0.2.2: {} + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + define-property@0.2.5: + dependencies: + is-descriptor: 0.1.7 + + define-property@1.0.0: + dependencies: + is-descriptor: 1.0.3 + + define-property@2.0.2: + dependencies: + is-descriptor: 1.0.3 + isobject: 3.0.1 + + defu@6.1.4: {} + + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + + delayed-stream@1.0.0: {} + + destr@2.0.3: {} + + diagram-js-direct-editing@1.8.0(diagram-js@7.9.0): + dependencies: + diagram-js: 7.9.0 + min-dash: 3.8.1 + min-dom: 3.2.1 + + diagram-js@12.8.1: + dependencies: + '@bpmn-io/diagram-js-ui': 0.2.3 + clsx: 2.1.1 + didi: 9.0.2 + hammerjs: 2.0.8 + inherits-browser: 0.1.0 + min-dash: 4.2.1 + min-dom: 4.1.0 + object-refs: 0.3.0 + path-intersection: 2.2.1 + tiny-svg: 3.0.1 + + diagram-js@7.9.0: + dependencies: + css.escape: 1.5.1 + didi: 5.2.1 + hammerjs: 2.0.8 + inherits: 2.0.4 + min-dash: 3.8.1 + min-dom: 3.2.1 + object-refs: 0.3.0 + path-intersection: 2.2.1 + tiny-svg: 2.2.4 + + didi@5.2.1: {} + + didi@9.0.2: {} + + dijkstrajs@1.0.3: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dlv@1.1.3: {} + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-serializer@0.2.2: + dependencies: + domelementtype: 2.3.0 + entities: 2.2.0 + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-walk@0.1.2: {} + + dom7@3.0.0: + dependencies: + ssr-window: 3.0.0 + + domelementtype@1.3.1: {} + + domelementtype@2.3.0: {} + + domhandler@2.4.2: + dependencies: + domelementtype: 1.3.1 + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domify@1.4.2: {} + + dompurify@3.1.1: {} + + domutils@1.7.0: + dependencies: + dom-serializer: 0.2.2 + domelementtype: 1.3.1 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + + domutils@3.1.0: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + driver.js@1.3.1: {} + + duplexer@0.1.2: {} + + eastasianwidth@0.2.0: {} + + echarts-wordcloud@2.1.0(echarts@5.5.0): + dependencies: + echarts: 5.5.0 + + echarts@5.5.0: + dependencies: + tslib: 2.3.0 + zrender: 5.5.0 + + ejs@3.1.10: + dependencies: + jake: 10.8.7 + + electron-to-chromium@1.4.750: {} + + element-plus@2.7.0(vue@3.4.21(typescript@5.3.3)): + dependencies: + '@ctrl/tinycolor': 3.6.1 + '@element-plus/icons-vue': 2.3.1(vue@3.4.21(typescript@5.3.3)) + '@floating-ui/dom': 1.6.4 + '@popperjs/core': '@sxzz/popperjs-es@2.11.7' + '@types/lodash': 4.17.0 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 9.13.0(vue@3.4.21(typescript@5.3.3)) + async-validator: 4.2.5 + dayjs: 1.11.11 + escape-html: 1.0.3 + lodash: 4.17.21 + lodash-es: 4.17.21 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.4.21(typescript@5.3.3) + transitivePeerDependencies: + - '@vue/composition-api' + + emoji-regex@10.3.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + emojis-list@3.0.0: {} + + encode-utf8@1.0.3: {} + + entities@1.1.2: {} + + entities@2.2.0: {} + + entities@4.5.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.23.3: + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.0.3 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.3 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.1 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.2.4 + + es-errors@1.3.0: {} + + es-module-lexer@1.5.2: {} + + es-object-atoms@1.0.0: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.0.3: + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-to-primitive@1.2.1: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + + es5-ext@0.10.64: + dependencies: + es6-iterator: 2.0.3 + es6-symbol: 3.1.4 + esniff: 2.0.1 + next-tick: 1.1.0 + + es6-iterator@2.0.3: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + es6-symbol: 3.1.4 + + es6-symbol@3.1.4: + dependencies: + d: 1.0.2 + ext: 1.7.0 + + esbuild@0.19.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + + escalade@3.1.2: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-config-prettier@9.1.0(eslint@8.57.0): + dependencies: + eslint: 8.57.0 + + eslint-define-config@2.1.0: {} + + eslint-plugin-prettier@5.1.3(@types/eslint@8.56.10)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.2.5): + dependencies: + eslint: 8.57.0 + prettier: 3.2.5 + prettier-linter-helpers: 1.0.0 + synckit: 0.8.8 + optionalDependencies: + '@types/eslint': 8.56.10 + eslint-config-prettier: 9.1.0(eslint@8.57.0) + + eslint-plugin-vue@9.25.0(eslint@8.57.0): + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + eslint: 8.57.0 + globals: 13.24.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.0.16 + semver: 7.6.0 + vue-eslint-parser: 9.4.2(eslint@8.57.0) + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - supports-color + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.0: + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/regexpp': 4.10.0 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.0 + '@humanwhocodes/config-array': 0.11.14 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.1 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + esniff@2.0.1: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + event-emitter: 0.3.5 + type: 2.7.2 + + espree@9.6.1: + dependencies: + acorn: 8.11.3 + acorn-jsx: 5.3.2(acorn@8.11.3) + eslint-visitor-keys: 3.4.3 + + esprima@4.0.1: {} + + esquery@1.5.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.5 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-emitter@0.3.5: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + + eventemitter3@5.0.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.3 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + expand-brackets@2.1.4: + dependencies: + debug: 2.6.9 + define-property: 0.2.5 + extend-shallow: 2.0.1 + posix-character-classes: 0.1.1 + regex-not: 1.0.2 + snapdragon: 0.8.2 + to-regex: 3.0.2 + transitivePeerDependencies: + - supports-color + + ext@1.7.0: + dependencies: + type: 2.7.2 + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend-shallow@3.0.2: + dependencies: + assign-symbols: 1.0.0 + is-extendable: 1.0.1 + + extglob@2.0.4: + dependencies: + array-unique: 0.3.2 + define-property: 1.0.0 + expand-brackets: 2.1.4 + extend-shallow: 2.0.1 + fragment-cache: 0.2.1 + regex-not: 1.0.2 + snapdragon: 0.8.2 + to-regex: 3.0.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-xml-parser@4.3.6: + dependencies: + strnum: 1.0.5 + + fastest-levenshtein@1.0.16: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + filelist@1.0.4: + dependencies: + minimatch: 5.1.6 + + fill-range@4.0.0: + dependencies: + extend-shallow: 2.0.1 + is-number: 3.0.0 + repeat-string: 1.6.1 + to-regex-range: 2.1.1 + + fill-range@7.0.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + rimraf: 3.0.2 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + + flatted@3.3.1: {} + + follow-redirects@1.15.6(debug@4.3.4): + optionalDependencies: + debug: 4.3.4 + + for-each@0.3.3: + dependencies: + is-callable: 1.2.7 + + for-in@1.0.2: {} + + foreground-child@3.1.1: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + + form-data@4.0.0: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + fraction.js@4.3.7: {} + + fragment-cache@0.2.1: + dependencies: + map-cache: 0.2.2 + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + functions-have-names: 1.2.3 + + functions-have-names@1.2.3: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.2.0: {} + + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + get-stream@6.0.1: {} + + get-stream@8.0.1: {} + + get-symbol-description@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + + get-value@2.0.6: {} + + git-raw-commits@4.0.0: + dependencies: + dargs: 8.1.0 + meow: 12.1.1 + split2: 4.2.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.3.12: + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.4 + minipass: 7.0.4 + path-scurry: 1.10.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-object@1.0.0: {} + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + global@4.4.0: + dependencies: + min-document: 2.19.0 + process: 0.11.10 + + globals@11.12.0: {} + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.3: + dependencies: + define-properties: 1.2.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.1 + merge2: 1.4.1 + slash: 3.0.0 + + globjoin@0.1.4: {} + + gopd@1.0.1: + dependencies: + get-intrinsic: 1.2.4 + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + hammerjs@2.0.8: {} + + has-ansi@2.0.0: + dependencies: + ansi-regex: 2.1.1 + + has-bigints@1.0.2: {} + + has-flag@1.0.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 + + has-proto@1.0.3: {} + + has-symbols@1.0.3: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.0.3 + + has-value@0.3.1: + dependencies: + get-value: 2.0.6 + has-values: 0.1.4 + isobject: 2.1.0 + + has-value@1.0.0: + dependencies: + get-value: 2.0.6 + has-values: 1.0.0 + isobject: 3.0.1 + + has-values@0.1.4: {} + + has-values@1.0.0: + dependencies: + is-number: 3.0.0 + kind-of: 4.0.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + highlight.js@11.9.0: {} + + htm@3.1.1: {} + + html-tags@3.3.1: {} + + html-void-elements@2.0.1: {} + + htmlparser2@3.10.1: + dependencies: + domelementtype: 1.3.1 + domhandler: 2.4.2 + domutils: 1.7.0 + entities: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + + human-signals@2.1.0: {} + + human-signals@5.0.0: {} + + i18next@20.6.1: + dependencies: + '@babel/runtime': 7.24.4 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ids@1.0.5: {} + + ignore@5.3.1: {} + + image-size@0.5.5: {} + + immer@9.0.21: {} + + immutable@4.3.5: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.0.0: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + indexof@0.0.1: {} + + individual@2.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits-browser@0.1.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@4.1.1: {} + + internal-slot@1.0.7: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.0.6 + + internmap@2.0.3: {} + + is-accessor-descriptor@1.0.1: + dependencies: + hasown: 2.0.2 + + is-array-buffer@3.0.4: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + + is-arrayish@0.2.1: {} + + is-bigint@1.0.4: + dependencies: + has-bigints: 1.0.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.1.2: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-buffer@1.1.6: {} + + is-callable@1.2.7: {} + + is-core-module@2.13.1: + dependencies: + hasown: 2.0.2 + + is-data-descriptor@1.0.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.1: + dependencies: + is-typed-array: 1.1.13 + + is-date-object@1.0.5: + dependencies: + has-tostringtag: 1.0.2 + + is-descriptor@0.1.7: + dependencies: + is-accessor-descriptor: 1.0.1 + is-data-descriptor: 1.0.1 + + is-descriptor@1.0.3: + dependencies: + is-accessor-descriptor: 1.0.1 + is-data-descriptor: 1.0.1 + + is-extendable@0.1.1: {} + + is-extendable@1.0.1: + dependencies: + is-plain-object: 2.0.4 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.2.0 + + is-function@1.0.2: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hotkey@0.2.0: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + + is-number@3.0.0: + dependencies: + kind-of: 3.2.2 + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-obj@1.1.0: {} + + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + + is-plain-object@5.0.0: {} + + is-regex@1.1.4: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-shared-array-buffer@1.0.3: + dependencies: + call-bind: 1.0.7 + + is-stream@2.0.1: {} + + is-stream@3.0.0: {} + + is-string@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + + is-symbol@1.0.4: + dependencies: + has-symbols: 1.0.3 + + is-text-path@2.0.0: + dependencies: + text-extensions: 2.4.0 + + is-typed-array@1.1.13: + dependencies: + which-typed-array: 1.1.15 + + is-url@1.2.4: {} + + is-weakref@1.0.2: + dependencies: + call-bind: 1.0.7 + + is-windows@1.0.2: {} + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isobject@2.1.0: + dependencies: + isarray: 1.0.0 + + isobject@3.0.1: {} + + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jake@10.8.7: + dependencies: + async: 3.2.5 + chalk: 4.1.2 + filelist: 1.0.4 + minimatch: 3.1.2 + + jiti@1.21.0: {} + + js-base64@2.6.4: {} + + js-tokens@4.0.0: {} + + js-tokens@8.0.3: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsencrypt@3.3.2: {} + + jsesc@0.5.0: {} + + jsesc@2.5.2: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-source-map@0.6.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsonc-eslint-parser@2.4.0: + dependencies: + acorn: 8.11.3 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + semver: 7.6.0 + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonparse@1.3.1: {} + + katex@0.16.11: + dependencies: + commander: 8.3.0 + + keycode@2.2.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@3.2.2: + dependencies: + is-buffer: 1.1.6 + + kind-of@4.0.0: + dependencies: + is-buffer: 1.1.6 + + kind-of@5.1.0: {} + + kind-of@6.0.3: {} + + known-css-properties@0.30.0: {} + + kolorist@1.8.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.0.0: {} + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + lint-staged@15.2.2: + dependencies: + chalk: 5.3.0 + commander: 11.1.0 + debug: 4.3.4 + execa: 8.0.1 + lilconfig: 3.0.0 + listr2: 8.0.1 + micromatch: 4.0.5 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.3.4 + transitivePeerDependencies: + - supports-color + + listr2@8.0.1: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.0.0 + rfdc: 1.3.1 + wrap-ansi: 9.0.0 + + loader-utils@1.4.2: + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 1.0.2 + + local-pkg@0.4.3: {} + + local-pkg@0.5.0: + dependencies: + mlly: 1.6.1 + pkg-types: 1.1.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash-es@4.17.21: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.17.21 + lodash-es: 4.17.21 + + lodash.camelcase@4.3.0: {} + + lodash.clonedeep@4.5.0: {} + + lodash.debounce@4.0.8: {} + + lodash.foreach@4.5.0: {} + + lodash.isequal@4.5.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.kebabcase@4.1.1: {} + + lodash.merge@4.6.2: {} + + lodash.mergewith@4.6.2: {} + + lodash.snakecase@4.1.1: {} + + lodash.startcase@4.4.0: {} + + lodash.throttle@4.1.1: {} + + lodash.toarray@4.4.0: {} + + lodash.truncate@4.4.2: {} + + lodash.uniq@4.5.0: {} + + lodash.upperfirst@4.3.1: {} + + lodash@4.17.21: {} + + log-update@6.0.0: + dependencies: + ansi-escapes: 6.2.1 + cli-cursor: 4.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + loglevel-colored-level-prefix@1.0.0: + dependencies: + chalk: 1.1.3 + loglevel: 1.9.1 + + loglevel@1.9.1: {} + + lru-cache@10.2.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + m3u8-parser@4.8.0: + dependencies: + '@babel/runtime': 7.24.4 + '@videojs/vhs-utils': 3.0.5 + global: 4.4.0 + + magic-string@0.30.10: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + + map-cache@0.2.2: {} + + map-visit@1.0.0: + dependencies: + object-visit: 1.0.1 + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + markmap-common@0.16.0: + dependencies: + '@babel/runtime': 7.24.4 + '@gera2ld/jsx-dom': 2.2.2 + npm2url: 0.2.4 + + markmap-html-parser@0.16.1(markmap-common@0.16.0): + dependencies: + '@babel/runtime': 7.24.4 + cheerio: 1.0.0-rc.12 + markmap-common: 0.16.0 + + markmap-lib@0.16.1(markmap-common@0.16.0): + dependencies: + '@babel/runtime': 7.24.4 + highlight.js: 11.9.0 + js-yaml: 4.1.0 + katex: 0.16.11 + markmap-common: 0.16.0 + markmap-html-parser: 0.16.1(markmap-common@0.16.0) + markmap-view: 0.16.0(markmap-common@0.16.0) + prismjs: 1.29.0 + remarkable: 2.0.1 + remarkable-katex: 1.2.1 + + markmap-toolbar@0.17.0(markmap-common@0.16.0): + dependencies: + '@babel/runtime': 7.24.4 + '@gera2ld/jsx-dom': 2.2.2 + markmap-common: 0.16.0 + + markmap-view@0.16.0(markmap-common@0.16.0): + dependencies: + '@babel/runtime': 7.24.4 + '@gera2ld/jsx-dom': 2.2.2 + '@types/d3': 7.4.3 + d3: 7.9.0 + d3-flextree: 2.1.2 + markmap-common: 0.16.0 + + matches-selector@1.2.0: {} + + mathml-tag-names@2.1.3: {} + + mdn-data@2.0.14: {} + + mdn-data@2.0.30: {} + + mdurl@2.0.0: {} + + memoize-one@6.0.0: {} + + meow@12.1.1: {} + + meow@13.2.0: {} + + merge-options@1.0.1: + dependencies: + is-plain-obj: 1.1.0 + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@3.1.0: + dependencies: + arr-diff: 4.0.0 + array-unique: 0.3.2 + braces: 2.3.2 + define-property: 1.0.0 + extend-shallow: 2.0.1 + extglob: 2.0.4 + fragment-cache: 0.2.1 + kind-of: 5.1.0 + nanomatch: 1.2.13 + object.pick: 1.3.0 + regex-not: 1.0.2 + snapdragon: 0.8.2 + to-regex: 3.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.5: + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-match@1.0.2: + dependencies: + wildcard: 1.1.2 + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + min-dash@3.8.1: {} + + min-dash@4.2.1: {} + + min-document@2.19.0: + dependencies: + dom-walk: 0.1.2 + + min-dom@0.2.0: + dependencies: + component-classes: 1.2.6 + component-closest: 0.1.4 + component-delegate: 0.2.4 + component-event: 0.1.4 + component-matches-selector: 0.1.7 + component-query: 0.0.3 + domify: 1.4.2 + + min-dom@3.2.1: + dependencies: + component-event: 0.1.4 + domify: 1.4.2 + indexof: 0.0.1 + matches-selector: 1.2.0 + min-dash: 3.8.1 + + min-dom@4.1.0: + dependencies: + component-event: 0.2.1 + domify: 1.4.2 + min-dash: 4.2.1 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.1 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.1 + + minimatch@9.0.4: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: {} + + minipass@7.0.4: {} + + mitt@1.2.0: {} + + mitt@3.0.1: {} + + mixin-deep@1.3.2: + dependencies: + for-in: 1.0.2 + is-extendable: 1.0.1 + + mlly@1.6.1: + dependencies: + acorn: 8.11.3 + pathe: 1.1.2 + pkg-types: 1.1.0 + ufo: 1.5.3 + + moddle-xml@9.0.6: + dependencies: + min-dash: 3.8.1 + moddle: 5.0.4 + saxen: 8.1.2 + + moddle@5.0.4: + dependencies: + min-dash: 3.8.1 + + mpd-parser@0.22.1: + dependencies: + '@babel/runtime': 7.24.4 + '@videojs/vhs-utils': 3.0.5 + '@xmldom/xmldom': 0.8.10 + global: 4.4.0 + + mrmime@2.0.0: {} + + ms@2.0.0: {} + + ms@2.1.2: {} + + muggle-string@0.3.1: {} + + mux.js@6.0.1: + dependencies: + '@babel/runtime': 7.24.4 + global: 4.4.0 + + namespace-emitter@2.0.1: {} + + nanoid@3.3.7: {} + + nanomatch@1.2.13: + dependencies: + arr-diff: 4.0.0 + array-unique: 0.3.2 + define-property: 2.0.2 + extend-shallow: 3.0.2 + fragment-cache: 0.2.1 + is-windows: 1.0.2 + kind-of: 6.0.3 + object.pick: 1.3.0 + regex-not: 1.0.2 + snapdragon: 0.8.2 + to-regex: 3.0.2 + transitivePeerDependencies: + - supports-color + + natural-compare@1.4.0: {} + + next-tick@1.1.0: {} + + node-fetch-native@1.6.4: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-releases@2.0.14: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + normalize-wheel-es@1.2.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npm2url@0.2.4: {} + + nprogress@0.2.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-copy@0.1.0: + dependencies: + copy-descriptor: 0.1.1 + define-property: 0.2.5 + kind-of: 3.2.2 + + object-inspect@1.13.1: {} + + object-keys@1.1.1: {} + + object-refs@0.3.0: {} + + object-visit@1.0.1: + dependencies: + isobject: 3.0.1 + + object.assign@4.1.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + + object.pick@1.3.0: + dependencies: + isobject: 3.0.1 + + ofetch@1.3.4: + dependencies: + destr: 2.0.3 + node-fetch-native: 1.6.4 + ufo: 1.5.3 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.0.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-try@2.2.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.24.2 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5-htmlparser2-tree-adapter@7.0.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.1.2 + + parse5@7.1.2: + dependencies: + entities: 4.5.0 + + pascalcase@0.1.1: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-exists@5.0.0: {} + + path-intersection@2.2.1: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@1.10.2: + dependencies: + lru-cache: 10.2.2 + minipass: 7.0.4 + + path-type@4.0.0: {} + + pathe@0.2.0: {} + + pathe@1.1.2: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.0.0: {} + + picomatch@2.3.1: {} + + pidtree@0.6.0: {} + + pinia-plugin-persistedstate@3.2.1(pinia@2.1.7(typescript@5.3.3)(vue@3.4.21(typescript@5.3.3))): + dependencies: + pinia: 2.1.7(typescript@5.3.3)(vue@3.4.21(typescript@5.3.3)) + + pinia@2.1.7(typescript@5.3.3)(vue@3.4.21(typescript@5.3.3)): + dependencies: + '@vue/devtools-api': 6.6.1 + vue: 3.4.21(typescript@5.3.3) + vue-demi: 0.14.7(vue@3.4.21(typescript@5.3.3)) + optionalDependencies: + typescript: 5.3.3 + + pkcs7@1.0.4: + dependencies: + '@babel/runtime': 7.24.4 + + pkg-types@1.1.0: + dependencies: + confbox: 0.1.7 + mlly: 1.6.1 + pathe: 1.1.2 + + pngjs@5.0.0: {} + + posix-character-classes@0.1.1: {} + + possible-typed-array-names@1.0.0: {} + + postcss-html@1.6.0: + dependencies: + htmlparser2: 8.0.2 + js-tokens: 8.0.3 + postcss: 8.4.38 + postcss-safe-parser: 6.0.0(postcss@8.4.38) + + postcss-prefix-selector@1.16.1(postcss@5.2.18): + dependencies: + postcss: 5.2.18 + + postcss-resolve-nested-selector@0.1.1: {} + + postcss-safe-parser@6.0.0(postcss@8.4.38): + dependencies: + postcss: 8.4.38 + + postcss-safe-parser@7.0.0(postcss@8.4.38): + dependencies: + postcss: 8.4.38 + + postcss-scss@4.0.9(postcss@8.4.38): + dependencies: + postcss: 8.4.38 + + postcss-selector-parser@6.0.16: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-sorting@8.0.2(postcss@8.4.38): + dependencies: + postcss: 8.4.38 + + postcss-value-parser@4.2.0: {} + + postcss@5.2.18: + dependencies: + chalk: 1.1.3 + js-base64: 2.6.4 + source-map: 0.5.7 + supports-color: 3.2.3 + + postcss@8.4.38: + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.2.0 + + posthtml-parser@0.2.1: + dependencies: + htmlparser2: 3.10.1 + isobject: 2.1.0 + + posthtml-rename-id@1.0.12: + dependencies: + escape-string-regexp: 1.0.5 + + posthtml-render@1.4.0: {} + + posthtml-svg-mode@1.0.3: + dependencies: + merge-options: 1.0.1 + posthtml: 0.9.2 + posthtml-parser: 0.2.1 + posthtml-render: 1.4.0 + + posthtml@0.9.2: + dependencies: + posthtml-parser: 0.2.1 + posthtml-render: 1.4.0 + + preact@10.20.2: {} + + prelude-ls@1.2.1: {} + + prettier-eslint@16.3.0: + dependencies: + '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.3.3) + common-tags: 1.8.2 + dlv: 1.1.3 + eslint: 8.57.0 + indent-string: 4.0.0 + lodash.merge: 4.6.2 + loglevel-colored-level-prefix: 1.0.0 + prettier: 3.2.5 + pretty-format: 29.7.0 + require-relative: 0.8.7 + typescript: 5.3.3 + vue-eslint-parser: 9.4.2(eslint@8.57.0) + transitivePeerDependencies: + - supports-color + + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + + prettier@3.2.5: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + prismjs@1.29.0: {} + + process@0.11.10: {} + + progress@2.0.3: {} + + proxy-from-env@1.1.0: {} + + punycode.js@2.3.1: {} + + punycode@1.4.1: {} + + punycode@2.3.1: {} + + qrcode@1.5.3: + dependencies: + dijkstrajs: 1.0.3 + encode-utf8: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + qs@6.12.1: + dependencies: + side-channel: 1.0.6 + + query-string@4.3.4: + dependencies: + object-assign: 4.1.1 + strict-uri-encode: 1.1.0 + + queue-microtask@1.2.3: {} + + rd@2.0.1: + dependencies: + '@types/node': 10.17.60 + + react-is@18.3.1: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + regenerate-unicode-properties@10.1.1: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regenerator-runtime@0.14.1: {} + + regenerator-transform@0.15.2: + dependencies: + '@babel/runtime': 7.24.4 + + regex-not@1.0.2: + dependencies: + extend-shallow: 3.0.2 + safe-regex: 1.1.0 + + regexp.prototype.flags@1.5.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.2 + + regexpu-core@5.3.2: + dependencies: + '@babel/regjsgen': 0.8.0 + regenerate: 1.4.2 + regenerate-unicode-properties: 10.1.1 + regjsparser: 0.9.1 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.1.0 + + regjsparser@0.9.1: + dependencies: + jsesc: 0.5.0 + + remarkable-katex@1.2.1: {} + + remarkable@2.0.1: + dependencies: + argparse: 1.0.10 + autolinker: 3.16.2 + + repeat-element@1.1.4: {} + + repeat-string@1.6.1: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + + require-relative@0.8.7: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-url@0.2.1: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + ret@0.1.15: {} + + reusify@1.0.4: {} + + rfdc@1.3.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rimraf@5.0.5: + dependencies: + glob: 10.3.12 + + robust-predicates@3.0.2: {} + + rollup-plugin-purge-icons@0.10.0: + dependencies: + '@purge-icons/core': 0.10.0 + '@purge-icons/generated': 0.10.0 + transitivePeerDependencies: + - encoding + - supports-color + + rollup@2.79.1: + optionalDependencies: + fsevents: 2.3.3 + + rollup@4.17.1: + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.17.1 + '@rollup/rollup-android-arm64': 4.17.1 + '@rollup/rollup-darwin-arm64': 4.17.1 + '@rollup/rollup-darwin-x64': 4.17.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.17.1 + '@rollup/rollup-linux-arm-musleabihf': 4.17.1 + '@rollup/rollup-linux-arm64-gnu': 4.17.1 + '@rollup/rollup-linux-arm64-musl': 4.17.1 + '@rollup/rollup-linux-powerpc64le-gnu': 4.17.1 + '@rollup/rollup-linux-riscv64-gnu': 4.17.1 + '@rollup/rollup-linux-s390x-gnu': 4.17.1 + '@rollup/rollup-linux-x64-gnu': 4.17.1 + '@rollup/rollup-linux-x64-musl': 4.17.1 + '@rollup/rollup-win32-arm64-msvc': 4.17.1 + '@rollup/rollup-win32-ia32-msvc': 4.17.1 + '@rollup/rollup-win32-x64-msvc': 4.17.1 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rust-result@1.0.0: + dependencies: + individual: 2.0.0 + + rw@1.3.3: {} + + safe-array-concat@1.1.2: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-json-parse@4.0.0: + dependencies: + rust-result: 1.0.0 + + safe-regex-test@1.0.3: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-regex: 1.1.4 + + safe-regex@1.1.0: + dependencies: + ret: 0.1.15 + + safer-buffer@2.1.2: {} + + sass@1.75.0: + dependencies: + chokidar: 3.6.0 + immutable: 4.3.5 + source-map-js: 1.2.0 + + sax@1.3.0: {} + + saxen@8.1.2: {} + + scroll-into-view-if-needed@2.2.31: + dependencies: + compute-scroll-into-view: 1.0.20 + + scroll-tabs@1.0.1: + dependencies: + min-dash: 3.8.1 + min-dom: 3.2.1 + mitt: 1.2.0 + + scule@1.3.0: {} + + selection-update@0.1.2: {} + + semver@6.3.1: {} + + semver@7.6.0: + dependencies: + lru-cache: 6.0.0 + + set-blocking@2.0.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-value@2.0.1: + dependencies: + extend-shallow: 2.0.1 + is-extendable: 0.1.1 + is-plain-object: 2.0.4 + split-string: 3.1.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.1 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.25 + mrmime: 2.0.0 + totalist: 3.0.1 + + slash@3.0.0: {} + + slate-history@0.66.0(slate@0.72.8): + dependencies: + is-plain-object: 5.0.0 + slate: 0.72.8 + + slate@0.72.8: + dependencies: + immer: 9.0.21 + is-plain-object: 5.0.0 + tiny-warning: 1.0.3 + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + snabbdom@3.6.2: {} + + snapdragon-node@2.1.1: + dependencies: + define-property: 1.0.0 + isobject: 3.0.1 + snapdragon-util: 3.0.1 + + snapdragon-util@3.0.1: + dependencies: + kind-of: 3.2.2 + + snapdragon@0.8.2: + dependencies: + base: 0.11.2 + debug: 2.6.9 + define-property: 0.2.5 + extend-shallow: 2.0.1 + map-cache: 0.2.2 + source-map: 0.5.7 + source-map-resolve: 0.5.3 + use: 3.1.1 + transitivePeerDependencies: + - supports-color + + sortablejs@1.14.0: {} + + source-map-js@1.2.0: {} + + source-map-resolve@0.5.3: + dependencies: + atob: 2.1.2 + decode-uri-component: 0.2.2 + resolve-url: 0.2.1 + source-map-url: 0.4.1 + urix: 0.1.0 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-url@0.4.1: {} + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + split-string@3.1.0: + dependencies: + extend-shallow: 3.0.2 + + split2@4.2.0: {} + + sprintf-js@1.0.3: {} + + ssr-window@3.0.0: {} + + stable@0.1.8: {} + + static-extend@0.1.2: + dependencies: + define-property: 0.2.5 + object-copy: 0.1.0 + + steady-xml@0.1.0: {} + + strict-uri-encode@1.1.0: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.1.0: + dependencies: + emoji-regex: 10.3.0 + get-east-asian-width: 1.2.0 + strip-ansi: 7.1.0 + + string.prototype.trim@1.2.9: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + + string.prototype.trimend@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@3.0.1: + dependencies: + ansi-regex: 2.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.0.1 + + strip-final-newline@2.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-json-comments@3.1.1: {} + + strip-literal@1.3.0: + dependencies: + acorn: 8.11.3 + + strnum@1.0.5: {} + + stylelint-config-html@1.1.0(postcss-html@1.6.0)(stylelint@16.4.0(typescript@5.3.3)): + dependencies: + postcss-html: 1.6.0 + stylelint: 16.4.0(typescript@5.3.3) + + stylelint-config-recommended@14.0.0(stylelint@16.4.0(typescript@5.3.3)): + dependencies: + stylelint: 16.4.0(typescript@5.3.3) + + stylelint-config-standard@36.0.0(stylelint@16.4.0(typescript@5.3.3)): + dependencies: + stylelint: 16.4.0(typescript@5.3.3) + stylelint-config-recommended: 14.0.0(stylelint@16.4.0(typescript@5.3.3)) + + stylelint-order@6.0.4(stylelint@16.4.0(typescript@5.3.3)): + dependencies: + postcss: 8.4.38 + postcss-sorting: 8.0.2(postcss@8.4.38) + stylelint: 16.4.0(typescript@5.3.3) + + stylelint@16.4.0(typescript@5.3.3): + dependencies: + '@csstools/css-parser-algorithms': 2.6.1(@csstools/css-tokenizer@2.2.4) + '@csstools/css-tokenizer': 2.2.4 + '@csstools/media-query-list-parser': 2.1.9(@csstools/css-parser-algorithms@2.6.1(@csstools/css-tokenizer@2.2.4))(@csstools/css-tokenizer@2.2.4) + '@csstools/selector-specificity': 3.0.3(postcss-selector-parser@6.0.16) + '@dual-bundle/import-meta-resolve': 4.0.0 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.0(typescript@5.3.3) + css-functions-list: 3.2.2 + css-tree: 2.3.1 + debug: 4.3.4 + fast-glob: 3.3.2 + fastest-levenshtein: 1.0.16 + file-entry-cache: 8.0.0 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 5.3.1 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.30.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.38 + postcss-resolve-nested-selector: 0.1.1 + postcss-safe-parser: 7.0.0(postcss@8.4.38) + postcss-selector-parser: 6.0.16 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + strip-ansi: 7.1.0 + supports-hyperlinks: 3.0.0 + svg-tags: 1.0.0 + table: 6.8.2 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@2.0.0: {} + + supports-color@3.2.3: + dependencies: + has-flag: 1.0.0 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.0.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-baker@1.7.0: + dependencies: + bluebird: 3.7.2 + clone: 2.1.2 + he: 1.2.0 + image-size: 0.5.5 + loader-utils: 1.4.2 + merge-options: 1.0.1 + micromatch: 3.1.0 + postcss: 5.2.18 + postcss-prefix-selector: 1.16.1(postcss@5.2.18) + posthtml-rename-id: 1.0.12 + posthtml-svg-mode: 1.0.3 + query-string: 4.3.4 + traverse: 0.6.9 + transitivePeerDependencies: + - supports-color + + svg-tags@1.0.0: {} + + svg.js@2.7.1: {} + + svgo@2.8.0: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 4.3.0 + css-tree: 1.1.3 + csso: 4.2.0 + picocolors: 1.0.0 + stable: 0.1.8 + + synckit@0.8.8: + dependencies: + '@pkgr/core': 0.1.1 + tslib: 2.6.2 + + systemjs@6.15.1: {} + + table@6.8.2: + dependencies: + ajv: 8.12.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + terser@5.30.4: + dependencies: + '@jridgewell/source-map': 0.3.6 + acorn: 8.11.3 + commander: 2.20.3 + source-map-support: 0.5.21 + + text-extensions@2.4.0: {} + + text-table@0.2.0: {} + + through@2.3.8: {} + + tiny-svg@2.2.4: {} + + tiny-svg@3.0.1: {} + + tiny-warning@1.0.3: {} + + to-fast-properties@2.0.0: {} + + to-object-path@0.3.0: + dependencies: + kind-of: 3.2.2 + + to-regex-range@2.1.1: + dependencies: + is-number: 3.0.0 + repeat-string: 1.6.1 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + to-regex@3.0.2: + dependencies: + define-property: 2.0.2 + extend-shallow: 3.0.2 + regex-not: 1.0.2 + safe-regex: 1.1.0 + + totalist@3.0.1: {} + + tr46@0.0.3: {} + + traverse@0.6.9: + dependencies: + gopd: 1.0.1 + typedarray.prototype.slice: 1.0.3 + which-typed-array: 1.1.15 + + ts-api-utils@1.3.0(typescript@5.3.3): + dependencies: + typescript: 5.3.3 + + tslib@2.3.0: {} + + tslib@2.6.2: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type@2.7.2: {} + + typed-array-buffer@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + + typed-array-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-byte-offset@1.0.2: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-length@1.0.6: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + + typedarray.prototype.slice@1.0.3: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + typed-array-buffer: 1.0.2 + typed-array-byte-offset: 1.0.2 + + typescript@5.3.3: {} + + uc.micro@2.1.0: {} + + ufo@1.5.3: {} + + unbox-primitive@1.0.2: + dependencies: + call-bind: 1.0.7 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + + unconfig@0.3.13: + dependencies: + '@antfu/utils': 0.7.7 + defu: 6.1.4 + jiti: 1.21.0 + + undici-types@5.26.5: {} + + unicode-canonical-property-names-ecmascript@2.0.0: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.0 + unicode-property-aliases-ecmascript: 2.1.0 + + unicode-match-property-value-ecmascript@2.1.0: {} + + unicode-property-aliases-ecmascript@2.1.0: {} + + unicorn-magic@0.1.0: {} + + unimport@3.7.1(rollup@4.17.1): + dependencies: + '@rollup/pluginutils': 5.1.0(rollup@4.17.1) + acorn: 8.11.3 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + fast-glob: 3.3.2 + local-pkg: 0.5.0 + magic-string: 0.30.10 + mlly: 1.6.1 + pathe: 1.1.2 + pkg-types: 1.1.0 + scule: 1.3.0 + strip-literal: 1.3.0 + unplugin: 1.10.1 + transitivePeerDependencies: + - rollup + + union-value@1.0.1: + dependencies: + arr-union: 3.1.0 + get-value: 2.0.6 + is-extendable: 0.1.1 + set-value: 2.0.1 + + universalify@2.0.1: {} + + unocss@0.58.9(postcss@8.4.38)(rollup@4.17.1)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)): + dependencies: + '@unocss/astro': 0.58.9(rollup@4.17.1)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + '@unocss/cli': 0.58.9(rollup@4.17.1) + '@unocss/core': 0.58.9 + '@unocss/extractor-arbitrary-variants': 0.58.9 + '@unocss/postcss': 0.58.9(postcss@8.4.38) + '@unocss/preset-attributify': 0.58.9 + '@unocss/preset-icons': 0.58.9 + '@unocss/preset-mini': 0.58.9 + '@unocss/preset-tagify': 0.58.9 + '@unocss/preset-typography': 0.58.9 + '@unocss/preset-uno': 0.58.9 + '@unocss/preset-web-fonts': 0.58.9 + '@unocss/preset-wind': 0.58.9 + '@unocss/reset': 0.58.9 + '@unocss/transformer-attributify-jsx': 0.58.9 + '@unocss/transformer-attributify-jsx-babel': 0.58.9 + '@unocss/transformer-compile-class': 0.58.9 + '@unocss/transformer-directives': 0.58.9 + '@unocss/transformer-variant-group': 0.58.9 + '@unocss/vite': 0.58.9(rollup@4.17.1)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)) + optionalDependencies: + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + transitivePeerDependencies: + - postcss + - rollup + - supports-color + + unplugin-auto-import@0.16.7(@vueuse/core@10.9.0(vue@3.4.21(typescript@5.3.3)))(rollup@4.17.1): + dependencies: + '@antfu/utils': 0.7.7 + '@rollup/pluginutils': 5.1.0(rollup@4.17.1) + fast-glob: 3.3.2 + local-pkg: 0.5.0 + magic-string: 0.30.10 + minimatch: 9.0.4 + unimport: 3.7.1(rollup@4.17.1) + unplugin: 1.10.1 + optionalDependencies: + '@vueuse/core': 10.9.0(vue@3.4.21(typescript@5.3.3)) + transitivePeerDependencies: + - rollup + + unplugin-element-plus@0.8.0(rollup@4.17.1): + dependencies: + '@rollup/pluginutils': 5.1.0(rollup@4.17.1) + es-module-lexer: 1.5.2 + magic-string: 0.30.10 + unplugin: 1.10.1 + transitivePeerDependencies: + - rollup + + unplugin-vue-components@0.25.2(@babel/parser@7.24.4)(rollup@4.17.1)(vue@3.4.21(typescript@5.3.3)): + dependencies: + '@antfu/utils': 0.7.7 + '@rollup/pluginutils': 5.1.0(rollup@4.17.1) + chokidar: 3.6.0 + debug: 4.3.4 + fast-glob: 3.3.2 + local-pkg: 0.4.3 + magic-string: 0.30.10 + minimatch: 9.0.4 + resolve: 1.22.8 + unplugin: 1.10.1 + vue: 3.4.21(typescript@5.3.3) + optionalDependencies: + '@babel/parser': 7.24.4 + transitivePeerDependencies: + - rollup + - supports-color + + unplugin@1.10.1: + dependencies: + acorn: 8.11.3 + chokidar: 3.6.0 + webpack-sources: 3.2.3 + webpack-virtual-modules: 0.6.1 + + unset-value@1.0.0: + dependencies: + has-value: 0.3.1 + isobject: 3.0.1 + + update-browserslist-db@1.0.13(browserslist@4.23.0): + dependencies: + browserslist: 4.23.0 + escalade: 3.1.2 + picocolors: 1.0.0 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + urix@0.1.0: {} + + url-toolkit@2.2.5: {} + + url@0.11.3: + dependencies: + punycode: 1.4.1 + qs: 6.12.1 + + use@3.1.1: {} + + util-deprecate@1.0.2: {} + + uuid@9.0.1: {} + + vary@1.1.2: {} + + video.js@7.21.5: + dependencies: + '@babel/runtime': 7.24.4 + '@videojs/http-streaming': 2.16.2(video.js@7.21.5) + '@videojs/vhs-utils': 3.0.5 + '@videojs/xhr': 2.6.0 + aes-decrypter: 3.1.3 + global: 4.4.0 + keycode: 2.2.1 + m3u8-parser: 4.8.0 + mpd-parser: 0.22.1 + mux.js: 6.0.1 + safe-json-parse: 4.0.0 + videojs-font: 3.2.0 + videojs-vtt.js: 0.15.5 + + videojs-font@3.2.0: {} + + videojs-vtt.js@0.15.5: + dependencies: + global: 4.4.0 + + vite-plugin-compression@0.5.1(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)): + dependencies: + chalk: 4.1.2 + debug: 4.3.4 + fs-extra: 10.1.0 + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + transitivePeerDependencies: + - supports-color + + vite-plugin-ejs@1.7.0(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)): + dependencies: + ejs: 3.1.10 + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + + vite-plugin-eslint@1.8.1(eslint@8.57.0)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)): + dependencies: + '@rollup/pluginutils': 4.2.1 + '@types/eslint': 8.56.10 + eslint: 8.57.0 + rollup: 2.79.1 + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + + vite-plugin-progress@0.0.7(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)): + dependencies: + picocolors: 1.0.0 + progress: 2.0.3 + rd: 2.0.1 + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + + vite-plugin-purge-icons@0.10.0(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)): + dependencies: + '@purge-icons/core': 0.10.0 + '@purge-icons/generated': 0.10.0 + rollup-plugin-purge-icons: 0.10.0 + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + transitivePeerDependencies: + - encoding + - supports-color + + vite-plugin-svg-icons@2.0.1(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)): + dependencies: + '@types/svgo': 2.6.4 + cors: 2.8.5 + debug: 4.3.4 + etag: 1.8.1 + fs-extra: 10.1.0 + pathe: 0.2.0 + svg-baker: 1.7.0 + svgo: 2.8.0 + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + transitivePeerDependencies: + - supports-color + + vite-plugin-top-level-await@1.4.1(rollup@4.17.1)(vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4)): + dependencies: + '@rollup/plugin-virtual': 3.0.2(rollup@4.17.1) + '@swc/core': 1.4.17 + uuid: 9.0.1 + vite: 5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4) + transitivePeerDependencies: + - '@swc/helpers' + - rollup + + vite@5.1.4(@types/node@20.12.7)(sass@1.75.0)(terser@5.30.4): + dependencies: + esbuild: 0.19.12 + postcss: 8.4.38 + rollup: 4.17.1 + optionalDependencies: + '@types/node': 20.12.7 + fsevents: 2.3.3 + sass: 1.75.0 + terser: 5.30.4 + + vue-demi@0.14.7(vue@3.4.21(typescript@5.3.3)): + dependencies: + vue: 3.4.21(typescript@5.3.3) + + vue-dompurify-html@4.1.4(vue@3.4.21(typescript@5.3.3)): + dependencies: + dompurify: 3.1.1 + vue: 3.4.21(typescript@5.3.3) + vue-demi: 0.14.7(vue@3.4.21(typescript@5.3.3)) + transitivePeerDependencies: + - '@vue/composition-api' + + vue-eslint-parser@9.4.2(eslint@8.57.0): + dependencies: + debug: 4.3.4 + eslint: 8.57.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.5.0 + lodash: 4.17.21 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color + + vue-i18n@9.10.2(vue@3.4.21(typescript@5.3.3)): + dependencies: + '@intlify/core-base': 9.10.2 + '@intlify/shared': 9.10.2 + '@vue/devtools-api': 6.6.1 + vue: 3.4.21(typescript@5.3.3) + + vue-router@4.3.2(vue@3.4.21(typescript@5.3.3)): + dependencies: + '@vue/devtools-api': 6.6.1 + vue: 3.4.21(typescript@5.3.3) + + vue-template-compiler@2.7.16: + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + vue-tsc@1.8.27(typescript@5.3.3): + dependencies: + '@volar/typescript': 1.11.1 + '@vue/language-core': 1.8.27(typescript@5.3.3) + semver: 7.6.0 + typescript: 5.3.3 + + vue-types@5.1.1(vue@3.4.21(typescript@5.3.3)): + dependencies: + is-plain-object: 5.0.0 + optionalDependencies: + vue: 3.4.21(typescript@5.3.3) + + vue@3.4.21(typescript@5.3.3): + dependencies: + '@vue/compiler-dom': 3.4.21 + '@vue/compiler-sfc': 3.4.21 + '@vue/runtime-dom': 3.4.21 + '@vue/server-renderer': 3.4.21(vue@3.4.21(typescript@5.3.3)) + '@vue/shared': 3.4.21 + optionalDependencies: + typescript: 5.3.3 + + vuedraggable@4.1.0(vue@3.4.21(typescript@5.3.3)): + dependencies: + sortablejs: 1.14.0 + vue: 3.4.21(typescript@5.3.3) + + wangeditor@4.7.15: + dependencies: + '@babel/runtime': 7.24.4 + '@babel/runtime-corejs3': 7.24.4 + tslib: 2.6.2 + + web-storage-cache@1.1.1: {} + + webidl-conversions@3.0.1: {} + + webpack-sources@3.2.3: {} + + webpack-virtual-modules@0.6.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-boxed-primitive@1.0.2: + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + + which-module@2.0.1: {} + + which-typed-array@1.1.15: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wildcard@1.1.2: {} + + word-wrap@1.2.5: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.1.0 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + xml-js@1.6.11: + dependencies: + sax: 1.3.0 + + xml-name-validator@4.0.0: {} + + y18n@4.0.3: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yaml-eslint-parser@1.2.2: + dependencies: + eslint-visitor-keys: 3.4.3 + lodash: 4.17.21 + yaml: 2.4.2 + + yaml@2.3.4: {} + + yaml@2.4.2: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs-parser@21.1.1: {} + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.1.2 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yocto-queue@1.0.0: {} + + zrender@5.5.0: + dependencies: + tslib: 2.3.0 diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..961986e --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + autoprefixer: {} + } +} diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 0000000..b014bbf --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,22 @@ +module.exports = { + printWidth: 100, // 每行代码长度(默认80) + tabWidth: 2, // 每个tab相当于多少个空格(默认2)ab进行缩进(默认false) + useTabs: false, // 是否使用tab + semi: false, // 声明结尾使用分号(默认true) + vueIndentScriptAndStyle: false, + singleQuote: true, // 使用单引号(默认false) + quoteProps: 'as-needed', + bracketSpacing: true, // 对象字面量的大括号间使用空格(默认true) + trailingComma: 'none', // 多行使用拖尾逗号(默认none) + jsxSingleQuote: false, + // 箭头函数参数括号 默认avoid 可选 avoid| always + // avoid 能省略括号的时候就省略 例如x => x + // always 总是有括号 + arrowParens: 'always', + insertPragma: false, + requirePragma: false, + proseWrap: 'never', + htmlWhitespaceSensitivity: 'strict', + endOfLine: 'auto', + rangeStart: 0 +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..5a7de08 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/home.png b/public/home.png new file mode 100644 index 0000000..ccd4145 Binary files /dev/null and b/public/home.png differ diff --git a/public/logo.gif b/public/logo.gif new file mode 100644 index 0000000..fdbd32c Binary files /dev/null and b/public/logo.gif differ diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..1f5f36d --- /dev/null +++ b/src/App.vue @@ -0,0 +1,61 @@ +<script lang="ts" setup> +import { isDark } from '@/utils/is' +import { useAppStore } from '@/store/modules/app' +import { useDesign } from '@/hooks/web/useDesign' +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' +import routerSearch from '@/components/RouterSearch/index.vue' + +defineOptions({ name: 'APP' }) + +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('app') +const appStore = useAppStore() +const currentSize = computed(() => appStore.getCurrentSize) +const greyMode = computed(() => appStore.getGreyMode) +const { wsCache } = useCache() + +// 根据浏览器当前主题设置系统主题色 +const setDefaultTheme = () => { + let isDarkTheme = wsCache.get(CACHE_KEY.IS_DARK) + if (isDarkTheme === null) { + isDarkTheme = isDark() + } + appStore.setIsDark(isDarkTheme) +} +setDefaultTheme() +</script> +<template> + <ConfigGlobal :size="currentSize"> + <RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" /> + <routerSearch /> + </ConfigGlobal> +</template> +<style lang="scss"> +$prefix-cls: #{$namespace}-app; + +.size { + width: 100%; + height: 100%; +} + +html, +body { + @extend .size; + + padding: 0 !important; + margin: 0; + overflow: hidden; + + #app { + @extend .size; + } +} + +.#{$prefix-cls}-grey-mode { + filter: grayscale(100%); +} + +.scrollbar__view { + height: 99%!important; +} +</style> diff --git a/src/api/ai/chat/conversation/index.ts b/src/api/ai/chat/conversation/index.ts new file mode 100644 index 0000000..6ce4482 --- /dev/null +++ b/src/api/ai/chat/conversation/index.ts @@ -0,0 +1,65 @@ +import request from '@/config/axios' + +// AI 聊天对话 VO +export interface ChatConversationVO { + id: number // ID 编号 + userId: number // 用户编号 + title: string // 对话标题 + pinned: boolean // 是否置顶 + roleId: number // 角色编号 + modelId: number // 模型编号 + model: string // 模型标志 + temperature: number // 温度参数 + maxTokens: number // 单条回复的最大 Token 数量 + maxContexts: number // 上下文的最大 Message 数量 + createTime?: Date // 创建时间 + // 额外字段 + systemMessage?: string // 角色设定 + modelName?: string // 模型名字 + roleAvatar?: string // 角色头像 + modelMaxTokens?: string // 模型的单条回复的最大 Token 数量 + modelMaxContexts?: string // 模型的上下文的最大 Message 数量 +} + +// AI 聊天对话 API +export const ChatConversationApi = { + // 获得【我的】聊天对话 + getChatConversationMy: async (id: number) => { + return await request.get({ url: `/ai/chat/conversation/get-my?id=${id}` }) + }, + + // 新增【我的】聊天对话 + createChatConversationMy: async (data?: ChatConversationVO) => { + return await request.post({ url: `/ai/chat/conversation/create-my`, data }) + }, + + // 更新【我的】聊天对话 + updateChatConversationMy: async (data: ChatConversationVO) => { + return await request.put({ url: `/ai/chat/conversation/update-my`, data }) + }, + + // 删除【我的】聊天对话 + deleteChatConversationMy: async (id: string) => { + return await request.delete({ url: `/ai/chat/conversation/delete-my?id=${id}` }) + }, + + // 删除【我的】所有对话,置顶除外 + deleteChatConversationMyByUnpinned: async () => { + return await request.delete({ url: `/ai/chat/conversation/delete-by-unpinned` }) + }, + + // 获得【我的】聊天对话列表 + getChatConversationMyList: async () => { + return await request.get({ url: `/ai/chat/conversation/my-list` }) + }, + + // 获得对话分页 + getChatConversationPage: async (params: any) => { + return await request.get({ url: `/ai/chat/conversation/page`, params }) + }, + + // 管理员删除消息 + deleteChatConversationByAdmin: async (id: number) => { + return await request.delete({ url: `/ai/chat/conversation/delete-by-admin?id=${id}` }) + } +} diff --git a/src/api/ai/chat/message/index.ts b/src/api/ai/chat/message/index.ts new file mode 100644 index 0000000..ef1196a --- /dev/null +++ b/src/api/ai/chat/message/index.ts @@ -0,0 +1,83 @@ +import request from '@/config/axios' +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { getAccessToken } from '@/utils/auth' +import { config } from '@/config/axios/config' + +// 聊天VO +export interface ChatMessageVO { + id: number // 编号 + conversationId: number // 对话编号 + type: string // 消息类型 + userId: string // 用户编号 + roleId: string // 角色编号 + model: number // 模型标志 + modelId: number // 模型编号 + content: string // 聊天内容 + tokens: number // 消耗 Token 数量 + createTime: Date // 创建时间 + roleAvatar: string // 角色头像 + userAvatar: string // 创建时间 +} + +// AI chat 聊天 +export const ChatMessageApi = { + // 消息列表 + getChatMessageListByConversationId: async (conversationId: number | null) => { + return await request.get({ + url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}` + }) + }, + + // 发送 Stream 消息 + // 为什么不用 axios 呢?因为它不支持 SSE 调用 + sendChatMessageStream: async ( + conversationId: number, + content: string, + ctrl, + enableContext: boolean, + onMessage, + onError, + onClose + ) => { + const token = getAccessToken() + return fetchEventSource(`${config.base_url}/ai/chat/message/send-stream`, { + method: 'post', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + openWhenHidden: true, + body: JSON.stringify({ + conversationId, + content, + useContext: enableContext + }), + onmessage: onMessage, + onerror: onError, + onclose: onClose, + signal: ctrl.signal + }) + }, + + // 删除消息 + deleteChatMessage: async (id: string) => { + return await request.delete({ url: `/ai/chat/message/delete?id=${id}` }) + }, + + // 删除指定对话的消息 + deleteByConversationId: async (conversationId: number) => { + return await request.delete({ + url: `/ai/chat/message/delete-by-conversation-id?conversationId=${conversationId}` + }) + }, + + // 获得消息分页 + getChatMessagePage: async (params: any) => { + return await request.get({ url: '/ai/chat/message/page', params }) + }, + + // 管理员删除消息 + deleteChatMessageByAdmin: async (id: number) => { + return await request.delete({ url: `/ai/chat/message/delete-by-admin?id=${id}` }) + } +} diff --git a/src/api/ai/image/index.ts b/src/api/ai/image/index.ts new file mode 100644 index 0000000..2f276c7 --- /dev/null +++ b/src/api/ai/image/index.ts @@ -0,0 +1,103 @@ +import request from '@/config/axios' + +// AI 绘图 VO +export interface ImageVO { + id: number // 编号 + platform: string // 平台 + model: string // 模型 + prompt: string // 提示词 + width: number // 图片宽度 + height: number // 图片高度 + status: number // 状态 + publicStatus: boolean // 公开状态 + picUrl: string // 任务地址 + errorMessage: string // 错误信息 + options: any // 配置 Map<string, string> + taskId: number // 任务编号 + buttons: ImageMidjourneyButtonsVO[] // mj 操作按钮 + createTime: Date // 创建时间 + finishTime: Date // 完成时间 +} + +export interface ImageDrawReqVO { + platform: string // 平台 + prompt: string // 提示词 + model: string // 模型 + style: string // 图像生成的风格 + width: string // 图片宽度 + height: string // 图片高度 + options: object // 绘制参数,Map<String, String> +} + +export interface ImageMidjourneyImagineReqVO { + prompt: string // 提示词 + model: string // 模型 mj nijj + base64Array: string[] // size不能为空 + width: string // 图片宽度 + height: string // 图片高度 + version: string // 版本 +} + +export interface ImageMidjourneyActionVO { + id: number // 图片编号 + customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识 +} + +export interface ImageMidjourneyButtonsVO { + customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识 + emoji: string // 图标 emoji + label: string // Make Variations 文本 + style: number // 样式: 2(Primary)、3(Green) +} + +// AI 图片 API +export const ImageApi = { + // 获取【我的】绘图分页 + getImagePageMy: async (params: any) => { + return await request.get({ url: `/ai/image/my-page`, params }) + }, + // 获取【我的】绘图记录 + getImageMy: async (id: number) => { + return await request.get({ url: `/ai/image/get-my?id=${id}` }) + }, + // 获取【我的】绘图记录列表 + getImageListMyByIds: async (ids: number[]) => { + return await request.get({ url: `/ai/image/my-list-by-ids`, params: { ids: ids.join(',') } }) + }, + // 生成图片 + drawImage: async (data: ImageDrawReqVO) => { + return await request.post({ url: `/ai/image/draw`, data }) + }, + // 删除【我的】绘画记录 + deleteImageMy: async (id: number) => { + return await request.delete({ url: `/ai/image/delete-my?id=${id}` }) + }, + + // ================ midjourney 专属 ================ + + // 【Midjourney】生成图片 + midjourneyImagine: async (data: ImageMidjourneyImagineReqVO) => { + return await request.post({ url: `/ai/image/midjourney/imagine`, data }) + }, + // 【Midjourney】Action 操作(二次生成图片) + midjourneyAction: async (data: ImageMidjourneyActionVO) => { + return await request.post({ url: `/ai/image/midjourney/action`, data }) + }, + + // ================ 绘图管理 ================ + + // 查询绘画分页 + getImagePage: async (params: any) => { + return await request.get({ url: `/ai/image/page`, params }) + }, + + // 更新绘画发布状态 + updateImage: async (data: any) => { + return await request.put({ url: '/ai/image/update', data }) + }, + + // 删除绘画 + deleteImage: async (id: number) => { + return await request.delete({ url: `/ai/image/delete?id=` + id }) + } +} diff --git a/src/api/ai/mindmap/index.ts b/src/api/ai/mindmap/index.ts new file mode 100644 index 0000000..1b784fa --- /dev/null +++ b/src/api/ai/mindmap/index.ts @@ -0,0 +1,60 @@ +import { getAccessToken } from '@/utils/auth' +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { config } from '@/config/axios/config' +import request from '@/config/axios' + +// AI 思维导图 VO +export interface MindMapVO { + id: number // 编号 + userId: number // 用户编号 + prompt: string // 生成内容提示 + generatedContent: string // 生成的思维导图内容 + platform: string // 平台 + model: string // 模型 + errorMessage: string // 错误信息 +} + +// AI 思维导图生成 VO +export interface AiMindMapGenerateReqVO { + prompt: string +} + +export const AiMindMapApi = { + generateMindMap: ({ + data, + onClose, + onMessage, + onError, + ctrl + }: { + data: AiMindMapGenerateReqVO + onMessage?: (res: any) => void + onError?: (...args: any[]) => void + onClose?: (...args: any[]) => void + ctrl: AbortController + }) => { + const token = getAccessToken() + return fetchEventSource(`${config.base_url}/ai/mind-map/generate-stream`, { + method: 'post', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + openWhenHidden: true, + body: JSON.stringify(data), + onmessage: onMessage, + onerror: onError, + onclose: onClose, + signal: ctrl.signal + }) + }, + + // 查询思维导图分页 + getMindMapPage: async (params: any) => { + return await request.get({ url: `/ai/mind-map/page`, params }) + }, + // 删除思维导图 + deleteMindMap: async (id: number) => { + return await request.delete({ url: `/ai/mind-map/delete?id=` + id }) + } +} diff --git a/src/api/ai/model/apiKey/index.ts b/src/api/ai/model/apiKey/index.ts new file mode 100644 index 0000000..ed94836 --- /dev/null +++ b/src/api/ai/model/apiKey/index.ts @@ -0,0 +1,44 @@ +import request from '@/config/axios' + +// AI API 密钥 VO +export interface ApiKeyVO { + id: number // 编号 + name: string // 名称 + apiKey: string // 密钥 + platform: string // 平台 + url: string // 自定义 API 地址 + status: number // 状态 +} + +// AI API 密钥 API +export const ApiKeyApi = { + // 查询 API 密钥分页 + getApiKeyPage: async (params: any) => { + return await request.get({ url: `/ai/api-key/page`, params }) + }, + + // 获得 API 密钥列表 + getApiKeySimpleList: async () => { + return await request.get({ url: `/ai/api-key/simple-list` }) + }, + + // 查询 API 密钥详情 + getApiKey: async (id: number) => { + return await request.get({ url: `/ai/api-key/get?id=` + id }) + }, + + // 新增 API 密钥 + createApiKey: async (data: ApiKeyVO) => { + return await request.post({ url: `/ai/api-key/create`, data }) + }, + + // 修改 API 密钥 + updateApiKey: async (data: ApiKeyVO) => { + return await request.put({ url: `/ai/api-key/update`, data }) + }, + + // 删除 API 密钥 + deleteApiKey: async (id: number) => { + return await request.delete({ url: `/ai/api-key/delete?id=` + id }) + } +} diff --git a/src/api/ai/model/chatModel/index.ts b/src/api/ai/model/chatModel/index.ts new file mode 100644 index 0000000..c2ef4c8 --- /dev/null +++ b/src/api/ai/model/chatModel/index.ts @@ -0,0 +1,53 @@ +import request from '@/config/axios' + +// AI 聊天模型 VO +export interface ChatModelVO { + id: number // 编号 + keyId: number // API 秘钥编号 + name: string // 模型名字 + model: string // 模型标识 + platform: string // 模型平台 + sort: number // 排序 + status: number // 状态 + temperature: number // 温度参数 + maxTokens: number // 单条回复的最大 Token 数量 + maxContexts: number // 上下文的最大 Message 数量 +} + +// AI 聊天模型 API +export const ChatModelApi = { + // 查询聊天模型分页 + getChatModelPage: async (params: any) => { + return await request.get({ url: `/ai/chat-model/page`, params }) + }, + + // 获得聊天模型列表 + getChatModelSimpleList: async (status?: number) => { + return await request.get({ + url: `/ai/chat-model/simple-list`, + params: { + status + } + }) + }, + + // 查询聊天模型详情 + getChatModel: async (id: number) => { + return await request.get({ url: `/ai/chat-model/get?id=` + id }) + }, + + // 新增聊天模型 + createChatModel: async (data: ChatModelVO) => { + return await request.post({ url: `/ai/chat-model/create`, data }) + }, + + // 修改聊天模型 + updateChatModel: async (data: ChatModelVO) => { + return await request.put({ url: `/ai/chat-model/update`, data }) + }, + + // 删除聊天模型 + deleteChatModel: async (id: number) => { + return await request.delete({ url: `/ai/chat-model/delete?id=` + id }) + } +} diff --git a/src/api/ai/model/chatRole/index.ts b/src/api/ai/model/chatRole/index.ts new file mode 100644 index 0000000..a9fce13 --- /dev/null +++ b/src/api/ai/model/chatRole/index.ts @@ -0,0 +1,80 @@ +import request from '@/config/axios' + +// AI 聊天角色 VO +export interface ChatRoleVO { + id: number // 角色编号 + modelId: number // 模型编号 + name: string // 角色名称 + avatar: string // 角色头像 + category: string // 角色类别 + sort: number // 角色排序 + description: string // 角色描述 + systemMessage: string // 角色设定 + welcomeMessage: string // 角色设定 + publicStatus: boolean // 是否公开 + status: number // 状态 +} + +// AI 聊天角色 分页请求 vo +export interface ChatRolePageReqVO { + name?: string // 角色名称 + category?: string // 角色类别 + publicStatus: boolean // 是否公开 + pageNo: number // 是否公开 + pageSize: number // 是否公开 +} + +// AI 聊天角色 API +export const ChatRoleApi = { + // 查询聊天角色分页 + getChatRolePage: async (params: any) => { + return await request.get({ url: `/ai/chat-role/page`, params }) + }, + + // 查询聊天角色详情 + getChatRole: async (id: number) => { + return await request.get({ url: `/ai/chat-role/get?id=` + id }) + }, + + // 新增聊天角色 + createChatRole: async (data: ChatRoleVO) => { + return await request.post({ url: `/ai/chat-role/create`, data }) + }, + + // 修改聊天角色 + updateChatRole: async (data: ChatRoleVO) => { + return await request.put({ url: `/ai/chat-role/update`, data }) + }, + + // 删除聊天角色 + deleteChatRole: async (id: number) => { + return await request.delete({ url: `/ai/chat-role/delete?id=` + id }) + }, + + // ======= chat 聊天 + + // 获取 my role + getMyPage: async (params: ChatRolePageReqVO) => { + return await request.get({ url: `/ai/chat-role/my-page`, params}) + }, + + // 获取角色分类 + getCategoryList: async () => { + return await request.get({ url: `/ai/chat-role/category-list`}) + }, + + // 创建角色 + createMy: async (data: ChatRoleVO) => { + return await request.post({ url: `/ai/chat-role/create-my`, data}) + }, + + // 更新角色 + updateMy: async (data: ChatRoleVO) => { + return await request.put({ url: `/ai/chat-role/update-my`, data}) + }, + + // 删除角色 my + deleteMy: async (id: number) => { + return await request.delete({ url: `/ai/chat-role/delete-my?id=` + id }) + }, +} diff --git a/src/api/ai/music/index.ts b/src/api/ai/music/index.ts new file mode 100644 index 0000000..74b8526 --- /dev/null +++ b/src/api/ai/music/index.ts @@ -0,0 +1,41 @@ +import request from '@/config/axios' + +// AI 音乐 VO +export interface MusicVO { + id: number // 编号 + userId: number // 用户编号 + title: string // 音乐名称 + lyric: string // 歌词 + imageUrl: string // 图片地址 + audioUrl: string // 音频地址 + videoUrl: string // 视频地址 + status: number // 音乐状态 + gptDescriptionPrompt: string // 描述词 + prompt: string // 提示词 + platform: string // 模型平台 + model: string // 模型 + generateMode: number // 生成模式 + tags: string // 音乐风格标签 + duration: number // 音乐时长 + publicStatus: boolean // 是否发布 + taskId: string // 任务id + errorMessage: string // 错误信息 +} + +// AI 音乐 API +export const MusicApi = { + // 查询音乐分页 + getMusicPage: async (params: any) => { + return await request.get({ url: `/ai/music/page`, params }) + }, + + // 更新音乐 + updateMusic: async (data: any) => { + return await request.put({ url: '/ai/music/update', data }) + }, + + // 删除音乐 + deleteMusic: async (id: number) => { + return await request.delete({ url: `/ai/music/delete?id=` + id }) + } +} diff --git a/src/api/ai/write/index.ts b/src/api/ai/write/index.ts new file mode 100644 index 0000000..013f998 --- /dev/null +++ b/src/api/ai/write/index.ts @@ -0,0 +1,85 @@ +import { fetchEventSource } from '@microsoft/fetch-event-source' + +import { getAccessToken } from '@/utils/auth' +import { config } from '@/config/axios/config' +import { AiWriteTypeEnum } from '@/views/ai/utils/constants' +import request from '@/config/axios' + +export interface WriteVO { + type: AiWriteTypeEnum.WRITING | AiWriteTypeEnum.REPLY // 1:撰写 2:回复 + prompt: string // 写作内容提示 1。撰写 2回复 + originalContent: string // 原文 + length: number // 长度 + format: number // 格式 + tone: number // 语气 + language: number // 语言 + userId?: number // 用户编号 + platform?: string // 平台 + model?: string // 模型 + generatedContent?: string // 生成的内容 + errorMessage?: string // 错误信息 + createTime?: Date // 创建时间 +} + +export interface AiWritePageReqVO extends PageParam { + userId?: number // 用户编号 + type?: AiWriteTypeEnum // 写作类型 + platform?: string // 平台 + createTime?: [string, string] // 创建时间 +} + +export interface AiWriteRespVo { + id: number + userId: number + type: number + platform: string + model: string + prompt: string + generatedContent: string + originalContent: string + length: number + format: number + tone: number + language: number + errorMessage: string + createTime: string +} + +export const WriteApi = { + writeStream: ({ + data, + onClose, + onMessage, + onError, + ctrl + }: { + data: WriteVO + onMessage?: (res: any) => void + onError?: (...args: any[]) => void + onClose?: (...args: any[]) => void + ctrl: AbortController + }) => { + const token = getAccessToken() + return fetchEventSource(`${config.base_url}/ai/write/generate-stream`, { + method: 'post', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + openWhenHidden: true, + body: JSON.stringify(data), + onmessage: onMessage, + onerror: onError, + onclose: onClose, + signal: ctrl.signal + }) + }, + // 获取写作列表 + getWritePage: (params: AiWritePageReqVO) => { + return request.get<PageResult<AiWriteRespVo[]>>({ url: `/ai/write/page`, params }) + }, + // 删除写作 + deleteWrite(id: number) { + return request.delete({ url: `/ai/write/delete`, params: { id } }) + } +} diff --git a/src/api/bpm/activity/index.ts b/src/api/bpm/activity/index.ts new file mode 100644 index 0000000..870d0d6 --- /dev/null +++ b/src/api/bpm/activity/index.ts @@ -0,0 +1,8 @@ +import request from '@/config/axios' + +export const getActivityList = async (params) => { + return await request.get({ + url: '/bpm/activity/list', + params + }) +} diff --git a/src/api/bpm/category/index.ts b/src/api/bpm/category/index.ts new file mode 100644 index 0000000..d1e109c --- /dev/null +++ b/src/api/bpm/category/index.ts @@ -0,0 +1,43 @@ +import request from '@/config/axios' + +// BPM 流程分类 VO +export interface CategoryVO { + id: number // 分类编号 + name: string // 分类名 + code: string // 分类标志 + status: number // 分类状态 + sort: number // 分类排序 +} + +// BPM 流程分类 API +export const CategoryApi = { + // 查询流程分类分页 + getCategoryPage: async (params: any) => { + return await request.get({ url: `/bpm/category/page`, params }) + }, + + // 查询流程分类列表 + getCategorySimpleList: async () => { + return await request.get({ url: `/bpm/category/simple-list` }) + }, + + // 查询流程分类详情 + getCategory: async (id: number) => { + return await request.get({ url: `/bpm/category/get?id=` + id }) + }, + + // 新增流程分类 + createCategory: async (data: CategoryVO) => { + return await request.post({ url: `/bpm/category/create`, data }) + }, + + // 修改流程分类 + updateCategory: async (data: CategoryVO) => { + return await request.put({ url: `/bpm/category/update`, data }) + }, + + // 删除流程分类 + deleteCategory: async (id: number) => { + return await request.delete({ url: `/bpm/category/delete?id=` + id }) + } +} diff --git a/src/api/bpm/definition/index.ts b/src/api/bpm/definition/index.ts new file mode 100644 index 0000000..caedba1 --- /dev/null +++ b/src/api/bpm/definition/index.ts @@ -0,0 +1,22 @@ +import request from '@/config/axios' + +export const getProcessDefinition = async (id?: string, key?: string) => { + return await request.get({ + url: '/bpm/process-definition/get', + params: { id, key } + }) +} + +export const getProcessDefinitionPage = async (params) => { + return await request.get({ + url: '/bpm/process-definition/page', + params + }) +} + +export const getProcessDefinitionList = async (params) => { + return await request.get({ + url: '/bpm/process-definition/list', + params + }) +} diff --git a/src/api/bpm/form/index.ts b/src/api/bpm/form/index.ts new file mode 100644 index 0000000..7fce11f --- /dev/null +++ b/src/api/bpm/form/index.ts @@ -0,0 +1,56 @@ +import request from '@/config/axios' + +export type FormVO = { + id: number + name: string + conf: string + fields: string[] + status: number + remark: string + createTime: string +} + +// 创建工作流的表单定义 +export const createForm = async (data: FormVO) => { + return await request.post({ + url: '/bpm/form/create', + data: data + }) +} + +// 更新工作流的表单定义 +export const updateForm = async (data: FormVO) => { + return await request.put({ + url: '/bpm/form/update', + data: data + }) +} + +// 删除工作流的表单定义 +export const deleteForm = async (id: number) => { + return await request.delete({ + url: '/bpm/form/delete?id=' + id + }) +} + +// 获得工作流的表单定义 +export const getForm = async (id: number) => { + return await request.get({ + url: '/bpm/form/get?id=' + id + }) +} + +// 获得工作流的表单定义分页 +export const getFormPage = async (params) => { + return await request.get({ + url: '/bpm/form/page', + params + }) +} + +// 获得动态表单的精简列表 +export const getFormSimpleList = async () => { + return await request.get({ + url: '/bpm/form/simple-list' + }) +} diff --git a/src/api/bpm/leave/index.ts b/src/api/bpm/leave/index.ts new file mode 100644 index 0000000..4f374b2 --- /dev/null +++ b/src/api/bpm/leave/index.ts @@ -0,0 +1,27 @@ +import request from '@/config/axios' + +export type LeaveVO = { + id: number + status: number + type: number + reason: string + processInstanceId: string + startTime: string + endTime: string + createTime: string +} + +// 创建请假申请 +export const createLeave = async (data: LeaveVO) => { + return await request.post({ url: '/bpm/oa/leave/create', data: data }) +} + +// 获得请假申请 +export const getLeave = async (id: number) => { + return await request.get({ url: '/bpm/oa/leave/get?id=' + id }) +} + +// 获得请假申请分页 +export const getLeavePage = async (params: PageParam) => { + return await request.get({ url: '/bpm/oa/leave/page', params }) +} diff --git a/src/api/bpm/model/index.ts b/src/api/bpm/model/index.ts new file mode 100644 index 0000000..2b484a6 --- /dev/null +++ b/src/api/bpm/model/index.ts @@ -0,0 +1,60 @@ +import request from '@/config/axios' + +export type ProcessDefinitionVO = { + id: string + version: number + deploymentTIme: string + suspensionState: number + formType?: number +} + +export type ModelVO = { + id: number + formName: string + key: string + name: string + description: string + category: string + formType: number + formId: number + formCustomCreatePath: string + formCustomViewPath: string + processDefinition: ProcessDefinitionVO + status: number + remark: string + createTime: string + bpmnXml: string +} + +export const getModelPage = async (params) => { + return await request.get({ url: '/bpm/model/page', params }) +} + +export const getModel = async (id: number) => { + return await request.get({ url: '/bpm/model/get?id=' + id }) +} + +export const updateModel = async (data: ModelVO) => { + return await request.put({ url: '/bpm/model/update', data: data }) +} + +// 任务状态修改 +export const updateModelState = async (id: number, state: number) => { + const data = { + id: id, + state: state + } + return await request.put({ url: '/bpm/model/update-state', data: data }) +} + +export const createModel = async (data: ModelVO) => { + return await request.post({ url: '/bpm/model/create', data: data }) +} + +export const deleteModel = async (id: number) => { + return await request.delete({ url: '/bpm/model/delete?id=' + id }) +} + +export const deployModel = async (id: number) => { + return await request.post({ url: '/bpm/model/deploy?id=' + id }) +} diff --git a/src/api/bpm/processExpression/index.ts b/src/api/bpm/processExpression/index.ts new file mode 100644 index 0000000..af6a737 --- /dev/null +++ b/src/api/bpm/processExpression/index.ts @@ -0,0 +1,42 @@ +import request from '@/config/axios' + +// BPM 流程表达式 VO +export interface ProcessExpressionVO { + id: number // 编号 + name: string // 表达式名字 + status: number // 表达式状态 + expression: string // 表达式 +} + +// BPM 流程表达式 API +export const ProcessExpressionApi = { + // 查询BPM 流程表达式分页 + getProcessExpressionPage: async (params: any) => { + return await request.get({ url: `/bpm/process-expression/page`, params }) + }, + + // 查询BPM 流程表达式详情 + getProcessExpression: async (id: number) => { + return await request.get({ url: `/bpm/process-expression/get?id=` + id }) + }, + + // 新增BPM 流程表达式 + createProcessExpression: async (data: ProcessExpressionVO) => { + return await request.post({ url: `/bpm/process-expression/create`, data }) + }, + + // 修改BPM 流程表达式 + updateProcessExpression: async (data: ProcessExpressionVO) => { + return await request.put({ url: `/bpm/process-expression/update`, data }) + }, + + // 删除BPM 流程表达式 + deleteProcessExpression: async (id: number) => { + return await request.delete({ url: `/bpm/process-expression/delete?id=` + id }) + }, + + // 导出BPM 流程表达式 Excel + exportProcessExpression: async (params) => { + return await request.download({ url: `/bpm/process-expression/export-excel`, params }) + } +} \ No newline at end of file diff --git a/src/api/bpm/processInstance/index.ts b/src/api/bpm/processInstance/index.ts new file mode 100644 index 0000000..9122b2b --- /dev/null +++ b/src/api/bpm/processInstance/index.ts @@ -0,0 +1,59 @@ +import request from '@/config/axios' +import { ProcessDefinitionVO } from '@/api/bpm/model' + +export type Task = { + id: string + name: string +} + +export type ProcessInstanceVO = { + id: number + name: string + processDefinitionId: string + category: string + result: number + tasks: Task[] + fields: string[] + status: number + remark: string + businessKey: string + createTime: string + endTime: string + processDefinition?: ProcessDefinitionVO +} + +export const getProcessInstanceMyPage = async (params: any) => { + return await request.get({ url: '/bpm/process-instance/my-page', params }) +} + +export const getProcessInstanceManagerPage = async (params: any) => { + return await request.get({ url: '/bpm/process-instance/manager-page', params }) +} + +export const createProcessInstance = async (data) => { + return await request.post({ url: '/bpm/process-instance/create', data: data }) +} + +export const cancelProcessInstanceByStartUser = async (id: number, reason: string) => { + const data = { + id: id, + reason: reason + } + return await request.delete({ url: '/bpm/process-instance/cancel-by-start-user', data: data }) +} + +export const cancelProcessInstanceByAdmin = async (id: number, reason: string) => { + const data = { + id: id, + reason: reason + } + return await request.delete({ url: '/bpm/process-instance/cancel-by-admin', data: data }) +} + +export const getProcessInstance = async (id: string) => { + return await request.get({ url: '/bpm/process-instance/get?id=' + id }) +} + +export const getProcessInstanceCopyPage = async (params: any) => { + return await request.get({ url: '/bpm/process-instance/copy/page', params }) +} diff --git a/src/api/bpm/processListener/index.ts b/src/api/bpm/processListener/index.ts new file mode 100644 index 0000000..dabaa47 --- /dev/null +++ b/src/api/bpm/processListener/index.ts @@ -0,0 +1,40 @@ +import request from '@/config/axios' + +// BPM 流程监听器 VO +export interface ProcessListenerVO { + id: number // 编号 + name: string // 监听器名字 + type: string // 监听器类型 + status: number // 监听器状态 + event: string // 监听事件 + valueType: string // 监听器值类型 + value: string // 监听器值 +} + +// BPM 流程监听器 API +export const ProcessListenerApi = { + // 查询流程监听器分页 + getProcessListenerPage: async (params: any) => { + return await request.get({ url: `/bpm/process-listener/page`, params }) + }, + + // 查询流程监听器详情 + getProcessListener: async (id: number) => { + return await request.get({ url: `/bpm/process-listener/get?id=` + id }) + }, + + // 新增流程监听器 + createProcessListener: async (data: ProcessListenerVO) => { + return await request.post({ url: `/bpm/process-listener/create`, data }) + }, + + // 修改流程监听器 + updateProcessListener: async (data: ProcessListenerVO) => { + return await request.put({ url: `/bpm/process-listener/update`, data }) + }, + + // 删除流程监听器 + deleteProcessListener: async (id: number) => { + return await request.delete({ url: `/bpm/process-listener/delete?id=` + id }) + } +} diff --git a/src/api/bpm/task/index.ts b/src/api/bpm/task/index.ts new file mode 100644 index 0000000..f3cda9f --- /dev/null +++ b/src/api/bpm/task/index.ts @@ -0,0 +1,66 @@ +import request from '@/config/axios' + +export type TaskVO = { + id: number +} + +export const getTaskTodoPage = async (params: any) => { + return await request.get({ url: '/bpm/task/todo-page', params }) +} + +export const getTaskDonePage = async (params: any) => { + return await request.get({ url: '/bpm/task/done-page', params }) +} + +export const getTaskManagerPage = async (params: any) => { + return await request.get({ url: '/bpm/task/manager-page', params }) +} + +export const approveTask = async (data: any) => { + return await request.put({ url: '/bpm/task/approve', data }) +} + +export const rejectTask = async (data: any) => { + return await request.put({ url: '/bpm/task/reject', data }) +} + +export const getTaskListByProcessInstanceId = async (processInstanceId: string) => { + return await request.get({ + url: '/bpm/task/list-by-process-instance-id?processInstanceId=' + processInstanceId + }) +} + +// 获取所有可回退的节点 +export const getTaskListByReturn = async (id: string) => { + return await request.get({ url: '/bpm/task/list-by-return', params: { id } }) +} + +// 回退 +export const returnTask = async (data: any) => { + return await request.put({ url: '/bpm/task/return', data }) +} + +// 委派 +export const delegateTask = async (data: any) => { + return await request.put({ url: '/bpm/task/delegate', data }) +} + +// 转派 +export const transferTask = async (data: any) => { + return await request.put({ url: '/bpm/task/transfer', data }) +} + +// 加签 +export const signCreateTask = async (data: any) => { + return await request.put({ url: '/bpm/task/create-sign', data }) +} + +// 减签 +export const signDeleteTask = async (data: any) => { + return await request.delete({ url: '/bpm/task/delete-sign', data }) +} + +// 获取减签任务列表 +export const getChildrenTaskList = async (id: string) => { + return await request.get({ url: '/bpm/task/list-by-parent-task-id?parentTaskId=' + id }) +} diff --git a/src/api/bpm/userGroup/index.ts b/src/api/bpm/userGroup/index.ts new file mode 100644 index 0000000..7d12755 --- /dev/null +++ b/src/api/bpm/userGroup/index.ts @@ -0,0 +1,47 @@ +import request from '@/config/axios' + +export type UserGroupVO = { + id: number + name: string + description: string + userIds: number[] + status: number + remark: string + createTime: string +} + +// 创建用户组 +export const createUserGroup = async (data: UserGroupVO) => { + return await request.post({ + url: '/bpm/user-group/create', + data: data + }) +} + +// 更新用户组 +export const updateUserGroup = async (data: UserGroupVO) => { + return await request.put({ + url: '/bpm/user-group/update', + data: data + }) +} + +// 删除用户组 +export const deleteUserGroup = async (id: number) => { + return await request.delete({ url: '/bpm/user-group/delete?id=' + id }) +} + +// 获得用户组 +export const getUserGroup = async (id: number) => { + return await request.get({ url: '/bpm/user-group/get?id=' + id }) +} + +// 获得用户组分页 +export const getUserGroupPage = async (params) => { + return await request.get({ url: '/bpm/user-group/page', params }) +} + +// 获取用户组精简信息列表 +export const getUserGroupSimpleList = async (): Promise<UserGroupVO[]> => { + return await request.get({ url: '/bpm/user-group/simple-list' }) +} diff --git a/src/api/crm/business/index.ts b/src/api/crm/business/index.ts new file mode 100644 index 0000000..2420425 --- /dev/null +++ b/src/api/crm/business/index.ts @@ -0,0 +1,98 @@ +import request from '@/config/axios' +import { TransferReqVO } from '@/api/crm/permission' + +export interface BusinessVO { + id: number + name: string + customerId: number + customerName?: string + followUpStatus: boolean + contactLastTime: Date + contactNextTime: Date + ownerUserId: number + ownerUserName?: string // 负责人的用户名称 + ownerUserDept?: string // 负责人的部门名称 + statusTypeId: number + statusTypeName?: string + statusId: number + statusName?: string + endStatus: number + endRemark: string + dealTime: Date + totalProductPrice: number + totalPrice: number + discountPercent: number + remark: string + creator: string // 创建人 + creatorName?: string // 创建人名称 + createTime: Date // 创建时间 + updateTime: Date // 更新时间 + products?: [ + { + id: number + productId: number + productName: string + productNo: string + productUnit: number + productPrice: number + businessPrice: number + count: number + totalPrice: number + } + ] +} + +// 查询 CRM 商机列表 +export const getBusinessPage = async (params) => { + return await request.get({ url: `/crm/business/page`, params }) +} + +// 查询 CRM 商机列表,基于指定客户 +export const getBusinessPageByCustomer = async (params) => { + return await request.get({ url: `/crm/business/page-by-customer`, params }) +} + +// 查询 CRM 商机详情 +export const getBusiness = async (id: number) => { + return await request.get({ url: `/crm/business/get?id=` + id }) +} + +// 获得 CRM 商机列表(精简) +export const getSimpleBusinessList = async () => { + return await request.get({ url: `/crm/business/simple-all-list` }) +} + +// 新增 CRM 商机 +export const createBusiness = async (data: BusinessVO) => { + return await request.post({ url: `/crm/business/create`, data }) +} + +// 修改 CRM 商机 +export const updateBusiness = async (data: BusinessVO) => { + return await request.put({ url: `/crm/business/update`, data }) +} + +// 修改 CRM 商机状态 +export const updateBusinessStatus = async (data: BusinessVO) => { + return await request.put({ url: `/crm/business/update-status`, data }) +} + +// 删除 CRM 商机 +export const deleteBusiness = async (id: number) => { + return await request.delete({ url: `/crm/business/delete?id=` + id }) +} + +// 导出 CRM 商机 Excel +export const exportBusiness = async (params) => { + return await request.download({ url: `/crm/business/export-excel`, params }) +} + +// 联系人关联商机列表 +export const getBusinessPageByContact = async (params) => { + return await request.get({ url: `/crm/business/page-by-contact`, params }) +} + +// 商机转移 +export const transferBusiness = async (data: TransferReqVO) => { + return await request.put({ url: '/crm/business/transfer', data }) +} diff --git a/src/api/crm/business/status/index.ts b/src/api/crm/business/status/index.ts new file mode 100644 index 0000000..cddaa5a --- /dev/null +++ b/src/api/crm/business/status/index.ts @@ -0,0 +1,68 @@ +import request from '@/config/axios' + +export interface BusinessStatusTypeVO { + id: number + name: string + deptIds: number[] + statuses?: { + id: number + name: string + percent: number + } +} + +export const DEFAULT_STATUSES = [ + { + endStatus: 1, + key: '结束', + name: '赢单', + percent: 100 + }, + { + endStatus: 2, + key: '结束', + name: '输单', + percent: 0 + }, + { + endStatus: 3, + key: '结束', + name: '无效', + percent: 0 + } +] + +// 查询商机状态组列表 +export const getBusinessStatusPage = async (params: any) => { + return await request.get({ url: `/crm/business-status/page`, params }) +} + +// 新增商机状态组 +export const createBusinessStatus = async (data: BusinessStatusTypeVO) => { + return await request.post({ url: `/crm/business-status/create`, data }) +} + +// 修改商机状态组 +export const updateBusinessStatus = async (data: BusinessStatusTypeVO) => { + return await request.put({ url: `/crm/business-status/update`, data }) +} + +// 查询商机状态类型详情 +export const getBusinessStatus = async (id: number) => { + return await request.get({ url: `/crm/business-status/get?id=` + id }) +} + +// 删除商机状态 +export const deleteBusinessStatus = async (id: number) => { + return await request.delete({ url: `/crm/business-status/delete?id=` + id }) +} + +// 获得商机状态组列表 +export const getBusinessStatusTypeSimpleList = async () => { + return await request.get({ url: `/crm/business-status/type-simple-list` }) +} + +// 获得商机阶段列表 +export const getBusinessStatusSimpleList = async (typeId: number) => { + return await request.get({ url: `/crm/business-status/status-simple-list`, params: { typeId } }) +} diff --git a/src/api/crm/clue/index.ts b/src/api/crm/clue/index.ts new file mode 100644 index 0000000..9736514 --- /dev/null +++ b/src/api/crm/clue/index.ts @@ -0,0 +1,78 @@ +import request from '@/config/axios' +import { TransferReqVO } from '@/api/crm/permission' + +export interface ClueVO { + id: number // 编号 + name: string // 线索名称 + followUpStatus: boolean // 跟进状态 + contactLastTime: Date // 最后跟进时间 + contactLastContent: string // 最后跟进内容 + contactNextTime: Date // 下次联系时间 + ownerUserId: number // 负责人的用户编号 + ownerUserName?: string // 负责人的用户名称 + ownerUserDept?: string // 负责人的部门名称 + transformStatus: boolean // 转化状态 + customerId: number // 客户编号 + customerName?: string // 客户名称 + mobile: string // 手机号 + telephone: string // 电话 + qq: string // QQ + wechat: string // wechat + email: string // email + areaId: number // 所在地 + areaName?: string // 所在地名称 + detailAddress: string // 详细地址 + industryId: number // 所属行业 + level: number // 客户等级 + source: number // 客户来源 + remark: string // 备注 + creator: string // 创建人 + creatorName?: string // 创建人名称 + createTime: Date // 创建时间 + updateTime: Date // 更新时间 +} + +// 查询线索列表 +export const getCluePage = async (params: any) => { + return await request.get({ url: `/crm/clue/page`, params }) +} + +// 查询线索详情 +export const getClue = async (id: number) => { + return await request.get({ url: `/crm/clue/get?id=` + id }) +} + +// 新增线索 +export const createClue = async (data: ClueVO) => { + return await request.post({ url: `/crm/clue/create`, data }) +} + +// 修改线索 +export const updateClue = async (data: ClueVO) => { + return await request.put({ url: `/crm/clue/update`, data }) +} + +// 删除线索 +export const deleteClue = async (id: number) => { + return await request.delete({ url: `/crm/clue/delete?id=` + id }) +} + +// 导出线索 Excel +export const exportClue = async (params) => { + return await request.download({ url: `/crm/clue/export-excel`, params }) +} + +// 线索转移 +export const transferClue = async (data: TransferReqVO) => { + return await request.put({ url: '/crm/clue/transfer', data }) +} + +// 线索转化为客户 +export const transformClue = async (id: number) => { + return await request.put({ url: '/crm/clue/transform', params: { id } }) +} + +// 获得分配给我的、待跟进的线索数量 +export const getFollowClueCount = async () => { + return await request.get({ url: '/crm/clue/follow-count' }) +} diff --git a/src/api/crm/contact/index.ts b/src/api/crm/contact/index.ts new file mode 100644 index 0000000..7c24dfa --- /dev/null +++ b/src/api/crm/contact/index.ts @@ -0,0 +1,113 @@ +import request from '@/config/axios' +import { TransferReqVO } from '@/api/crm/permission' + +export interface ContactVO { + id: number // 编号 + name: string // 联系人名称 + customerId: number // 客户编号 + customerName?: string // 客户名称 + contactLastTime: Date // 最后跟进时间 + contactLastContent: string // 最后跟进内容 + contactNextTime: Date // 下次联系时间 + ownerUserId: number // 负责人的用户编号 + ownerUserName?: string // 负责人的用户名称 + ownerUserDept?: string // 负责人的部门名称 + mobile: string // 手机号 + telephone: string // 电话 + qq: string // QQ + wechat: string // wechat + email: string // email + areaId: number // 所在地 + areaName?: string // 所在地名称 + detailAddress: string // 详细地址 + sex: number // 性别 + master: boolean // 是否主联系人 + post: string // 职务 + parentId: number // 上级联系人编号 + parentName?: string // 上级联系人名称 + remark: string // 备注 + creator: string // 创建人 + creatorName?: string // 创建人名称 + createTime: Date // 创建时间 + updateTime: Date // 更新时间 +} + +export interface ContactBusinessReqVO { + contactId: number + businessIds: number[] +} + +export interface ContactBusiness2ReqVO { + businessId: number + contactIds: number[] +} + +// 查询 CRM 联系人列表 +export const getContactPage = async (params) => { + return await request.get({ url: `/crm/contact/page`, params }) +} + +// 查询 CRM 联系人列表,基于指定客户 +export const getContactPageByCustomer = async (params: any) => { + return await request.get({ url: `/crm/contact/page-by-customer`, params }) +} + +// 查询 CRM 联系人列表,基于指定商机 +export const getContactPageByBusiness = async (params: any) => { + return await request.get({ url: `/crm/contact/page-by-business`, params }) +} + +// 查询 CRM 联系人详情 +export const getContact = async (id: number) => { + return await request.get({ url: `/crm/contact/get?id=` + id }) +} + +// 新增 CRM 联系人 +export const createContact = async (data: ContactVO) => { + return await request.post({ url: `/crm/contact/create`, data }) +} + +// 修改 CRM 联系人 +export const updateContact = async (data: ContactVO) => { + return await request.put({ url: `/crm/contact/update`, data }) +} + +// 删除 CRM 联系人 +export const deleteContact = async (id: number) => { + return await request.delete({ url: `/crm/contact/delete?id=` + id }) +} + +// 导出 CRM 联系人 Excel +export const exportContact = async (params) => { + return await request.download({ url: `/crm/contact/export-excel`, params }) +} + +// 获得 CRM 联系人列表(精简) +export const getSimpleContactList = async () => { + return await request.get({ url: `/crm/contact/simple-all-list` }) +} + +// 批量新增联系人商机关联 +export const createContactBusinessList = async (data: ContactBusinessReqVO) => { + return await request.post({ url: `/crm/contact/create-business-list`, data }) +} + +// 批量新增联系人商机关联 +export const createContactBusinessList2 = async (data: ContactBusiness2ReqVO) => { + return await request.post({ url: `/crm/contact/create-business-list2`, data }) +} + +// 解除联系人商机关联 +export const deleteContactBusinessList = async (data: ContactBusinessReqVO) => { + return await request.delete({ url: `/crm/contact/delete-business-list`, data }) +} + +// 解除联系人商机关联 +export const deleteContactBusinessList2 = async (data: ContactBusiness2ReqVO) => { + return await request.delete({ url: `/crm/contact/delete-business-list2`, data }) +} + +// 联系人转移 +export const transferContact = async (data: TransferReqVO) => { + return await request.put({ url: '/crm/contact/transfer', data }) +} diff --git a/src/api/crm/contract/config/index.ts b/src/api/crm/contract/config/index.ts new file mode 100644 index 0000000..0c7ad20 --- /dev/null +++ b/src/api/crm/contract/config/index.ts @@ -0,0 +1,16 @@ +import request from '@/config/axios' + +export interface ContractConfigVO { + notifyEnabled?: boolean + notifyDays?: number +} + +// 获取合同配置 +export const getContractConfig = async () => { + return await request.get({ url: `/crm/contract-config/get` }) +} + +// 更新合同配置 +export const saveContractConfig = async (data: ContractConfigVO) => { + return await request.put({ url: `/crm/contract-config/save`, data }) +} diff --git a/src/api/crm/contract/index.ts b/src/api/crm/contract/index.ts new file mode 100644 index 0000000..7028b77 --- /dev/null +++ b/src/api/crm/contract/index.ts @@ -0,0 +1,114 @@ +import request from '@/config/axios' +import { TransferReqVO } from '@/api/crm/permission' + +export interface ContractVO { + id: number + name: string + no: string + customerId: number + customerName?: string + businessId: number + businessName: string + contactLastTime: Date + ownerUserId: number + ownerUserName?: string + ownerUserDeptName?: string + processInstanceId: number + auditStatus: number + orderDate: Date + startTime: Date + endTime: Date + totalProductPrice: number + discountPercent: number + totalPrice: number + totalReceivablePrice: number + signContactId: number + signContactName?: string + signUserId: number + signUserName: string + remark: string + createTime?: Date + creator: string + creatorName: string + updateTime?: Date + products?: [ + { + id: number + productId: number + productName: string + productNo: string + productUnit: number + productPrice: number + contractPrice: number + count: number + totalPrice: number + } + ] +} + +// 查询 CRM 合同列表 +export const getContractPage = async (params) => { + return await request.get({ url: `/crm/contract/page`, params }) +} + +// 查询 CRM 联系人列表,基于指定客户 +export const getContractPageByCustomer = async (params: any) => { + return await request.get({ url: `/crm/contract/page-by-customer`, params }) +} + +// 查询 CRM 联系人列表,基于指定商机 +export const getContractPageByBusiness = async (params: any) => { + return await request.get({ url: `/crm/contract/page-by-business`, params }) +} + +// 查询 CRM 合同详情 +export const getContract = async (id: number) => { + return await request.get({ url: `/crm/contract/get?id=` + id }) +} + +// 查询 CRM 合同下拉列表 +export const getContractSimpleList = async (customerId: number) => { + return await request.get({ + url: `/crm/contract/simple-list?customerId=${customerId}` + }) +} + +// 新增 CRM 合同 +export const createContract = async (data: ContractVO) => { + return await request.post({ url: `/crm/contract/create`, data }) +} + +// 修改 CRM 合同 +export const updateContract = async (data: ContractVO) => { + return await request.put({ url: `/crm/contract/update`, data }) +} + +// 删除 CRM 合同 +export const deleteContract = async (id: number) => { + return await request.delete({ url: `/crm/contract/delete?id=` + id }) +} + +// 导出 CRM 合同 Excel +export const exportContract = async (params) => { + return await request.download({ url: `/crm/contract/export-excel`, params }) +} + +// 提交审核 +export const submitContract = async (id: number) => { + return await request.put({ url: `/crm/contract/submit?id=${id}` }) +} + +// 合同转移 +export const transferContract = async (data: TransferReqVO) => { + return await request.put({ url: '/crm/contract/transfer', data }) +} + +// 获得待审核合同数量 +export const getAuditContractCount = async () => { + return await request.get({ url: '/crm/contract/audit-count' }) +} + +// 获得即将到期(提醒)的合同数量 +export const getRemindContractCount = async () => { + return await request.get({ url: '/crm/contract/remind-count' }) +} diff --git a/src/api/crm/customer/index.ts b/src/api/crm/customer/index.ts new file mode 100644 index 0000000..d149d4e --- /dev/null +++ b/src/api/crm/customer/index.ts @@ -0,0 +1,132 @@ +import request from '@/config/axios' +import { TransferReqVO } from '@/api/crm/permission' + +export interface CustomerVO { + id: number // 编号 + name: string // 客户名称 + followUpStatus: boolean // 跟进状态 + contactLastTime: Date // 最后跟进时间 + contactLastContent: string // 最后跟进内容 + contactNextTime: Date // 下次联系时间 + ownerUserId: number // 负责人的用户编号 + ownerUserName?: string // 负责人的用户名称 + ownerUserDept?: string // 负责人的部门名称 + lockStatus?: boolean + dealStatus?: boolean + mobile: string // 手机号 + telephone: string // 电话 + qq: string // QQ + wechat: string // wechat + email: string // email + areaId: number // 所在地 + areaName?: string // 所在地名称 + detailAddress: string // 详细地址 + industryId: number // 所属行业 + level: number // 客户等级 + source: number // 客户来源 + remark: string // 备注 + creator: string // 创建人 + creatorName?: string // 创建人名称 + createTime: Date // 创建时间 + updateTime: Date // 更新时间 +} + +// 查询客户列表 +export const getCustomerPage = async (params) => { + return await request.get({ url: `/crm/customer/page`, params }) +} + +// 进入公海客户提醒的客户列表 +export const getPutPoolRemindCustomerPage = async (params) => { + return await request.get({ url: `/crm/customer/put-pool-remind-page`, params }) +} + +// 获得待进入公海客户数量 +export const getPutPoolRemindCustomerCount = async () => { + return await request.get({ url: `/crm/customer/put-pool-remind-count` }) +} + +// 获得今日需联系客户数量 +export const getTodayContactCustomerCount = async () => { + return await request.get({ url: `/crm/customer/today-contact-count` }) +} + +// 获得分配给我、待跟进的线索数量的客户数量 +export const getFollowCustomerCount = async () => { + return await request.get({ url: `/crm/customer/follow-count` }) +} + +// 查询客户详情 +export const getCustomer = async (id: number) => { + return await request.get({ url: `/crm/customer/get?id=` + id }) +} + +// 新增客户 +export const createCustomer = async (data: CustomerVO) => { + return await request.post({ url: `/crm/customer/create`, data }) +} + +// 修改客户 +export const updateCustomer = async (data: CustomerVO) => { + return await request.put({ url: `/crm/customer/update`, data }) +} + +// 更新客户的成交状态 +export const updateCustomerDealStatus = async (id: number, dealStatus: boolean) => { + return await request.put({ url: `/crm/customer/update-deal-status`, params: { id, dealStatus } }) +} + +// 删除客户 +export const deleteCustomer = async (id: number) => { + return await request.delete({ url: `/crm/customer/delete?id=` + id }) +} + +// 导出客户 Excel +export const exportCustomer = async (params: any) => { + return await request.download({ url: `/crm/customer/export-excel`, params }) +} + +// 下载客户导入模板 +export const importCustomerTemplate = () => { + return request.download({ url: '/crm/customer/get-import-template' }) +} + +// 导入客户 +export const handleImport = async (formData) => { + return await request.upload({ url: `/crm/customer/import`, data: formData }) +} + +// 客户列表 +export const getCustomerSimpleList = async () => { + return await request.get({ url: `/crm/customer/simple-list` }) +} + +// ======================= 业务操作 ======================= + +// 客户转移 +export const transferCustomer = async (data: TransferReqVO) => { + return await request.put({ url: '/crm/customer/transfer', data }) +} + +// 锁定/解锁客户 +export const lockCustomer = async (id: number, lockStatus: boolean) => { + return await request.put({ url: `/crm/customer/lock`, data: { id, lockStatus } }) +} + +// 领取公海客户 +export const receiveCustomer = async (ids: any[]) => { + return await request.put({ url: '/crm/customer/receive', params: { ids: ids.join(',') } }) +} + +// 分配公海给对应负责人 +export const distributeCustomer = async (ids: any[], ownerUserId: number) => { + return await request.put({ + url: '/crm/customer/distribute', + data: { ids: ids, ownerUserId } + }) +} + +// 客户放入公海 +export const putCustomerPool = async (id: number) => { + return await request.put({ url: `/crm/customer/put-pool?id=${id}` }) +} diff --git a/src/api/crm/customer/limitConfig/index.ts b/src/api/crm/customer/limitConfig/index.ts new file mode 100644 index 0000000..8677632 --- /dev/null +++ b/src/api/crm/customer/limitConfig/index.ts @@ -0,0 +1,49 @@ +import request from '@/config/axios' + +export interface CustomerLimitConfigVO { + id?: number + type?: number + userIds?: string + deptIds?: string + maxCount?: number + dealCountEnabled?: boolean +} + +/** + * 客户限制配置类型 + */ +export enum LimitConfType { + /** + * 拥有客户数限制 + */ + CUSTOMER_QUANTITY_LIMIT = 1, + /** + * 锁定客户数限制 + */ + CUSTOMER_LOCK_LIMIT = 2 +} + +// 查询客户限制配置列表 +export const getCustomerLimitConfigPage = async (params) => { + return await request.get({ url: `/crm/customer-limit-config/page`, params }) +} + +// 查询客户限制配置详情 +export const getCustomerLimitConfig = async (id: number) => { + return await request.get({ url: `/crm/customer-limit-config/get?id=` + id }) +} + +// 新增客户限制配置 +export const createCustomerLimitConfig = async (data: CustomerLimitConfigVO) => { + return await request.post({ url: `/crm/customer-limit-config/create`, data }) +} + +// 修改客户限制配置 +export const updateCustomerLimitConfig = async (data: CustomerLimitConfigVO) => { + return await request.put({ url: `/crm/customer-limit-config/update`, data }) +} + +// 删除客户限制配置 +export const deleteCustomerLimitConfig = async (id: number) => { + return await request.delete({ url: `/crm/customer-limit-config/delete?id=` + id }) +} diff --git a/src/api/crm/customer/poolConfig/index.ts b/src/api/crm/customer/poolConfig/index.ts new file mode 100644 index 0000000..b96e61f --- /dev/null +++ b/src/api/crm/customer/poolConfig/index.ts @@ -0,0 +1,19 @@ +import request from '@/config/axios' + +export interface CustomerPoolConfigVO { + enabled?: boolean + contactExpireDays?: number + dealExpireDays?: number + notifyEnabled?: boolean + notifyDays?: number +} + +// 获取客户公海规则设置 +export const getCustomerPoolConfig = async () => { + return await request.get({ url: `/crm/customer-pool-config/get` }) +} + +// 更新客户公海规则设置 +export const saveCustomerPoolConfig = async (data: CustomerPoolConfigVO) => { + return await request.put({ url: `/crm/customer-pool-config/save`, data }) +} diff --git a/src/api/crm/followup/index.ts b/src/api/crm/followup/index.ts new file mode 100644 index 0000000..414f3f7 --- /dev/null +++ b/src/api/crm/followup/index.ts @@ -0,0 +1,43 @@ +import request from '@/config/axios' + +// 跟进记录 VO +export interface FollowUpRecordVO { + id: number // 编号 + bizType: number // 数据类型 + bizId: number // 数据编号 + type: number // 跟进类型 + content: string // 跟进内容 + picUrls: string[] // 图片 + fileUrls: string[] // 附件 + nextTime: Date // 下次联系时间 + businessIds: number[] // 关联的商机编号数组 + businesses: { + id: number + name: string + }[] // 关联的商机数组 + contactIds: number[] // 关联的联系人编号数组 + contacts: { + id: number + name: string + }[] // 关联的联系人数组 + creator: string + creatorName?: string +} + +// 跟进记录 API +export const FollowUpRecordApi = { + // 查询跟进记录分页 + getFollowUpRecordPage: async (params: any) => { + return await request.get({ url: `/crm/follow-up-record/page`, params }) + }, + + // 新增跟进记录 + createFollowUpRecord: async (data: FollowUpRecordVO) => { + return await request.post({ url: `/crm/follow-up-record/create`, data }) + }, + + // 删除跟进记录 + deleteFollowUpRecord: async (id: number) => { + return await request.delete({ url: `/crm/follow-up-record/delete?id=` + id }) + } +} diff --git a/src/api/crm/operateLog/index.ts b/src/api/crm/operateLog/index.ts new file mode 100644 index 0000000..d0f25b6 --- /dev/null +++ b/src/api/crm/operateLog/index.ts @@ -0,0 +1,11 @@ +import request from '@/config/axios' + +export interface OperateLogVO extends PageParam { + bizType: number + bizId: number +} + +// 获得操作日志 +export const getOperateLogPage = async (params: OperateLogVO) => { + return await request.get({ url: `/crm/operate-log/page`, params }) +} diff --git a/src/api/crm/permission/index.ts b/src/api/crm/permission/index.ts new file mode 100644 index 0000000..4f88b14 --- /dev/null +++ b/src/api/crm/permission/index.ts @@ -0,0 +1,72 @@ +import request from '@/config/axios' + +export interface PermissionVO { + id?: number // 数据权限编号 + userId: number // 用户编号 + bizType: number // Crm 类型 + bizId: number // Crm 类型数据编号 + level: number // 权限级别 + toBizTypes?: number[] // 同时添加至 + deptName?: string // 部门名称 + nickname?: string // 用户昵称 + postNames?: string[] // 岗位名称数组 + createTime?: Date + ids?: number[] +} + +export interface TransferReqVO { + id: number // 模块编号 + newOwnerUserId: number // 新负责人的用户编号 + oldOwnerPermissionLevel?: number // 老负责人加入团队后的权限级别 + toBizTypes?: number[] // 转移客户时,需要额外有【联系人】【商机】【合同】的 checkbox 选择 +} + +/** + * CRM 业务类型枚举 + * + * @author HUIHUI + */ +export enum BizTypeEnum { + CRM_CLUE = 1, // 线索 + CRM_CUSTOMER = 2, // 客户 + CRM_CONTACT = 3, // 联系人 + CRM_BUSINESS = 4, // 商机 + CRM_CONTRACT = 5, // 合同 + CRM_PRODUCT = 6, // 产品 + CRM_RECEIVABLE = 7, // 回款 + CRM_RECEIVABLE_PLAN = 8 // 回款计划 +} + +/** + * CRM 数据权限级别枚举 + */ +export enum PermissionLevelEnum { + OWNER = 1, // 负责人 + READ = 2, // 只读 + WRITE = 3 // 读写 +} + +// 获得数据权限列表(查询团队成员列表) +export const getPermissionList = async (params) => { + return await request.get({ url: `/crm/permission/list`, params }) +} + +// 创建数据权限(新增团队成员) +export const createPermission = async (data: PermissionVO) => { + return await request.post({ url: `/crm/permission/create`, data }) +} + +// 编辑数据权限(修改团队成员权限级别) +export const updatePermission = async (data) => { + return await request.put({ url: `/crm/permission/update`, data }) +} + +// 删除数据权限(删除团队成员) +export const deletePermissionBatch = async (val: number[]) => { + return await request.delete({ url: '/crm/permission/delete?ids=' + val.join(',') }) +} + +// 删除自己的数据权限(退出团队) +export const deleteSelfPermission = async (id: number) => { + return await request.delete({ url: '/crm/permission/delete-self?id=' + id }) +} diff --git a/src/api/crm/product/category/index.ts b/src/api/crm/product/category/index.ts new file mode 100644 index 0000000..6341d1b --- /dev/null +++ b/src/api/crm/product/category/index.ts @@ -0,0 +1,33 @@ +import request from '@/config/axios' + +// TODO @zange:挪到 product 下,建个 category 包,挪进去哈; +export interface ProductCategoryVO { + id: number + name: string + parentId: number +} + +// 查询产品分类详情 +export const getProductCategory = async (id: number) => { + return await request.get({ url: `/crm/product-category/get?id=` + id }) +} + +// 新增产品分类 +export const createProductCategory = async (data: ProductCategoryVO) => { + return await request.post({ url: `/crm/product-category/create`, data }) +} + +// 修改产品分类 +export const updateProductCategory = async (data: ProductCategoryVO) => { + return await request.put({ url: `/crm/product-category/update`, data }) +} + +// 删除产品分类 +export const deleteProductCategory = async (id: number) => { + return await request.delete({ url: `/crm/product-category/delete?id=` + id }) +} + +// 产品分类列表 +export const getProductCategoryList = async (params) => { + return await request.get({ url: `/crm/product-category/list`, params }) +} diff --git a/src/api/crm/product/index.ts b/src/api/crm/product/index.ts new file mode 100644 index 0000000..f0c2328 --- /dev/null +++ b/src/api/crm/product/index.ts @@ -0,0 +1,49 @@ +import request from '@/config/axios' + +export interface ProductVO { + id: number + name: string + no: string + unit: number + price: number + status: number + categoryId: number + categoryName?: string + description: string + ownerUserId: number +} + +// 查询产品列表 +export const getProductPage = async (params) => { + return await request.get({ url: `/crm/product/page`, params }) +} + +// 获得产品精简列表 +export const getProductSimpleList = async () => { + return await request.get({ url: `/crm/product/simple-list` }) +} + +// 查询产品详情 +export const getProduct = async (id: number) => { + return await request.get({ url: `/crm/product/get?id=` + id }) +} + +// 新增产品 +export const createProduct = async (data: ProductVO) => { + return await request.post({ url: `/crm/product/create`, data }) +} + +// 修改产品 +export const updateProduct = async (data: ProductVO) => { + return await request.put({ url: `/crm/product/update`, data }) +} + +// 删除产品 +export const deleteProduct = async (id: number) => { + return await request.delete({ url: `/crm/product/delete?id=` + id }) +} + +// 导出产品 Excel +export const exportProduct = async (params) => { + return await request.download({ url: `/crm/product/export-excel`, params }) +} diff --git a/src/api/crm/receivable/index.ts b/src/api/crm/receivable/index.ts new file mode 100644 index 0000000..32ecd25 --- /dev/null +++ b/src/api/crm/receivable/index.ts @@ -0,0 +1,73 @@ +import request from '@/config/axios' + +export interface ReceivableVO { + id: number + no: string + planId?: number + customerId?: number + customerName?: string + contractId?: number + contract?: { + id?: number + name?: string + no: string + totalPrice: number + } + auditStatus: number + processInstanceId: number + returnTime: Date + returnType: number + price: number + ownerUserId: number + ownerUserName?: string + remark: string + creator: string // 创建人 + creatorName?: string // 创建人名称 + createTime: Date // 创建时间 + updateTime: Date // 更新时间 +} + +// 查询回款列表 +export const getReceivablePage = async (params) => { + return await request.get({ url: `/crm/receivable/page`, params }) +} + +// 查询回款列表 +export const getReceivablePageByCustomer = async (params) => { + return await request.get({ url: `/crm/receivable/page-by-customer`, params }) +} + +// 查询回款详情 +export const getReceivable = async (id: number) => { + return await request.get({ url: `/crm/receivable/get?id=` + id }) +} + +// 新增回款 +export const createReceivable = async (data: ReceivableVO) => { + return await request.post({ url: `/crm/receivable/create`, data }) +} + +// 修改回款 +export const updateReceivable = async (data: ReceivableVO) => { + return await request.put({ url: `/crm/receivable/update`, data }) +} + +// 删除回款 +export const deleteReceivable = async (id: number) => { + return await request.delete({ url: `/crm/receivable/delete?id=` + id }) +} + +// 导出回款 Excel +export const exportReceivable = async (params) => { + return await request.download({ url: `/crm/receivable/export-excel`, params }) +} + +// 提交审核 +export const submitReceivable = async (id: number) => { + return await request.put({ url: `/crm/receivable/submit?id=${id}` }) +} + +// 获得待审核回款数量 +export const getAuditReceivableCount = async () => { + return await request.get({ url: '/crm/receivable/audit-count' }) +} diff --git a/src/api/crm/receivable/plan/index.ts b/src/api/crm/receivable/plan/index.ts new file mode 100644 index 0000000..770b347 --- /dev/null +++ b/src/api/crm/receivable/plan/index.ts @@ -0,0 +1,74 @@ +import request from '@/config/axios' + +export interface ReceivablePlanVO { + id: number + period: number + receivableId: number + price: number + returnTime: Date + remindDays: number + returnType: number + remindTime: Date + customerId: number + customerName?: string + contractId?: number + contractNo?: string + ownerUserId: number + ownerUserName?: string + remark: string + creator: string // 创建人 + creatorName?: string // 创建人名称 + createTime: Date // 创建时间 + updateTime: Date // 更新时间 + receivable?: { + price: number + returnTime: Date + } +} + +// 查询回款计划列表 +export const getReceivablePlanPage = async (params) => { + return await request.get({ url: `/crm/receivable-plan/page`, params }) +} + +// 查询回款计划列表 +export const getReceivablePlanPageByCustomer = async (params) => { + return await request.get({ url: `/crm/receivable-plan/page-by-customer`, params }) +} + +// 查询回款计划详情 +export const getReceivablePlan = async (id: number) => { + return await request.get({ url: `/crm/receivable-plan/get?id=` + id }) +} + +// 查询回款计划下拉数据 +export const getReceivablePlanSimpleList = async (customerId: number, contractId: number) => { + return await request.get({ + url: `/crm/receivable-plan/simple-list?customerId=${customerId}&contractId=${contractId}` + }) +} + +// 新增回款计划 +export const createReceivablePlan = async (data: ReceivablePlanVO) => { + return await request.post({ url: `/crm/receivable-plan/create`, data }) +} + +// 修改回款计划 +export const updateReceivablePlan = async (data: ReceivablePlanVO) => { + return await request.put({ url: `/crm/receivable-plan/update`, data }) +} + +// 删除回款计划 +export const deleteReceivablePlan = async (id: number) => { + return await request.delete({ url: `/crm/receivable-plan/delete?id=` + id }) +} + +// 导出回款计划 Excel +export const exportReceivablePlan = async (params) => { + return await request.download({ url: `/crm/receivable-plan/export-excel`, params }) +} + +// 获得待回款提醒数量 +export const getReceivablePlanRemindCount = async () => { + return await request.get({ url: '/crm/receivable-plan/remind-count' }) +} diff --git a/src/api/crm/statistics/customer.ts b/src/api/crm/statistics/customer.ts new file mode 100644 index 0000000..c2092e4 --- /dev/null +++ b/src/api/crm/statistics/customer.ts @@ -0,0 +1,168 @@ +import request from '@/config/axios' + +export interface CrmStatisticsCustomerSummaryByDateRespVO { + time: string + customerCreateCount: number + customerDealCount: number +} + +export interface CrmStatisticsCustomerSummaryByUserRespVO { + ownerUserName: string + customerCreateCount: number + customerDealCount: number + contractPrice: number + receivablePrice: number +} + +export interface CrmStatisticsFollowUpSummaryByDateRespVO { + time: string + followUpRecordCount: number + followUpCustomerCount: number +} + +export interface CrmStatisticsFollowUpSummaryByUserRespVO { + ownerUserName: string + followupRecordCount: number + followupCustomerCount: number +} + +export interface CrmStatisticsFollowUpSummaryByTypeRespVO { + followUpType: string + followUpRecordCount: number +} + +export interface CrmStatisticsCustomerContractSummaryRespVO { + customerName: string + contractName: string + totalPrice: number + receivablePrice: number + customerType: string + customerSource: string + ownerUserName: string + creatorUserName: string + createTime: Date + orderDate: Date +} + +export interface CrmStatisticsPoolSummaryByDateRespVO { + time: string + customerPutCount: number + customerTakeCount: number +} + +export interface CrmStatisticsPoolSummaryByUserRespVO { + ownerUserName: string + customerPutCount: number + customerTakeCount: number +} + +export interface CrmStatisticsCustomerDealCycleByDateRespVO { + time: string + customerDealCycle: number +} + +export interface CrmStatisticsCustomerDealCycleByUserRespVO { + ownerUserName: string + customerDealCycle: number + customerDealCount: number +} + +export interface CrmStatisticsCustomerDealCycleByAreaRespVO { + areaName: string + customerDealCycle: number + customerDealCount: number +} + +export interface CrmStatisticsCustomerDealCycleByProductRespVO { + productName: string + customerDealCycle: number + customerDealCount: number +} + +// 客户分析 API +export const StatisticsCustomerApi = { + // 1.1 客户总量分析(按日期) + getCustomerSummaryByDate: (params: any) => { + return request.get({ + url: '/crm/statistics-customer/get-customer-summary-by-date', + params + }) + }, + // 1.2 客户总量分析(按用户) + getCustomerSummaryByUser: (params: any) => { + return request.get({ + url: '/crm/statistics-customer/get-customer-summary-by-user', + params + }) + }, + // 2.1 客户跟进次数分析(按日期) + getFollowUpSummaryByDate: (params: any) => { + return request.get({ + url: '/crm/statistics-customer/get-follow-up-summary-by-date', + params + }) + }, + // 2.2 客户跟进次数分析(按用户) + getFollowUpSummaryByUser: (params: any) => { + return request.get({ + url: '/crm/statistics-customer/get-follow-up-summary-by-user', + params + }) + }, + // 3.1 获取客户跟进方式统计数 + getFollowUpSummaryByType: (params: any) => { + return request.get({ + url: '/crm/statistics-customer/get-follow-up-summary-by-type', + params + }) + }, + // 4.1 合同摘要信息(客户转化率页面) + getContractSummary: (params: any) => { + return request.get({ + url: '/crm/statistics-customer/get-contract-summary', + params + }) + }, + // 5.1 获取客户公海分析(按日期) + getPoolSummaryByDate: (param: any) => { + return request.get({ + url: '/crm/statistics-customer/get-pool-summary-by-date', + params: param + }) + }, + // 5.2 获取客户公海分析(按用户) + getPoolSummaryByUser: (param: any) => { + return request.get({ + url: '/crm/statistics-customer/get-pool-summary-by-user', + params: param + }) + }, + // 6.1 获取客户成交周期(按日期) + getCustomerDealCycleByDate: (params: any) => { + return request.get({ + url: '/crm/statistics-customer/get-customer-deal-cycle-by-date', + params + }) + }, + // 6.2 获取客户成交周期(按用户) + getCustomerDealCycleByUser: (params: any) => { + return request.get({ + url: '/crm/statistics-customer/get-customer-deal-cycle-by-user', + params + }) + }, + // 6.2 获取客户成交周期(按用户) + getCustomerDealCycleByArea: (params: any) => { + return request.get({ + url: '/crm/statistics-customer/get-customer-deal-cycle-by-area', + params + }) + }, + // 6.2 获取客户成交周期(按用户) + getCustomerDealCycleByProduct: (params: any) => { + return request.get({ + url: '/crm/statistics-customer/get-customer-deal-cycle-by-product', + params + }) + } +} diff --git a/src/api/crm/statistics/funnel.ts b/src/api/crm/statistics/funnel.ts new file mode 100644 index 0000000..574a5f4 --- /dev/null +++ b/src/api/crm/statistics/funnel.ts @@ -0,0 +1,58 @@ +import request from '@/config/axios' + +export interface CrmStatisticFunnelRespVO { + customerCount: number // 客户数 + businessCount: number // 商机数 + businessWinCount: number // 赢单数 +} + +export interface CrmStatisticsBusinessSummaryByDateRespVO { + time: string // 时间 + businessCreateCount: number // 商机数 + totalPrice: number | string // 商机金额 +} + +export interface CrmStatisticsBusinessInversionRateSummaryByDateRespVO { + time: string // 时间 + businessCount: number // 商机数量 + businessWinCount: number // 赢单商机数 +} + +// 客户分析 API +export const StatisticFunnelApi = { + // 1. 获取销售漏斗统计数据 + getFunnelSummary: (params: any) => { + return request.get({ + url: '/crm/statistics-funnel/get-funnel-summary', + params + }) + }, + // 2. 获取商机结束状态统计 + getBusinessSummaryByEndStatus: (params: any) => { + return request.get({ + url: '/crm/statistics-funnel/get-business-summary-by-end-status', + params + }) + }, + // 3. 获取新增商机分析(按日期) + getBusinessSummaryByDate: (params: any) => { + return request.get({ + url: '/crm/statistics-funnel/get-business-summary-by-date', + params + }) + }, + // 4. 获取商机转化率分析(按日期) + getBusinessInversionRateSummaryByDate: (params: any) => { + return request.get({ + url: '/crm/statistics-funnel/get-business-inversion-rate-summary-by-date', + params + }) + }, + // 5. 获取商机列表(按日期) + getBusinessPageByDate: (params: any) => { + return request.get({ + url: '/crm/statistics-funnel/get-business-page-by-date', + params + }) + } +} diff --git a/src/api/crm/statistics/performance.ts b/src/api/crm/statistics/performance.ts new file mode 100644 index 0000000..2318505 --- /dev/null +++ b/src/api/crm/statistics/performance.ts @@ -0,0 +1,33 @@ +import request from '@/config/axios' + +export interface StatisticsPerformanceRespVO { + time: string + currentMonthCount: number + lastMonthCount: number + lastYearCount: number +} + +// 排行 API +export const StatisticsPerformanceApi = { + // 员工获得合同金额统计 + getContractPricePerformance: (params: any) => { + return request.get({ + url: '/crm/statistics-performance/get-contract-price-performance', + params + }) + }, + // 员工获得回款统计 + getReceivablePricePerformance: (params: any) => { + return request.get({ + url: '/crm/statistics-performance/get-receivable-price-performance', + params + }) + }, + //员工获得签约合同数量统计 + getContractCountPerformance: (params: any) => { + return request.get({ + url: '/crm/statistics-performance/get-contract-count-performance', + params + }) + } +} diff --git a/src/api/crm/statistics/portrait.ts b/src/api/crm/statistics/portrait.ts new file mode 100644 index 0000000..c7a2572 --- /dev/null +++ b/src/api/crm/statistics/portrait.ts @@ -0,0 +1,60 @@ +import request from '@/config/axios' + +export interface CrmStatisticCustomerBaseRespVO { + customerCount: number + dealCount: number + dealPortion: string | number +} + +export interface CrmStatisticCustomerIndustryRespVO extends CrmStatisticCustomerBaseRespVO { + industryId: number + industryPortion: string | number +} + +export interface CrmStatisticCustomerSourceRespVO extends CrmStatisticCustomerBaseRespVO { + source: number + sourcePortion: string | number +} + +export interface CrmStatisticCustomerLevelRespVO extends CrmStatisticCustomerBaseRespVO { + level: number + levelPortion: string | number +} + +export interface CrmStatisticCustomerAreaRespVO extends CrmStatisticCustomerBaseRespVO { + areaId: number + areaName: string + areaPortion: string | number +} + +// 客户分析 API +export const StatisticsPortraitApi = { + // 1. 获取客户行业统计数据 + getCustomerIndustry: (params: any) => { + return request.get({ + url: '/crm/statistics-portrait/get-customer-industry-summary', + params + }) + }, + // 2. 获取客户来源统计数据 + getCustomerSource: (params: any) => { + return request.get({ + url: '/crm/statistics-portrait/get-customer-source-summary', + params + }) + }, + // 3. 获取客户级别统计数据 + getCustomerLevel: (params: any) => { + return request.get({ + url: '/crm/statistics-portrait/get-customer-level-summary', + params + }) + }, + // 4. 获取客户地区统计数据 + getCustomerArea: (params: any) => { + return request.get({ + url: '/crm/statistics-portrait/get-customer-area-summary', + params + }) + } +} diff --git a/src/api/crm/statistics/rank.ts b/src/api/crm/statistics/rank.ts new file mode 100644 index 0000000..a9b355e --- /dev/null +++ b/src/api/crm/statistics/rank.ts @@ -0,0 +1,67 @@ +import request from '@/config/axios' + +export interface StatisticsRankRespVO { + count: number + nickname: string + deptName: string +} + +// 排行 API +export const StatisticsRankApi = { + // 获得合同排行榜 + getContractPriceRank: (params: any) => { + return request.get({ + url: '/crm/statistics-rank/get-contract-price-rank', + params + }) + }, + // 获得回款排行榜 + getReceivablePriceRank: (params: any) => { + return request.get({ + url: '/crm/statistics-rank/get-receivable-price-rank', + params + }) + }, + // 签约合同排行 + getContractCountRank: (params: any) => { + return request.get({ + url: '/crm/statistics-rank/get-contract-count-rank', + params + }) + }, + // 产品销量排行 + getProductSalesRank: (params: any) => { + return request.get({ + url: '/crm/statistics-rank/get-product-sales-rank', + params + }) + }, + // 新增客户数排行 + getCustomerCountRank: (params: any) => { + return request.get({ + url: '/crm/statistics-rank/get-customer-count-rank', + params + }) + }, + // 新增联系人数排行 + getContactsCountRank: (params: any) => { + return request.get({ + url: '/crm/statistics-rank/get-contacts-count-rank', + params + }) + }, + // 跟进次数排行 + getFollowCountRank: (params: any) => { + return request.get({ + url: '/crm/statistics-rank/get-follow-count-rank', + params + }) + }, + // 跟进客户数排行 + getFollowCustomerCountRank: (params: any) => { + return request.get({ + url: '/crm/statistics-rank/get-follow-customer-count-rank', + params + }) + } +} diff --git a/src/api/erp/finance/account/index.ts b/src/api/erp/finance/account/index.ts new file mode 100644 index 0000000..a62b180 --- /dev/null +++ b/src/api/erp/finance/account/index.ts @@ -0,0 +1,61 @@ +import request from '@/config/axios' + +// ERP 结算账户 VO +export interface AccountVO { + id: number // 结算账户编号 + no: string // 账户编码 + remark: string // 备注 + status: number // 开启状态 + sort: number // 排序 + defaultStatus: boolean // 是否默认 + name: string // 账户名称 +} + +// ERP 结算账户 API +export const AccountApi = { + // 查询结算账户分页 + getAccountPage: async (params: any) => { + return await request.get({ url: `/erp/account/page`, params }) + }, + + // 查询结算账户精简列表 + getAccountSimpleList: async () => { + return await request.get({ url: `/erp/account/simple-list` }) + }, + + // 查询结算账户详情 + getAccount: async (id: number) => { + return await request.get({ url: `/erp/account/get?id=` + id }) + }, + + // 新增结算账户 + createAccount: async (data: AccountVO) => { + return await request.post({ url: `/erp/account/create`, data }) + }, + + // 修改结算账户 + updateAccount: async (data: AccountVO) => { + return await request.put({ url: `/erp/account/update`, data }) + }, + + // 修改结算账户默认状态 + updateAccountDefaultStatus: async (id: number, defaultStatus: boolean) => { + return await request.put({ + url: `/erp/account/update-default-status`, + params: { + id, + defaultStatus + } + }) + }, + + // 删除结算账户 + deleteAccount: async (id: number) => { + return await request.delete({ url: `/erp/account/delete?id=` + id }) + }, + + // 导出结算账户 Excel + exportAccount: async (params: any) => { + return await request.download({ url: `/erp/account/export-excel`, params }) + } +} diff --git a/src/api/erp/finance/payment/index.ts b/src/api/erp/finance/payment/index.ts new file mode 100644 index 0000000..c6749db --- /dev/null +++ b/src/api/erp/finance/payment/index.ts @@ -0,0 +1,61 @@ +import request from '@/config/axios' + +// ERP 付款单 VO +export interface FinancePaymentVO { + id: number // 付款单编号 + no: string // 付款单号 + supplierId: number // 供应商编号 + paymentTime: Date // 付款时间 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 +} + +// ERP 付款单 API +export const FinancePaymentApi = { + // 查询付款单分页 + getFinancePaymentPage: async (params: any) => { + return await request.get({ url: `/erp/finance-payment/page`, params }) + }, + + // 查询付款单详情 + getFinancePayment: async (id: number) => { + return await request.get({ url: `/erp/finance-payment/get?id=` + id }) + }, + + // 新增付款单 + createFinancePayment: async (data: FinancePaymentVO) => { + return await request.post({ url: `/erp/finance-payment/create`, data }) + }, + + // 修改付款单 + updateFinancePayment: async (data: FinancePaymentVO) => { + return await request.put({ url: `/erp/finance-payment/update`, data }) + }, + + // 更新付款单的状态 + updateFinancePaymentStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/finance-payment/update-status`, + params: { + id, + status + } + }) + }, + + // 删除付款单 + deleteFinancePayment: async (ids: number[]) => { + return await request.delete({ + url: `/erp/finance-payment/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出付款单 Excel + exportFinancePayment: async (params: any) => { + return await request.download({ url: `/erp/finance-payment/export-excel`, params }) + } +} diff --git a/src/api/erp/finance/receipt/index.ts b/src/api/erp/finance/receipt/index.ts new file mode 100644 index 0000000..4de28ca --- /dev/null +++ b/src/api/erp/finance/receipt/index.ts @@ -0,0 +1,61 @@ +import request from '@/config/axios' + +// ERP 收款单 VO +export interface FinanceReceiptVO { + id: number // 收款单编号 + no: string // 收款单号 + customerId: number // 客户编号 + receiptTime: Date // 收款时间 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 +} + +// ERP 收款单 API +export const FinanceReceiptApi = { + // 查询收款单分页 + getFinanceReceiptPage: async (params: any) => { + return await request.get({ url: `/erp/finance-receipt/page`, params }) + }, + + // 查询收款单详情 + getFinanceReceipt: async (id: number) => { + return await request.get({ url: `/erp/finance-receipt/get?id=` + id }) + }, + + // 新增收款单 + createFinanceReceipt: async (data: FinanceReceiptVO) => { + return await request.post({ url: `/erp/finance-receipt/create`, data }) + }, + + // 修改收款单 + updateFinanceReceipt: async (data: FinanceReceiptVO) => { + return await request.put({ url: `/erp/finance-receipt/update`, data }) + }, + + // 更新收款单的状态 + updateFinanceReceiptStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/finance-receipt/update-status`, + params: { + id, + status + } + }) + }, + + // 删除收款单 + deleteFinanceReceipt: async (ids: number[]) => { + return await request.delete({ + url: `/erp/finance-receipt/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出收款单 Excel + exportFinanceReceipt: async (params: any) => { + return await request.download({ url: `/erp/finance-receipt/export-excel`, params }) + } +} diff --git a/src/api/erp/product/category/index.ts b/src/api/erp/product/category/index.ts new file mode 100644 index 0000000..d67ccff --- /dev/null +++ b/src/api/erp/product/category/index.ts @@ -0,0 +1,49 @@ +import request from '@/config/axios' + +// ERP 产品分类 VO +export interface ProductCategoryVO { + id: number // 分类编号 + parentId: number // 父分类编号 + name: string // 分类名称 + code: string // 分类编码 + sort: number // 分类排序 + status: number // 开启状态 +} + +// ERP 产品分类 API +export const ProductCategoryApi = { + // 查询产品分类列表 + getProductCategoryList: async () => { + return await request.get({ url: `/erp/product-category/list` }) + }, + + // 查询产品分类精简列表 + getProductCategorySimpleList: async () => { + return await request.get({ url: `/erp/product-category/simple-list` }) + }, + + // 查询产品分类详情 + getProductCategory: async (id: number) => { + return await request.get({ url: `/erp/product-category/get?id=` + id }) + }, + + // 新增产品分类 + createProductCategory: async (data: ProductCategoryVO) => { + return await request.post({ url: `/erp/product-category/create`, data }) + }, + + // 修改产品分类 + updateProductCategory: async (data: ProductCategoryVO) => { + return await request.put({ url: `/erp/product-category/update`, data }) + }, + + // 删除产品分类 + deleteProductCategory: async (id: number) => { + return await request.delete({ url: `/erp/product-category/delete?id=` + id }) + }, + + // 导出产品分类 Excel + exportProductCategory: async (params) => { + return await request.download({ url: `/erp/product-category/export-excel`, params }) + } +} diff --git a/src/api/erp/product/product/index.ts b/src/api/erp/product/product/index.ts new file mode 100644 index 0000000..1136282 --- /dev/null +++ b/src/api/erp/product/product/index.ts @@ -0,0 +1,57 @@ +import request from '@/config/axios' + +// ERP 产品 VO +export interface ProductVO { + id: number // 产品编号 + name: string // 产品名称 + barCode: string // 产品条码 + categoryId: number // 产品类型编号 + unitId: number // 单位编号 + unitName?: string // 单位名字 + status: number // 产品状态 + standard: string // 产品规格 + remark: string // 产品备注 + expiryDay: number // 保质期天数 + weight: number // 重量(kg) + purchasePrice: number // 采购价格,单位:元 + salePrice: number // 销售价格,单位:元 + minPrice: number // 最低价格,单位:元 +} + +// ERP 产品 API +export const ProductApi = { + // 查询产品分页 + getProductPage: async (params: any) => { + return await request.get({ url: `/erp/product/page`, params }) + }, + + // 查询产品精简列表 + getProductSimpleList: async () => { + return await request.get({ url: `/erp/product/simple-list` }) + }, + + // 查询产品详情 + getProduct: async (id: number) => { + return await request.get({ url: `/erp/product/get?id=` + id }) + }, + + // 新增产品 + createProduct: async (data: ProductVO) => { + return await request.post({ url: `/erp/product/create`, data }) + }, + + // 修改产品 + updateProduct: async (data: ProductVO) => { + return await request.put({ url: `/erp/product/update`, data }) + }, + + // 删除产品 + deleteProduct: async (id: number) => { + return await request.delete({ url: `/erp/product/delete?id=` + id }) + }, + + // 导出产品 Excel + exportProduct: async (params) => { + return await request.download({ url: `/erp/product/export-excel`, params }) + } +} diff --git a/src/api/erp/product/unit/index.ts b/src/api/erp/product/unit/index.ts new file mode 100644 index 0000000..1e1c8ac --- /dev/null +++ b/src/api/erp/product/unit/index.ts @@ -0,0 +1,46 @@ +import request from '@/config/axios' + +// ERP 产品单位 VO +export interface ProductUnitVO { + id: number // 单位编号 + name: string // 单位名字 + status: number // 单位状态 +} + +// ERP 产品单位 API +export const ProductUnitApi = { + // 查询产品单位分页 + getProductUnitPage: async (params: any) => { + return await request.get({ url: `/erp/product-unit/page`, params }) + }, + + // 查询产品单位精简列表 + getProductUnitSimpleList: async () => { + return await request.get({ url: `/erp/product-unit/simple-list` }) + }, + + // 查询产品单位详情 + getProductUnit: async (id: number) => { + return await request.get({ url: `/erp/product-unit/get?id=` + id }) + }, + + // 新增产品单位 + createProductUnit: async (data: ProductUnitVO) => { + return await request.post({ url: `/erp/product-unit/create`, data }) + }, + + // 修改产品单位 + updateProductUnit: async (data: ProductUnitVO) => { + return await request.put({ url: `/erp/product-unit/update`, data }) + }, + + // 删除产品单位 + deleteProductUnit: async (id: number) => { + return await request.delete({ url: `/erp/product-unit/delete?id=` + id }) + }, + + // 导出产品单位 Excel + exportProductUnit: async (params) => { + return await request.download({ url: `/erp/product-unit/export-excel`, params }) + } +} diff --git a/src/api/erp/purchase/in/index.ts b/src/api/erp/purchase/in/index.ts new file mode 100644 index 0000000..f94708d --- /dev/null +++ b/src/api/erp/purchase/in/index.ts @@ -0,0 +1,64 @@ +import request from '@/config/axios' + +// ERP 采购入库 VO +export interface PurchaseInVO { + id: number // 入库工单编号 + no: string // 采购入库号 + customerId: number // 客户编号 + inTime: Date // 入库时间 + totalCount: number // 合计数量 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 + outCount: number // 采购出库数量 + returnCount: number // 采购退货数量 +} + +// ERP 采购入库 API +export const PurchaseInApi = { + // 查询采购入库分页 + getPurchaseInPage: async (params: any) => { + return await request.get({ url: `/erp/purchase-in/page`, params }) + }, + + // 查询采购入库详情 + getPurchaseIn: async (id: number) => { + return await request.get({ url: `/erp/purchase-in/get?id=` + id }) + }, + + // 新增采购入库 + createPurchaseIn: async (data: PurchaseInVO) => { + return await request.post({ url: `/erp/purchase-in/create`, data }) + }, + + // 修改采购入库 + updatePurchaseIn: async (data: PurchaseInVO) => { + return await request.put({ url: `/erp/purchase-in/update`, data }) + }, + + // 更新采购入库的状态 + updatePurchaseInStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/purchase-in/update-status`, + params: { + id, + status + } + }) + }, + + // 删除采购入库 + deletePurchaseIn: async (ids: number[]) => { + return await request.delete({ + url: `/erp/purchase-in/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出采购入库 Excel + exportPurchaseIn: async (params: any) => { + return await request.download({ url: `/erp/purchase-in/export-excel`, params }) + } +} diff --git a/src/api/erp/purchase/order/index.ts b/src/api/erp/purchase/order/index.ts new file mode 100644 index 0000000..ad3222f --- /dev/null +++ b/src/api/erp/purchase/order/index.ts @@ -0,0 +1,64 @@ +import request from '@/config/axios' + +// ERP 采购订单 VO +export interface PurchaseOrderVO { + id: number // 订单工单编号 + no: string // 采购订单号 + customerId: number // 客户编号 + orderTime: Date // 订单时间 + totalCount: number // 合计数量 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 + outCount: number // 采购出库数量 + returnCount: number // 采购退货数量 +} + +// ERP 采购订单 API +export const PurchaseOrderApi = { + // 查询采购订单分页 + getPurchaseOrderPage: async (params: any) => { + return await request.get({ url: `/erp/purchase-order/page`, params }) + }, + + // 查询采购订单详情 + getPurchaseOrder: async (id: number) => { + return await request.get({ url: `/erp/purchase-order/get?id=` + id }) + }, + + // 新增采购订单 + createPurchaseOrder: async (data: PurchaseOrderVO) => { + return await request.post({ url: `/erp/purchase-order/create`, data }) + }, + + // 修改采购订单 + updatePurchaseOrder: async (data: PurchaseOrderVO) => { + return await request.put({ url: `/erp/purchase-order/update`, data }) + }, + + // 更新采购订单的状态 + updatePurchaseOrderStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/purchase-order/update-status`, + params: { + id, + status + } + }) + }, + + // 删除采购订单 + deletePurchaseOrder: async (ids: number[]) => { + return await request.delete({ + url: `/erp/purchase-order/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出采购订单 Excel + exportPurchaseOrder: async (params: any) => { + return await request.download({ url: `/erp/purchase-order/export-excel`, params }) + } +} diff --git a/src/api/erp/purchase/return/index.ts b/src/api/erp/purchase/return/index.ts new file mode 100644 index 0000000..182e04e --- /dev/null +++ b/src/api/erp/purchase/return/index.ts @@ -0,0 +1,62 @@ +import request from '@/config/axios' + +// ERP 采购退货 VO +export interface PurchaseReturnVO { + id: number // 采购退货编号 + no: string // 采购退货号 + customerId: number // 客户编号 + returnTime: Date // 退货时间 + totalCount: number // 合计数量 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 +} + +// ERP 采购退货 API +export const PurchaseReturnApi = { + // 查询采购退货分页 + getPurchaseReturnPage: async (params: any) => { + return await request.get({ url: `/erp/purchase-return/page`, params }) + }, + + // 查询采购退货详情 + getPurchaseReturn: async (id: number) => { + return await request.get({ url: `/erp/purchase-return/get?id=` + id }) + }, + + // 新增采购退货 + createPurchaseReturn: async (data: PurchaseReturnVO) => { + return await request.post({ url: `/erp/purchase-return/create`, data }) + }, + + // 修改采购退货 + updatePurchaseReturn: async (data: PurchaseReturnVO) => { + return await request.put({ url: `/erp/purchase-return/update`, data }) + }, + + // 更新采购退货的状态 + updatePurchaseReturnStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/purchase-return/update-status`, + params: { + id, + status + } + }) + }, + + // 删除采购退货 + deletePurchaseReturn: async (ids: number[]) => { + return await request.delete({ + url: `/erp/purchase-return/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出采购退货 Excel + exportPurchaseReturn: async (params: any) => { + return await request.download({ url: `/erp/purchase-return/export-excel`, params }) + } +} diff --git a/src/api/erp/purchase/supplier/index.ts b/src/api/erp/purchase/supplier/index.ts new file mode 100644 index 0000000..34729a5 --- /dev/null +++ b/src/api/erp/purchase/supplier/index.ts @@ -0,0 +1,58 @@ +import request from '@/config/axios' + +// ERP 供应商 VO +export interface SupplierVO { + id: number // 供应商编号 + name: string // 供应商名称 + contact: string // 联系人 + mobile: string // 手机号码 + telephone: string // 联系电话 + email: string // 电子邮箱 + fax: string // 传真 + remark: string // 备注 + status: number // 开启状态 + sort: number // 排序 + taxNo: string // 纳税人识别号 + taxPercent: number // 税率 + bankName: string // 开户行 + bankAccount: string // 开户账号 + bankAddress: string // 开户地址 +} + +// ERP 供应商 API +export const SupplierApi = { + // 查询供应商分页 + getSupplierPage: async (params: any) => { + return await request.get({ url: `/erp/supplier/page`, params }) + }, + + // 获得供应商精简列表 + getSupplierSimpleList: async () => { + return await request.get({ url: `/erp/supplier/simple-list` }) + }, + + // 查询供应商详情 + getSupplier: async (id: number) => { + return await request.get({ url: `/erp/supplier/get?id=` + id }) + }, + + // 新增供应商 + createSupplier: async (data: SupplierVO) => { + return await request.post({ url: `/erp/supplier/create`, data }) + }, + + // 修改供应商 + updateSupplier: async (data: SupplierVO) => { + return await request.put({ url: `/erp/supplier/update`, data }) + }, + + // 删除供应商 + deleteSupplier: async (id: number) => { + return await request.delete({ url: `/erp/supplier/delete?id=` + id }) + }, + + // 导出供应商 Excel + exportSupplier: async (params) => { + return await request.download({ url: `/erp/supplier/export-excel`, params }) + } +} diff --git a/src/api/erp/sale/customer/index.ts b/src/api/erp/sale/customer/index.ts new file mode 100644 index 0000000..3aaefb5 --- /dev/null +++ b/src/api/erp/sale/customer/index.ts @@ -0,0 +1,58 @@ +import request from '@/config/axios' + +// ERP 客户 VO +export interface CustomerVO { + id: number // 客户编号 + name: string // 客户名称 + contact: string // 联系人 + mobile: string // 手机号码 + telephone: string // 联系电话 + email: string // 电子邮箱 + fax: string // 传真 + remark: string // 备注 + status: number // 开启状态 + sort: number // 排序 + taxNo: string // 纳税人识别号 + taxPercent: number // 税率 + bankName: string // 开户行 + bankAccount: string // 开户账号 + bankAddress: string // 开户地址 +} + +// ERP 客户 API +export const CustomerApi = { + // 查询客户分页 + getCustomerPage: async (params: any) => { + return await request.get({ url: `/erp/customer/page`, params }) + }, + + // 查询客户精简列表 + getCustomerSimpleList: async () => { + return await request.get({ url: `/erp/customer/simple-list` }) + }, + + // 查询客户详情 + getCustomer: async (id: number) => { + return await request.get({ url: `/erp/customer/get?id=` + id }) + }, + + // 新增客户 + createCustomer: async (data: CustomerVO) => { + return await request.post({ url: `/erp/customer/create`, data }) + }, + + // 修改客户 + updateCustomer: async (data: CustomerVO) => { + return await request.put({ url: `/erp/customer/update`, data }) + }, + + // 删除客户 + deleteCustomer: async (id: number) => { + return await request.delete({ url: `/erp/customer/delete?id=` + id }) + }, + + // 导出客户 Excel + exportCustomer: async (params) => { + return await request.download({ url: `/erp/customer/export-excel`, params }) + } +} diff --git a/src/api/erp/sale/order/index.ts b/src/api/erp/sale/order/index.ts new file mode 100644 index 0000000..2d2ac53 --- /dev/null +++ b/src/api/erp/sale/order/index.ts @@ -0,0 +1,64 @@ +import request from '@/config/axios' + +// ERP 销售订单 VO +export interface SaleOrderVO { + id: number // 订单工单编号 + no: string // 销售订单号 + customerId: number // 客户编号 + orderTime: Date // 订单时间 + totalCount: number // 合计数量 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 + outCount: number // 销售出库数量 + returnCount: number // 销售退货数量 +} + +// ERP 销售订单 API +export const SaleOrderApi = { + // 查询销售订单分页 + getSaleOrderPage: async (params: any) => { + return await request.get({ url: `/erp/sale-order/page`, params }) + }, + + // 查询销售订单详情 + getSaleOrder: async (id: number) => { + return await request.get({ url: `/erp/sale-order/get?id=` + id }) + }, + + // 新增销售订单 + createSaleOrder: async (data: SaleOrderVO) => { + return await request.post({ url: `/erp/sale-order/create`, data }) + }, + + // 修改销售订单 + updateSaleOrder: async (data: SaleOrderVO) => { + return await request.put({ url: `/erp/sale-order/update`, data }) + }, + + // 更新销售订单的状态 + updateSaleOrderStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/sale-order/update-status`, + params: { + id, + status + } + }) + }, + + // 删除销售订单 + deleteSaleOrder: async (ids: number[]) => { + return await request.delete({ + url: `/erp/sale-order/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出销售订单 Excel + exportSaleOrder: async (params: any) => { + return await request.download({ url: `/erp/sale-order/export-excel`, params }) + } +} diff --git a/src/api/erp/sale/out/index.ts b/src/api/erp/sale/out/index.ts new file mode 100644 index 0000000..cbc605e --- /dev/null +++ b/src/api/erp/sale/out/index.ts @@ -0,0 +1,62 @@ +import request from '@/config/axios' + +// ERP 销售出库 VO +export interface SaleOutVO { + id: number // 销售出库编号 + no: string // 销售出库号 + customerId: number // 客户编号 + outTime: Date // 出库时间 + totalCount: number // 合计数量 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 +} + +// ERP 销售出库 API +export const SaleOutApi = { + // 查询销售出库分页 + getSaleOutPage: async (params: any) => { + return await request.get({ url: `/erp/sale-out/page`, params }) + }, + + // 查询销售出库详情 + getSaleOut: async (id: number) => { + return await request.get({ url: `/erp/sale-out/get?id=` + id }) + }, + + // 新增销售出库 + createSaleOut: async (data: SaleOutVO) => { + return await request.post({ url: `/erp/sale-out/create`, data }) + }, + + // 修改销售出库 + updateSaleOut: async (data: SaleOutVO) => { + return await request.put({ url: `/erp/sale-out/update`, data }) + }, + + // 更新销售出库的状态 + updateSaleOutStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/sale-out/update-status`, + params: { + id, + status + } + }) + }, + + // 删除销售出库 + deleteSaleOut: async (ids: number[]) => { + return await request.delete({ + url: `/erp/sale-out/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出销售出库 Excel + exportSaleOut: async (params: any) => { + return await request.download({ url: `/erp/sale-out/export-excel`, params }) + } +} diff --git a/src/api/erp/sale/return/index.ts b/src/api/erp/sale/return/index.ts new file mode 100644 index 0000000..160ac01 --- /dev/null +++ b/src/api/erp/sale/return/index.ts @@ -0,0 +1,62 @@ +import request from '@/config/axios' + +// ERP 销售退货 VO +export interface SaleReturnVO { + id: number // 销售退货编号 + no: string // 销售退货号 + customerId: number // 客户编号 + returnTime: Date // 退货时间 + totalCount: number // 合计数量 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 +} + +// ERP 销售退货 API +export const SaleReturnApi = { + // 查询销售退货分页 + getSaleReturnPage: async (params: any) => { + return await request.get({ url: `/erp/sale-return/page`, params }) + }, + + // 查询销售退货详情 + getSaleReturn: async (id: number) => { + return await request.get({ url: `/erp/sale-return/get?id=` + id }) + }, + + // 新增销售退货 + createSaleReturn: async (data: SaleReturnVO) => { + return await request.post({ url: `/erp/sale-return/create`, data }) + }, + + // 修改销售退货 + updateSaleReturn: async (data: SaleReturnVO) => { + return await request.put({ url: `/erp/sale-return/update`, data }) + }, + + // 更新销售退货的状态 + updateSaleReturnStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/sale-return/update-status`, + params: { + id, + status + } + }) + }, + + // 删除销售退货 + deleteSaleReturn: async (ids: number[]) => { + return await request.delete({ + url: `/erp/sale-return/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出销售退货 Excel + exportSaleReturn: async (params: any) => { + return await request.download({ url: `/erp/sale-return/export-excel`, params }) + } +} diff --git a/src/api/erp/statistics/purchase/index.ts b/src/api/erp/statistics/purchase/index.ts new file mode 100644 index 0000000..80d907a --- /dev/null +++ b/src/api/erp/statistics/purchase/index.ts @@ -0,0 +1,28 @@ +import request from '@/config/axios' + +// ERP 采购全局统计 VO +export interface ErpPurchaseSummaryRespVO { + todayPrice: number // 今日采购金额 + yesterdayPrice: number // 昨日采购金额 + monthPrice: number // 本月采购金额 + yearPrice: number // 今年采购金额 +} + +// ERP 采购时间段统计 VO +export interface ErpPurchaseTimeSummaryRespVO { + time: string // 时间 + price: number // 采购金额 +} + +// ERP 采购统计 API +export const PurchaseStatisticsApi = { + // 获得采购统计 + getPurchaseSummary: async (): Promise<ErpPurchaseSummaryRespVO> => { + return await request.get({ url: `/erp/purchase-statistics/summary` }) + }, + + // 获得采购时间段统计 + getPurchaseTimeSummary: async (): Promise<ErpPurchaseTimeSummaryRespVO[]> => { + return await request.get({ url: `/erp/purchase-statistics/time-summary` }) + } +} diff --git a/src/api/erp/statistics/sale/index.ts b/src/api/erp/statistics/sale/index.ts new file mode 100644 index 0000000..09d8500 --- /dev/null +++ b/src/api/erp/statistics/sale/index.ts @@ -0,0 +1,28 @@ +import request from '@/config/axios' + +// ERP 销售全局统计 VO +export interface ErpSaleSummaryRespVO { + todayPrice: number // 今日销售金额 + yesterdayPrice: number // 昨日销售金额 + monthPrice: number // 本月销售金额 + yearPrice: number // 今年销售金额 +} + +// ERP 销售时间段统计 VO +export interface ErpSaleTimeSummaryRespVO { + time: string // 时间 + price: number // 销售金额 +} + +// ERP 销售统计 API +export const SaleStatisticsApi = { + // 获得销售统计 + getSaleSummary: async (): Promise<ErpSaleSummaryRespVO> => { + return await request.get({ url: `/erp/sale-statistics/summary` }) + }, + + // 获得销售时间段统计 + getSaleTimeSummary: async (): Promise<ErpSaleTimeSummaryRespVO[]> => { + return await request.get({ url: `/erp/sale-statistics/time-summary` }) + } +} diff --git a/src/api/erp/stock/check/index.ts b/src/api/erp/stock/check/index.ts new file mode 100644 index 0000000..4a3e653 --- /dev/null +++ b/src/api/erp/stock/check/index.ts @@ -0,0 +1,61 @@ +import request from '@/config/axios' + +// ERP 库存盘点单 VO +export interface StockCheckVO { + id: number // 出库编号 + no: string // 出库单号 + outTime: Date // 出库时间 + totalCount: number // 合计数量 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 +} + +// ERP 库存盘点单 API +export const StockCheckApi = { + // 查询库存盘点单分页 + getStockCheckPage: async (params: any) => { + return await request.get({ url: `/erp/stock-check/page`, params }) + }, + + // 查询库存盘点单详情 + getStockCheck: async (id: number) => { + return await request.get({ url: `/erp/stock-check/get?id=` + id }) + }, + + // 新增库存盘点单 + createStockCheck: async (data: StockCheckVO) => { + return await request.post({ url: `/erp/stock-check/create`, data }) + }, + + // 修改库存盘点单 + updateStockCheck: async (data: StockCheckVO) => { + return await request.put({ url: `/erp/stock-check/update`, data }) + }, + + // 更新库存盘点单的状态 + updateStockCheckStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/stock-check/update-status`, + params: { + id, + status + } + }) + }, + + // 删除库存盘点单 + deleteStockCheck: async (ids: number[]) => { + return await request.delete({ + url: `/erp/stock-check/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出库存盘点单 Excel + exportStockCheck: async (params) => { + return await request.download({ url: `/erp/stock-check/export-excel`, params }) + } +} diff --git a/src/api/erp/stock/in/index.ts b/src/api/erp/stock/in/index.ts new file mode 100644 index 0000000..148b64f --- /dev/null +++ b/src/api/erp/stock/in/index.ts @@ -0,0 +1,62 @@ +import request from '@/config/axios' + +// ERP 其它入库单 VO +export interface StockInVO { + id: number // 入库编号 + no: string // 入库单号 + supplierId: number // 供应商编号 + inTime: Date // 入库时间 + totalCount: number // 合计数量 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 +} + +// ERP 其它入库单 API +export const StockInApi = { + // 查询其它入库单分页 + getStockInPage: async (params: any) => { + return await request.get({ url: `/erp/stock-in/page`, params }) + }, + + // 查询其它入库单详情 + getStockIn: async (id: number) => { + return await request.get({ url: `/erp/stock-in/get?id=` + id }) + }, + + // 新增其它入库单 + createStockIn: async (data: StockInVO) => { + return await request.post({ url: `/erp/stock-in/create`, data }) + }, + + // 修改其它入库单 + updateStockIn: async (data: StockInVO) => { + return await request.put({ url: `/erp/stock-in/update`, data }) + }, + + // 更新其它入库单的状态 + updateStockInStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/stock-in/update-status`, + params: { + id, + status + } + }) + }, + + // 删除其它入库单 + deleteStockIn: async (ids: number[]) => { + return await request.delete({ + url: `/erp/stock-in/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出其它入库单 Excel + exportStockIn: async (params) => { + return await request.download({ url: `/erp/stock-in/export-excel`, params }) + } +} diff --git a/src/api/erp/stock/move/index.ts b/src/api/erp/stock/move/index.ts new file mode 100644 index 0000000..398568e --- /dev/null +++ b/src/api/erp/stock/move/index.ts @@ -0,0 +1,61 @@ +import request from '@/config/axios' + +// ERP 库存调度单 VO +export interface StockMoveVO { + id: number // 出库编号 + no: string // 出库单号 + outTime: Date // 出库时间 + totalCount: number // 合计数量 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 +} + +// ERP 库存调度单 API +export const StockMoveApi = { + // 查询库存调度单分页 + getStockMovePage: async (params: any) => { + return await request.get({ url: `/erp/stock-move/page`, params }) + }, + + // 查询库存调度单详情 + getStockMove: async (id: number) => { + return await request.get({ url: `/erp/stock-move/get?id=` + id }) + }, + + // 新增库存调度单 + createStockMove: async (data: StockMoveVO) => { + return await request.post({ url: `/erp/stock-move/create`, data }) + }, + + // 修改库存调度单 + updateStockMove: async (data: StockMoveVO) => { + return await request.put({ url: `/erp/stock-move/update`, data }) + }, + + // 更新库存调度单的状态 + updateStockMoveStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/stock-move/update-status`, + params: { + id, + status + } + }) + }, + + // 删除库存调度单 + deleteStockMove: async (ids: number[]) => { + return await request.delete({ + url: `/erp/stock-move/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出库存调度单 Excel + exportStockMove: async (params) => { + return await request.download({ url: `/erp/stock-move/export-excel`, params }) + } +} diff --git a/src/api/erp/stock/out/index.ts b/src/api/erp/stock/out/index.ts new file mode 100644 index 0000000..f0f40d3 --- /dev/null +++ b/src/api/erp/stock/out/index.ts @@ -0,0 +1,62 @@ +import request from '@/config/axios' + +// ERP 其它出库单 VO +export interface StockOutVO { + id: number // 出库编号 + no: string // 出库单号 + customerId: number // 客户编号 + outTime: Date // 出库时间 + totalCount: number // 合计数量 + totalPrice: number // 合计金额,单位:元 + status: number // 状态 + remark: string // 备注 +} + +// ERP 其它出库单 API +export const StockOutApi = { + // 查询其它出库单分页 + getStockOutPage: async (params: any) => { + return await request.get({ url: `/erp/stock-out/page`, params }) + }, + + // 查询其它出库单详情 + getStockOut: async (id: number) => { + return await request.get({ url: `/erp/stock-out/get?id=` + id }) + }, + + // 新增其它出库单 + createStockOut: async (data: StockOutVO) => { + return await request.post({ url: `/erp/stock-out/create`, data }) + }, + + // 修改其它出库单 + updateStockOut: async (data: StockOutVO) => { + return await request.put({ url: `/erp/stock-out/update`, data }) + }, + + // 更新其它出库单的状态 + updateStockOutStatus: async (id: number, status: number) => { + return await request.put({ + url: `/erp/stock-out/update-status`, + params: { + id, + status + } + }) + }, + + // 删除其它出库单 + deleteStockOut: async (ids: number[]) => { + return await request.delete({ + url: `/erp/stock-out/delete`, + params: { + ids: ids.join(',') + } + }) + }, + + // 导出其它出库单 Excel + exportStockOut: async (params) => { + return await request.download({ url: `/erp/stock-out/export-excel`, params }) + } +} diff --git a/src/api/erp/stock/record/index.ts b/src/api/erp/stock/record/index.ts new file mode 100644 index 0000000..a758eb4 --- /dev/null +++ b/src/api/erp/stock/record/index.ts @@ -0,0 +1,32 @@ +import request from '@/config/axios' + +// ERP 产品库存明细 VO +export interface StockRecordVO { + id: number // 编号 + productId: number // 产品编号 + warehouseId: number // 仓库编号 + count: number // 出入库数量 + totalCount: number // 总库存量 + bizType: number // 业务类型 + bizId: number // 业务编号 + bizItemId: number // 业务项编号 + bizNo: string // 业务单号 +} + +// ERP 产品库存明细 API +export const StockRecordApi = { + // 查询产品库存明细分页 + getStockRecordPage: async (params: any) => { + return await request.get({ url: `/erp/stock-record/page`, params }) + }, + + // 查询产品库存明细详情 + getStockRecord: async (id: number) => { + return await request.get({ url: `/erp/stock-record/get?id=` + id }) + }, + + // 导出产品库存明细 Excel + exportStockRecord: async (params) => { + return await request.download({ url: `/erp/stock-record/export-excel`, params }) + } +} diff --git a/src/api/erp/stock/stock/index.ts b/src/api/erp/stock/stock/index.ts new file mode 100644 index 0000000..4de86fb --- /dev/null +++ b/src/api/erp/stock/stock/index.ts @@ -0,0 +1,41 @@ +import request from '@/config/axios' + +// ERP 产品库存 VO +export interface StockVO { + // 编号 + id: number + // 产品编号 + productId: number + // 仓库编号 + warehouseId: number + // 库存数量 + count: number +} + +// ERP 产品库存 API +export const StockApi = { + // 查询产品库存分页 + getStockPage: async (params: any) => { + return await request.get({ url: `/erp/stock/page`, params }) + }, + + // 查询产品库存详情 + getStock: async (id: number) => { + return await request.get({ url: `/erp/stock/get?id=` + id }) + }, + + // 查询产品库存详情 + getStock2: async (productId: number, warehouseId: number) => { + return await request.get({ url: `/erp/stock/get`, params: { productId, warehouseId } }) + }, + + // 获得产品库存数量 + getStockCount: async (productId: number) => { + return await request.get({ url: `/erp/stock/get-count`, params: { productId } }) + }, + + // 导出产品库存 Excel + exportStock: async (params) => { + return await request.download({ url: `/erp/stock/export-excel`, params }) + } +} diff --git a/src/api/erp/stock/warehouse/index.ts b/src/api/erp/stock/warehouse/index.ts new file mode 100644 index 0000000..598824b --- /dev/null +++ b/src/api/erp/stock/warehouse/index.ts @@ -0,0 +1,64 @@ +import request from '@/config/axios' + +// ERP 仓库 VO +export interface WarehouseVO { + id: number // 仓库编号 + name: string // 仓库名称 + address: string // 仓库地址 + sort: number // 排序 + remark: string // 备注 + principal: string // 负责人 + warehousePrice: number // 仓储费,单位:元 + truckagePrice: number // 搬运费,单位:元 + status: number // 开启状态 + defaultStatus: boolean // 是否默认 +} + +// ERP 仓库 API +export const WarehouseApi = { + // 查询仓库分页 + getWarehousePage: async (params: any) => { + return await request.get({ url: `/erp/warehouse/page`, params }) + }, + + // 查询仓库精简列表 + getWarehouseSimpleList: async () => { + return await request.get({ url: `/erp/warehouse/simple-list` }) + }, + + // 查询仓库详情 + getWarehouse: async (id: number) => { + return await request.get({ url: `/erp/warehouse/get?id=` + id }) + }, + + // 新增仓库 + createWarehouse: async (data: WarehouseVO) => { + return await request.post({ url: `/erp/warehouse/create`, data }) + }, + + // 修改仓库 + updateWarehouse: async (data: WarehouseVO) => { + return await request.put({ url: `/erp/warehouse/update`, data }) + }, + + // 修改仓库默认状态 + updateWarehouseDefaultStatus: async (id: number, defaultStatus: boolean) => { + return await request.put({ + url: `/erp/warehouse/update-default-status`, + params: { + id, + defaultStatus + } + }) + }, + + // 删除仓库 + deleteWarehouse: async (id: number) => { + return await request.delete({ url: `/erp/warehouse/delete?id=` + id }) + }, + + // 导出仓库 Excel + exportWarehouse: async (params) => { + return await request.download({ url: `/erp/warehouse/export-excel`, params }) + } +} diff --git a/src/api/infra/apiAccessLog/index.ts b/src/api/infra/apiAccessLog/index.ts new file mode 100644 index 0000000..4fa50e1 --- /dev/null +++ b/src/api/infra/apiAccessLog/index.ts @@ -0,0 +1,34 @@ +import request from '@/config/axios' + +export interface ApiAccessLogVO { + id: number + traceId: string + userId: number + userType: number + applicationName: string + requestMethod: string + requestParams: string + responseBody: string + requestUrl: string + userIp: string + userAgent: string + operateModule: string + operateName: string + operateType: number + beginTime: Date + endTime: Date + duration: number + resultCode: number + resultMsg: string + createTime: Date +} + +// 查询列表API 访问日志 +export const getApiAccessLogPage = (params: PageParam) => { + return request.get({ url: '/infra/api-access-log/page', params }) +} + +// 导出API 访问日志 +export const exportApiAccessLog = (params) => { + return request.download({ url: '/infra/api-access-log/export-excel', params }) +} diff --git a/src/api/infra/apiErrorLog/index.ts b/src/api/infra/apiErrorLog/index.ts new file mode 100644 index 0000000..59ee214 --- /dev/null +++ b/src/api/infra/apiErrorLog/index.ts @@ -0,0 +1,48 @@ +import request from '@/config/axios' + +export interface ApiErrorLogVO { + id: number + traceId: string + userId: number + userType: number + applicationName: string + requestMethod: string + requestParams: string + requestUrl: string + userIp: string + userAgent: string + exceptionTime: Date + exceptionName: string + exceptionMessage: string + exceptionRootCauseMessage: string + exceptionStackTrace: string + exceptionClassName: string + exceptionFileName: string + exceptionMethodName: string + exceptionLineNumber: number + processUserId: number + processStatus: number + processTime: Date + resultCode: number + createTime: Date +} + +// 查询列表API 访问日志 +export const getApiErrorLogPage = (params: PageParam) => { + return request.get({ url: '/infra/api-error-log/page', params }) +} + +// 更新 API 错误日志的处理状态 +export const updateApiErrorLogPage = (id: number, processStatus: number) => { + return request.put({ + url: '/infra/api-error-log/update-status?id=' + id + '&processStatus=' + processStatus + }) +} + +// 导出API 访问日志 +export const exportApiErrorLog = (params) => { + return request.download({ + url: '/infra/api-error-log/export-excel', + params + }) +} diff --git a/src/api/infra/codegen/index.ts b/src/api/infra/codegen/index.ts new file mode 100644 index 0000000..441ca83 --- /dev/null +++ b/src/api/infra/codegen/index.ts @@ -0,0 +1,122 @@ +import request from '@/config/axios' + +export type CodegenTableVO = { + id: number + tableId: number + isParentMenuIdValid: boolean + dataSourceConfigId: number + scene: number + tableName: string + tableComment: string + remark: string + moduleName: string + businessName: string + className: string + classComment: string + author: string + createTime: Date + updateTime: Date + templateType: number + parentMenuId: number +} + +export type CodegenColumnVO = { + id: number + tableId: number + columnName: string + dataType: string + columnComment: string + nullable: number + primaryKey: number + ordinalPosition: number + javaType: string + javaField: string + dictType: string + example: string + createOperation: number + updateOperation: number + listOperation: number + listOperationCondition: string + listOperationResult: number + htmlType: string +} + +export type DatabaseTableVO = { + name: string + comment: string +} + +export type CodegenDetailVO = { + table: CodegenTableVO + columns: CodegenColumnVO[] +} + +export type CodegenPreviewVO = { + filePath: string + code: string +} + +export type CodegenUpdateReqVO = { + table: CodegenTableVO | any + columns: CodegenColumnVO[] +} + +export type CodegenCreateListReqVO = { + dataSourceConfigId: number + tableNames: string[] +} + +// 查询列表代码生成表定义 +export const getCodegenTableList = (dataSourceConfigId: number) => { + return request.get({ url: '/infra/codegen/table/list?dataSourceConfigId=' + dataSourceConfigId }) +} + +// 查询列表代码生成表定义 +export const getCodegenTablePage = (params: PageParam) => { + return request.get({ url: '/infra/codegen/table/page', params }) +} + +// 查询详情代码生成表定义 +export const getCodegenTable = (id: number) => { + return request.get({ url: '/infra/codegen/detail?tableId=' + id }) +} + +// 新增代码生成表定义 +export const createCodegenTable = (data: CodegenCreateListReqVO) => { + return request.post({ url: '/infra/codegen/create', data }) +} + +// 修改代码生成表定义 +export const updateCodegenTable = (data: CodegenUpdateReqVO) => { + return request.put({ url: '/infra/codegen/update', data }) +} + +// 基于数据库的表结构,同步数据库的表和字段定义 +export const syncCodegenFromDB = (id: number) => { + return request.put({ url: '/infra/codegen/sync-from-db?tableId=' + id }) +} + +// 预览生成代码 +export const previewCodegen = (id: number) => { + return request.get({ url: '/infra/codegen/preview?tableId=' + id }) +} + +// 下载生成代码 +export const downloadCodegen = (id: number) => { + return request.download({ url: '/infra/codegen/download?tableId=' + id }) +} + +// 获得表定义 +export const getSchemaTableList = (params) => { + return request.get({ url: '/infra/codegen/db/table/list', params }) +} + +// 基于数据库的表结构,创建代码生成器的表定义 +export const createCodegenList = (data) => { + return request.post({ url: '/infra/codegen/create-list', data }) +} + +// 删除代码生成表定义 +export const deleteCodegenTable = (id: number) => { + return request.delete({ url: '/infra/codegen/delete?tableId=' + id }) +} diff --git a/src/api/infra/config/index.ts b/src/api/infra/config/index.ts new file mode 100644 index 0000000..5ef59f3 --- /dev/null +++ b/src/api/infra/config/index.ts @@ -0,0 +1,48 @@ +import request from '@/config/axios' + +export interface ConfigVO { + id: number | undefined + category: string + name: string + key: string + value: string + type: number + visible: boolean + remark: string + createTime: Date +} + +// 查询参数列表 +export const getConfigPage = (params: PageParam) => { + return request.get({ url: '/infra/config/page', params }) +} + +// 查询参数详情 +export const getConfig = (id: number) => { + return request.get({ url: '/infra/config/get?id=' + id }) +} + +// 根据参数键名查询参数值 +export const getConfigKey = (configKey: string) => { + return request.get({ url: '/infra/config/get-value-by-key?key=' + configKey }) +} + +// 新增参数 +export const createConfig = (data: ConfigVO) => { + return request.post({ url: '/infra/config/create', data }) +} + +// 修改参数 +export const updateConfig = (data: ConfigVO) => { + return request.put({ url: '/infra/config/update', data }) +} + +// 删除参数 +export const deleteConfig = (id: number) => { + return request.delete({ url: '/infra/config/delete?id=' + id }) +} + +// 导出参数 +export const exportConfig = (params) => { + return request.download({ url: '/infra/config/export', params }) +} diff --git a/src/api/infra/dataSourceConfig/index.ts b/src/api/infra/dataSourceConfig/index.ts new file mode 100644 index 0000000..b413f34 --- /dev/null +++ b/src/api/infra/dataSourceConfig/index.ts @@ -0,0 +1,35 @@ +import request from '@/config/axios' + +export interface DataSourceConfigVO { + id: number | undefined + name: string + url: string + username: string + password: string + createTime?: Date +} + +// 新增数据源配置 +export const createDataSourceConfig = (data: DataSourceConfigVO) => { + return request.post({ url: '/infra/data-source-config/create', data }) +} + +// 修改数据源配置 +export const updateDataSourceConfig = (data: DataSourceConfigVO) => { + return request.put({ url: '/infra/data-source-config/update', data }) +} + +// 删除数据源配置 +export const deleteDataSourceConfig = (id: number) => { + return request.delete({ url: '/infra/data-source-config/delete?id=' + id }) +} + +// 查询数据源配置详情 +export const getDataSourceConfig = (id: number) => { + return request.get({ url: '/infra/data-source-config/get?id=' + id }) +} + +// 查询数据源配置列表 +export const getDataSourceConfigList = () => { + return request.get({ url: '/infra/data-source-config/list' }) +} diff --git a/src/api/infra/demo/demo01/index.ts b/src/api/infra/demo/demo01/index.ts new file mode 100644 index 0000000..e34a05d --- /dev/null +++ b/src/api/infra/demo/demo01/index.ts @@ -0,0 +1,40 @@ +import request from '@/config/axios' + +export interface Demo01ContactVO { + id: number + name: string + sex: number + birthday: Date + description: string + avatar: string +} + +// 查询示例联系人分页 +export const getDemo01ContactPage = async (params) => { + return await request.get({ url: `/infra/demo01-contact/page`, params }) +} + +// 查询示例联系人详情 +export const getDemo01Contact = async (id: number) => { + return await request.get({ url: `/infra/demo01-contact/get?id=` + id }) +} + +// 新增示例联系人 +export const createDemo01Contact = async (data: Demo01ContactVO) => { + return await request.post({ url: `/infra/demo01-contact/create`, data }) +} + +// 修改示例联系人 +export const updateDemo01Contact = async (data: Demo01ContactVO) => { + return await request.put({ url: `/infra/demo01-contact/update`, data }) +} + +// 删除示例联系人 +export const deleteDemo01Contact = async (id: number) => { + return await request.delete({ url: `/infra/demo01-contact/delete?id=` + id }) +} + +// 导出示例联系人 Excel +export const exportDemo01Contact = async (params) => { + return await request.download({ url: `/infra/demo01-contact/export-excel`, params }) +} diff --git a/src/api/infra/demo/demo02/index.ts b/src/api/infra/demo/demo02/index.ts new file mode 100644 index 0000000..736a123 --- /dev/null +++ b/src/api/infra/demo/demo02/index.ts @@ -0,0 +1,37 @@ +import request from '@/config/axios' + +export interface Demo02CategoryVO { + id: number + name: string + parentId: number +} + +// 查询示例分类列表 +export const getDemo02CategoryList = async () => { + return await request.get({ url: `/infra/demo02-category/list` }) +} + +// 查询示例分类详情 +export const getDemo02Category = async (id: number) => { + return await request.get({ url: `/infra/demo02-category/get?id=` + id }) +} + +// 新增示例分类 +export const createDemo02Category = async (data: Demo02CategoryVO) => { + return await request.post({ url: `/infra/demo02-category/create`, data }) +} + +// 修改示例分类 +export const updateDemo02Category = async (data: Demo02CategoryVO) => { + return await request.put({ url: `/infra/demo02-category/update`, data }) +} + +// 删除示例分类 +export const deleteDemo02Category = async (id: number) => { + return await request.delete({ url: `/infra/demo02-category/delete?id=` + id }) +} + +// 导出示例分类 Excel +export const exportDemo02Category = async (params) => { + return await request.download({ url: `/infra/demo02-category/export-excel`, params }) +} diff --git a/src/api/infra/demo/demo03/erp/index.ts b/src/api/infra/demo/demo03/erp/index.ts new file mode 100644 index 0000000..a2ab539 --- /dev/null +++ b/src/api/infra/demo/demo03/erp/index.ts @@ -0,0 +1,91 @@ +import request from '@/config/axios' + +export interface Demo03StudentVO { + id: number + name: string + sex: number + birthday: Date + description: string +} + +// 查询学生分页 +export const getDemo03StudentPage = async (params) => { + return await request.get({ url: `/infra/demo03-student/page`, params }) +} + +// 查询学生详情 +export const getDemo03Student = async (id: number) => { + return await request.get({ url: `/infra/demo03-student/get?id=` + id }) +} + +// 新增学生 +export const createDemo03Student = async (data: Demo03StudentVO) => { + return await request.post({ url: `/infra/demo03-student/create`, data }) +} + +// 修改学生 +export const updateDemo03Student = async (data: Demo03StudentVO) => { + return await request.put({ url: `/infra/demo03-student/update`, data }) +} + +// 删除学生 +export const deleteDemo03Student = async (id: number) => { + return await request.delete({ url: `/infra/demo03-student/delete?id=` + id }) +} + +// 导出学生 Excel +export const exportDemo03Student = async (params) => { + return await request.download({ url: `/infra/demo03-student/export-excel`, params }) +} + +// ==================== 子表(学生课程) ==================== + +// 获得学生课程分页 +export const getDemo03CoursePage = async (params) => { + return await request.get({ url: `/infra/demo03-student/demo03-course/page`, params }) +} +// 新增学生课程 +export const createDemo03Course = async (data) => { + return await request.post({ url: `/infra/demo03-student/demo03-course/create`, data }) +} + +// 修改学生课程 +export const updateDemo03Course = async (data) => { + return await request.put({ url: `/infra/demo03-student/demo03-course/update`, data }) +} + +// 删除学生课程 +export const deleteDemo03Course = async (id: number) => { + return await request.delete({ url: `/infra/demo03-student/demo03-course/delete?id=` + id }) +} + +// 获得学生课程 +export const getDemo03Course = async (id: number) => { + return await request.get({ url: `/infra/demo03-student/demo03-course/get?id=` + id }) +} + +// ==================== 子表(学生班级) ==================== + +// 获得学生班级分页 +export const getDemo03GradePage = async (params) => { + return await request.get({ url: `/infra/demo03-student/demo03-grade/page`, params }) +} +// 新增学生班级 +export const createDemo03Grade = async (data) => { + return await request.post({ url: `/infra/demo03-student/demo03-grade/create`, data }) +} + +// 修改学生班级 +export const updateDemo03Grade = async (data) => { + return await request.put({ url: `/infra/demo03-student/demo03-grade/update`, data }) +} + +// 删除学生班级 +export const deleteDemo03Grade = async (id: number) => { + return await request.delete({ url: `/infra/demo03-student/demo03-grade/delete?id=` + id }) +} + +// 获得学生班级 +export const getDemo03Grade = async (id: number) => { + return await request.get({ url: `/infra/demo03-student/demo03-grade/get?id=` + id }) +} diff --git a/src/api/infra/demo/demo03/inner/index.ts b/src/api/infra/demo/demo03/inner/index.ts new file mode 100644 index 0000000..e366307 --- /dev/null +++ b/src/api/infra/demo/demo03/inner/index.ts @@ -0,0 +1,57 @@ +import request from '@/config/axios' + +export interface Demo03StudentVO { + id: number + name: string + sex: number + birthday: Date + description: string +} + +// 查询学生分页 +export const getDemo03StudentPage = async (params) => { + return await request.get({ url: `/infra/demo03-student/page`, params }) +} + +// 查询学生详情 +export const getDemo03Student = async (id: number) => { + return await request.get({ url: `/infra/demo03-student/get?id=` + id }) +} + +// 新增学生 +export const createDemo03Student = async (data: Demo03StudentVO) => { + return await request.post({ url: `/infra/demo03-student/create`, data }) +} + +// 修改学生 +export const updateDemo03Student = async (data: Demo03StudentVO) => { + return await request.put({ url: `/infra/demo03-student/update`, data }) +} + +// 删除学生 +export const deleteDemo03Student = async (id: number) => { + return await request.delete({ url: `/infra/demo03-student/delete?id=` + id }) +} + +// 导出学生 Excel +export const exportDemo03Student = async (params) => { + return await request.download({ url: `/infra/demo03-student/export-excel`, params }) +} + +// ==================== 子表(学生课程) ==================== + +// 获得学生课程列表 +export const getDemo03CourseListByStudentId = async (studentId) => { + return await request.get({ + url: `/infra/demo03-student/demo03-course/list-by-student-id?studentId=` + studentId + }) +} + +// ==================== 子表(学生班级) ==================== + +// 获得学生班级 +export const getDemo03GradeByStudentId = async (studentId) => { + return await request.get({ + url: `/infra/demo03-student/demo03-grade/get-by-student-id?studentId=` + studentId + }) +} diff --git a/src/api/infra/demo/demo03/normal/index.ts b/src/api/infra/demo/demo03/normal/index.ts new file mode 100644 index 0000000..e366307 --- /dev/null +++ b/src/api/infra/demo/demo03/normal/index.ts @@ -0,0 +1,57 @@ +import request from '@/config/axios' + +export interface Demo03StudentVO { + id: number + name: string + sex: number + birthday: Date + description: string +} + +// 查询学生分页 +export const getDemo03StudentPage = async (params) => { + return await request.get({ url: `/infra/demo03-student/page`, params }) +} + +// 查询学生详情 +export const getDemo03Student = async (id: number) => { + return await request.get({ url: `/infra/demo03-student/get?id=` + id }) +} + +// 新增学生 +export const createDemo03Student = async (data: Demo03StudentVO) => { + return await request.post({ url: `/infra/demo03-student/create`, data }) +} + +// 修改学生 +export const updateDemo03Student = async (data: Demo03StudentVO) => { + return await request.put({ url: `/infra/demo03-student/update`, data }) +} + +// 删除学生 +export const deleteDemo03Student = async (id: number) => { + return await request.delete({ url: `/infra/demo03-student/delete?id=` + id }) +} + +// 导出学生 Excel +export const exportDemo03Student = async (params) => { + return await request.download({ url: `/infra/demo03-student/export-excel`, params }) +} + +// ==================== 子表(学生课程) ==================== + +// 获得学生课程列表 +export const getDemo03CourseListByStudentId = async (studentId) => { + return await request.get({ + url: `/infra/demo03-student/demo03-course/list-by-student-id?studentId=` + studentId + }) +} + +// ==================== 子表(学生班级) ==================== + +// 获得学生班级 +export const getDemo03GradeByStudentId = async (studentId) => { + return await request.get({ + url: `/infra/demo03-student/demo03-grade/get-by-student-id?studentId=` + studentId + }) +} diff --git a/src/api/infra/file/index.ts b/src/api/infra/file/index.ts new file mode 100644 index 0000000..0e1b2e7 --- /dev/null +++ b/src/api/infra/file/index.ts @@ -0,0 +1,45 @@ +import request from '@/config/axios' + +export interface FilePageReqVO extends PageParam { + path?: string + type?: string + createTime?: Date[] +} + +// 文件预签名地址 Response VO +export interface FilePresignedUrlRespVO { + // 文件配置编号 + configId: number + // 文件上传 URL + uploadUrl: string + // 文件 URL + url: string +} + +// 查询文件列表 +export const getFilePage = (params: FilePageReqVO) => { + return request.get({ url: '/infra/file/page', params }) +} + +// 删除文件 +export const deleteFile = (id: number) => { + return request.delete({ url: '/infra/file/delete?id=' + id }) +} + +// 获取文件预签名地址 +export const getFilePresignedUrl = (path: string) => { + return request.get<FilePresignedUrlRespVO>({ + url: '/infra/file/presigned-url', + params: { path } + }) +} + +// 创建文件 +export const createFile = (data: any) => { + return request.post({ url: '/infra/file/create', data }) +} + +// 上传文件 +export const updateFile = (data: any) => { + return request.upload({ url: '/infra/file/upload', data }) +} diff --git a/src/api/infra/fileConfig/index.ts b/src/api/infra/fileConfig/index.ts new file mode 100644 index 0000000..ba40054 --- /dev/null +++ b/src/api/infra/fileConfig/index.ts @@ -0,0 +1,61 @@ +import request from '@/config/axios' + +export interface FileClientConfig { + basePath: string + host?: string + port?: number + username?: string + password?: string + mode?: string + endpoint?: string + bucket?: string + accessKey?: string + accessSecret?: string + domain: string +} + +export interface FileConfigVO { + id: number + name: string + storage?: number + master: boolean + visible: boolean + config: FileClientConfig + remark: string + createTime: Date +} + +// 查询文件配置列表 +export const getFileConfigPage = (params: PageParam) => { + return request.get({ url: '/infra/file-config/page', params }) +} + +// 查询文件配置详情 +export const getFileConfig = (id: number) => { + return request.get({ url: '/infra/file-config/get?id=' + id }) +} + +// 更新文件配置为主配置 +export const updateFileConfigMaster = (id: number) => { + return request.put({ url: '/infra/file-config/update-master?id=' + id }) +} + +// 新增文件配置 +export const createFileConfig = (data: FileConfigVO) => { + return request.post({ url: '/infra/file-config/create', data }) +} + +// 修改文件配置 +export const updateFileConfig = (data: FileConfigVO) => { + return request.put({ url: '/infra/file-config/update', data }) +} + +// 删除文件配置 +export const deleteFileConfig = (id: number) => { + return request.delete({ url: '/infra/file-config/delete?id=' + id }) +} + +// 测试文件配置 +export const testFileConfig = (id: number) => { + return request.get({ url: '/infra/file-config/test?id=' + id }) +} diff --git a/src/api/infra/job/index.ts b/src/api/infra/job/index.ts new file mode 100644 index 0000000..033b2cb --- /dev/null +++ b/src/api/infra/job/index.ts @@ -0,0 +1,63 @@ +import request from '@/config/axios' + +export interface JobVO { + id: number + name: string + status: number + handlerName: string + handlerParam: string + cronExpression: string + retryCount: number + retryInterval: number + monitorTimeout: number + createTime: Date +} + +// 任务列表 +export const getJobPage = (params: PageParam) => { + return request.get({ url: '/infra/job/page', params }) +} + +// 任务详情 +export const getJob = (id: number) => { + return request.get({ url: '/infra/job/get?id=' + id }) +} + +// 新增任务 +export const createJob = (data: JobVO) => { + return request.post({ url: '/infra/job/create', data }) +} + +// 修改定时任务调度 +export const updateJob = (data: JobVO) => { + return request.put({ url: '/infra/job/update', data }) +} + +// 删除定时任务调度 +export const deleteJob = (id: number) => { + return request.delete({ url: '/infra/job/delete?id=' + id }) +} + +// 导出定时任务调度 +export const exportJob = (params) => { + return request.download({ url: '/infra/job/export-excel', params }) +} + +// 任务状态修改 +export const updateJobStatus = (id: number, status: number) => { + const params = { + id, + status + } + return request.put({ url: '/infra/job/update-status', params }) +} + +// 定时任务立即执行一次 +export const runJob = (id: number) => { + return request.put({ url: '/infra/job/trigger?id=' + id }) +} + +// 获得定时任务的下 n 次执行时间 +export const getJobNextTimes = (id: number) => { + return request.get({ url: '/infra/job/get_next_times?id=' + id }) +} diff --git a/src/api/infra/jobLog/index.ts b/src/api/infra/jobLog/index.ts new file mode 100644 index 0000000..ed54761 --- /dev/null +++ b/src/api/infra/jobLog/index.ts @@ -0,0 +1,34 @@ +import request from '@/config/axios' + +export interface JobLogVO { + id: number + jobId: number + handlerName: string + handlerParam: string + cronExpression: string + executeIndex: string + beginTime: Date + endTime: Date + duration: string + status: number + createTime: string + result: string +} + +// 任务日志列表 +export const getJobLogPage = (params: PageParam) => { + return request.get({ url: '/infra/job-log/page', params }) +} + +// 任务日志详情 +export const getJobLog = (id: number) => { + return request.get({ url: '/infra/job-log/get?id=' + id }) +} + +// 导出定时任务日志 +export const exportJobLog = (params) => { + return request.download({ + url: '/infra/job-log/export-excel', + params + }) +} diff --git a/src/api/infra/redis/index.ts b/src/api/infra/redis/index.ts new file mode 100644 index 0000000..f27be77 --- /dev/null +++ b/src/api/infra/redis/index.ts @@ -0,0 +1,8 @@ +import request from '@/config/axios' + +/** + * 获取redis 监控信息 + */ +export const getCache = () => { + return request.get({ url: '/infra/redis/get-monitor-info' }) +} diff --git a/src/api/infra/redis/types.ts b/src/api/infra/redis/types.ts new file mode 100644 index 0000000..548bfe9 --- /dev/null +++ b/src/api/infra/redis/types.ts @@ -0,0 +1,176 @@ +export interface RedisMonitorInfoVO { + info: RedisInfoVO + dbSize: number + commandStats: RedisCommandStatsVO[] +} + +export interface RedisInfoVO { + io_threaded_reads_processed: string + tracking_clients: string + uptime_in_seconds: string + cluster_connections: string + current_cow_size: string + maxmemory_human: string + aof_last_cow_size: string + master_replid2: string + mem_replication_backlog: string + aof_rewrite_scheduled: string + total_net_input_bytes: string + rss_overhead_ratio: string + hz: string + current_cow_size_age: string + redis_build_id: string + errorstat_BUSYGROUP: string + aof_last_bgrewrite_status: string + multiplexing_api: string + client_recent_max_output_buffer: string + allocator_resident: string + mem_fragmentation_bytes: string + aof_current_size: string + repl_backlog_first_byte_offset: string + tracking_total_prefixes: string + redis_mode: string + redis_git_dirty: string + aof_delayed_fsync: string + allocator_rss_bytes: string + repl_backlog_histlen: string + io_threads_active: string + rss_overhead_bytes: string + total_system_memory: string + loading: string + evicted_keys: string + maxclients: string + cluster_enabled: string + redis_version: string + repl_backlog_active: string + mem_aof_buffer: string + allocator_frag_bytes: string + io_threaded_writes_processed: string + instantaneous_ops_per_sec: string + used_memory_human: string + total_error_replies: string + role: string + maxmemory: string + used_memory_lua: string + rdb_current_bgsave_time_sec: string + used_memory_startup: string + used_cpu_sys_main_thread: string + lazyfree_pending_objects: string + aof_pending_bio_fsync: string + used_memory_dataset_perc: string + allocator_frag_ratio: string + arch_bits: string + used_cpu_user_main_thread: string + mem_clients_normal: string + expired_time_cap_reached_count: string + unexpected_error_replies: string + mem_fragmentation_ratio: string + aof_last_rewrite_time_sec: string + master_replid: string + aof_rewrite_in_progress: string + lru_clock: string + maxmemory_policy: string + run_id: string + latest_fork_usec: string + tracking_total_items: string + total_commands_processed: string + expired_keys: string + errorstat_ERR: string + used_memory: string + module_fork_in_progress: string + errorstat_WRONGPASS: string + aof_buffer_length: string + dump_payload_sanitizations: string + mem_clients_slaves: string + keyspace_misses: string + server_time_usec: string + executable: string + lazyfreed_objects: string + db0: string + used_memory_peak_human: string + keyspace_hits: string + rdb_last_cow_size: string + aof_pending_rewrite: string + used_memory_overhead: string + active_defrag_hits: string + tcp_port: string + uptime_in_days: string + used_memory_peak_perc: string + current_save_keys_processed: string + blocked_clients: string + total_reads_processed: string + expire_cycle_cpu_milliseconds: string + sync_partial_err: string + used_memory_scripts_human: string + aof_current_rewrite_time_sec: string + aof_enabled: string + process_supervised: string + master_repl_offset: string + used_memory_dataset: string + used_cpu_user: string + rdb_last_bgsave_status: string + tracking_total_keys: string + atomicvar_api: string + allocator_rss_ratio: string + client_recent_max_input_buffer: string + clients_in_timeout_table: string + aof_last_write_status: string + mem_allocator: string + used_memory_scripts: string + used_memory_peak: string + process_id: string + master_failover_state: string + errorstat_NOAUTH: string + used_cpu_sys: string + repl_backlog_size: string + connected_slaves: string + current_save_keys_total: string + gcc_version: string + total_system_memory_human: string + sync_full: string + connected_clients: string + module_fork_last_cow_size: string + total_writes_processed: string + allocator_active: string + total_net_output_bytes: string + pubsub_channels: string + current_fork_perc: string + active_defrag_key_hits: string + rdb_changes_since_last_save: string + instantaneous_input_kbps: string + used_memory_rss_human: string + configured_hz: string + expired_stale_perc: string + active_defrag_misses: string + used_cpu_sys_children: string + number_of_cached_scripts: string + sync_partial_ok: string + used_memory_lua_human: string + rdb_last_save_time: string + pubsub_patterns: string + slave_expires_tracked_keys: string + redis_git_sha1: string + used_memory_rss: string + rdb_last_bgsave_time_sec: string + os: string + mem_not_counted_for_evict: string + active_defrag_running: string + rejected_connections: string + aof_rewrite_buffer_length: string + total_forks: string + active_defrag_key_misses: string + allocator_allocated: string + aof_base_size: string + instantaneous_output_kbps: string + second_repl_offset: string + rdb_bgsave_in_progress: string + used_cpu_user_children: string + total_connections_received: string + migrate_cached_sockets: string +} + +export interface RedisCommandStatsVO { + command: string + calls: number + usec: number +} diff --git a/src/api/login/index.ts b/src/api/login/index.ts new file mode 100644 index 0000000..ef86563 --- /dev/null +++ b/src/api/login/index.ts @@ -0,0 +1,81 @@ +import request from '@/config/axios' +import { getRefreshToken } from '@/utils/auth' +import type { UserLoginVO } from './types' + +export interface SmsCodeVO { + mobile: string + scene: number +} + +export interface SmsLoginVO { + mobile: string + code: string +} + +// 登录 +export const login = (data: UserLoginVO) => { + return request.post({ url: '/system/auth/login', data }) +} + +// 刷新访问令牌 +export const refreshToken = () => { + return request.post({ url: '/system/auth/refresh-token?refreshToken=' + getRefreshToken() }) +} + +// 使用租户名,获得租户编号 +export const getTenantIdByName = (name: string) => { + return request.get({ url: '/system/tenant/get-id-by-name?name=' + name }) +} + +// 使用租户域名,获得租户信息 +export const getTenantByWebsite = (website: string) => { + return request.get({ url: '/system/tenant/get-by-website?website=' + website }) +} + +// 登出 +export const loginOut = () => { + return request.post({ url: '/system/auth/logout' }) +} + +// 获取用户权限信息 +export const getInfo = () => { + return request.get({ url: '/system/auth/get-permission-info' }) +} + +//获取登录验证码 +export const sendSmsCode = (data: SmsCodeVO) => { + return request.post({ url: '/system/auth/send-sms-code', data }) +} + +// 短信验证码登录 +export const smsLogin = (data: SmsLoginVO) => { + return request.post({ url: '/system/auth/sms-login', data }) +} + +// 社交快捷登录,使用 code 授权码 +export function socialLogin(type: string, code: string, state: string) { + return request.post({ + url: '/system/auth/social-login', + data: { + type, + code, + state + } + }) +} + +// 社交授权的跳转 +export const socialAuthRedirect = (type: number, redirectUri: string) => { + return request.get({ + url: '/system/auth/social-auth-redirect?type=' + type + '&redirectUri=' + redirectUri + }) +} +// 获取验证图片以及 token +export const getCode = (data) => { + return request.postOriginal({ url: 'system/captcha/get', data }) +} + +// 滑动或者点选验证 +export const reqCheck = (data) => { + return request.postOriginal({ url: 'system/captcha/check', data }) +} diff --git a/src/api/login/oauth2/index.ts b/src/api/login/oauth2/index.ts new file mode 100644 index 0000000..aef1820 --- /dev/null +++ b/src/api/login/oauth2/index.ts @@ -0,0 +1,41 @@ +import request from '@/config/axios' + +// 获得授权信息 +export const getAuthorize = (clientId: string) => { + return request.get({ url: '/system/oauth2/authorize?clientId=' + clientId }) +} + +// 发起授权 +export const authorize = ( + responseType: string, + clientId: string, + redirectUri: string, + state: string, + autoApprove: boolean, + checkedScopes: string[], + uncheckedScopes: string[] +) => { + // 构建 scopes + const scopes = {} + for (const scope of checkedScopes) { + scopes[scope] = true + } + for (const scope of uncheckedScopes) { + scopes[scope] = false + } + // 发起请求 + return request.post({ + url: '/system/oauth2/authorize', + headers: { + 'Content-type': 'application/x-www-form-urlencoded' + }, + params: { + response_type: responseType, + client_id: clientId, + redirect_uri: redirectUri, + state: state, + auto_approve: autoApprove, + scope: JSON.stringify(scopes) + } + }) +} diff --git a/src/api/login/types.ts b/src/api/login/types.ts new file mode 100644 index 0000000..fff8122 --- /dev/null +++ b/src/api/login/types.ts @@ -0,0 +1,31 @@ +export type UserLoginVO = { + username: string + password: string + captchaVerification: string + socialType?: string + socialCode?: string + socialState?: string +} + +export type TokenType = { + id: number // 编号 + accessToken: string // 访问令牌 + refreshToken: string // 刷新令牌 + userId: number // 用户编号 + userType: number //用户类型 + clientId: string //客户端编号 + expiresTime: number //过期时间 +} + +export type UserVO = { + id: number + username: string + nickname: string + deptId: number + email: string + mobile: string + sex: number + avatar: string + loginIp: string + loginDate: string +} diff --git a/src/api/mall/market/banner/index.ts b/src/api/mall/market/banner/index.ts new file mode 100644 index 0000000..ee65024 --- /dev/null +++ b/src/api/mall/market/banner/index.ts @@ -0,0 +1,37 @@ +import request from '@/config/axios' + +export interface BannerVO { + id: number + title: string + picUrl: string + status: number + url: string + position: number + sort: number + memo: string +} + +// 查询Banner管理列表 +export const getBannerPage = async (params) => { + return await request.get({ url: `/promotion/banner/page`, params }) +} + +// 查询Banner管理详情 +export const getBanner = async (id: number) => { + return await request.get({ url: `/promotion/banner/get?id=` + id }) +} + +// 新增Banner管理 +export const createBanner = async (data: BannerVO) => { + return await request.post({ url: `/promotion/banner/create`, data }) +} + +// 修改Banner管理 +export const updateBanner = async (data: BannerVO) => { + return await request.put({ url: `/promotion/banner/update`, data }) +} + +// 删除Banner管理 +export const deleteBanner = async (id: number) => { + return await request.delete({ url: `/promotion/banner/delete?id=` + id }) +} diff --git a/src/api/mall/product/brand.ts b/src/api/mall/product/brand.ts new file mode 100644 index 0000000..94d5370 --- /dev/null +++ b/src/api/mall/product/brand.ts @@ -0,0 +1,61 @@ +import request from '@/config/axios' + +/** + * 商品品牌 + */ +export interface BrandVO { + /** + * 品牌编号 + */ + id?: number + /** + * 品牌名称 + */ + name: string + /** + * 品牌图片 + */ + picUrl: string + /** + * 品牌排序 + */ + sort?: number + /** + * 品牌描述 + */ + description?: string + /** + * 开启状态 + */ + status: number +} + +// 创建商品品牌 +export const createBrand = (data: BrandVO) => { + return request.post({ url: '/product/brand/create', data }) +} + +// 更新商品品牌 +export const updateBrand = (data: BrandVO) => { + return request.put({ url: '/product/brand/update', data }) +} + +// 删除商品品牌 +export const deleteBrand = (id: number) => { + return request.delete({ url: `/product/brand/delete?id=${id}` }) +} + +// 获得商品品牌 +export const getBrand = (id: number) => { + return request.get({ url: `/product/brand/get?id=${id}` }) +} + +// 获得商品品牌列表 +export const getBrandParam = (params: PageParam) => { + return request.get({ url: '/product/brand/page', params }) +} + +// 获得商品品牌精简信息列表 +export const getSimpleBrandList = () => { + return request.get({ url: '/product/brand/list-all-simple' }) +} diff --git a/src/api/mall/product/category.ts b/src/api/mall/product/category.ts new file mode 100644 index 0000000..7e80b76 --- /dev/null +++ b/src/api/mall/product/category.ts @@ -0,0 +1,56 @@ +import request from '@/config/axios' + +/** + * 产品分类 + */ +export interface CategoryVO { + /** + * 分类编号 + */ + id?: number + /** + * 父分类编号 + */ + parentId?: number + /** + * 分类名称 + */ + name: string + /** + * 移动端分类图 + */ + picUrl: string + /** + * 分类排序 + */ + sort: number + /** + * 开启状态 + */ + status: number +} + +// 创建商品分类 +export const createCategory = (data: CategoryVO) => { + return request.post({ url: '/product/category/create', data }) +} + +// 更新商品分类 +export const updateCategory = (data: CategoryVO) => { + return request.put({ url: '/product/category/update', data }) +} + +// 删除商品分类 +export const deleteCategory = (id: number) => { + return request.delete({ url: `/product/category/delete?id=${id}` }) +} + +// 获得商品分类 +export const getCategory = (id: number) => { + return request.get({ url: `/product/category/get?id=${id}` }) +} + +// 获得商品分类列表 +export const getCategoryList = (params: any) => { + return request.get({ url: '/product/category/list', params }) +} diff --git a/src/api/mall/product/comment.ts b/src/api/mall/product/comment.ts new file mode 100644 index 0000000..defdbb9 --- /dev/null +++ b/src/api/mall/product/comment.ts @@ -0,0 +1,49 @@ +import request from '@/config/axios' + +export interface CommentVO { + id: number + userId: number + userNickname: string + userAvatar: string + anonymous: boolean + orderId: number + orderItemId: number + spuId: number + spuName: string + skuId: number + visible: boolean + scores: number + descriptionScores: number + benefitScores: number + content: string + picUrls: string + replyStatus: boolean + replyUserId: number + replyContent: string + replyTime: Date +} + +// 查询商品评论列表 +export const getCommentPage = async (params) => { + return await request.get({ url: `/product/comment/page`, params }) +} + +// 查询商品评论详情 +export const getComment = async (id: number) => { + return await request.get({ url: `/product/comment/get?id=` + id }) +} + +// 添加自评 +export const createComment = async (data: CommentVO) => { + return await request.post({ url: `/product/comment/create`, data }) +} + +// 显示 / 隐藏评论 +export const updateCommentVisible = async (data: any) => { + return await request.put({ url: `/product/comment/update-visible`, data }) +} + +// 商家回复 +export const replyComment = async (data: any) => { + return await request.put({ url: `/product/comment/reply`, data }) +} diff --git a/src/api/mall/product/favorite.ts b/src/api/mall/product/favorite.ts new file mode 100644 index 0000000..3834eed --- /dev/null +++ b/src/api/mall/product/favorite.ts @@ -0,0 +1,12 @@ +import request from '@/config/axios' + +export interface Favorite { + id?: number + userId?: string // 用户编号 + spuId?: number | null // 商品 SPU 编号 +} + +// 获得 ProductFavorite 列表 +export const getFavoritePage = (params: PageParam) => { + return request.get({ url: '/product/favorite/page', params }) +} diff --git a/src/api/mall/product/history.ts b/src/api/mall/product/history.ts new file mode 100644 index 0000000..0aa45bd --- /dev/null +++ b/src/api/mall/product/history.ts @@ -0,0 +1,10 @@ +import request from '@/config/axios' + +/** + * 获得商品浏览记录分页 + * + * @param params 请求参数 + */ +export const getBrowseHistoryPage = (params: any) => { + return request.get({ url: '/product/browse-history/page', params }) +} diff --git a/src/api/mall/product/property.ts b/src/api/mall/product/property.ts new file mode 100644 index 0000000..a191d82 --- /dev/null +++ b/src/api/mall/product/property.ts @@ -0,0 +1,89 @@ +import request from '@/config/axios' + +/** + * 商品属性 + */ +export interface PropertyVO { + id?: number + /** 名称 */ + name: string + /** 备注 */ + remark?: string +} + +/** + * 属性值 + */ +export interface PropertyValueVO { + id?: number + /** 属性项的编号 */ + propertyId?: number + /** 名称 */ + name: string + /** 备注 */ + remark?: string +} + +// ------------------------ 属性项 ------------------- + +// 创建属性项 +export const createProperty = (data: PropertyVO) => { + return request.post({ url: '/product/property/create', data }) +} + +// 更新属性项 +export const updateProperty = (data: PropertyVO) => { + return request.put({ url: '/product/property/update', data }) +} + +// 删除属性项 +export const deleteProperty = (id: number) => { + return request.delete({ url: `/product/property/delete?id=${id}` }) +} + +// 获得属性项 +export const getProperty = (id: number): Promise<PropertyVO> => { + return request.get({ url: `/product/property/get?id=${id}` }) +} + +// 获得属性项分页 +export const getPropertyPage = (params: PageParam) => { + return request.get({ url: '/product/property/page', params }) +} + +// 获得属性项精简列表 +export const getPropertySimpleList = (): Promise<PropertyVO[]> => { + return request.get({ url: '/product/property/simple-list' }) +} + +// ------------------------ 属性值 ------------------- + +// 获得属性值分页 +export const getPropertyValuePage = (params: PageParam & any) => { + return request.get({ url: '/product/property/value/page', params }) +} + +// 获得属性值 +export const getPropertyValue = (id: number): Promise<PropertyValueVO> => { + return request.get({ url: `/product/property/value/get?id=${id}` }) +} + +// 创建属性值 +export const createPropertyValue = (data: PropertyValueVO) => { + return request.post({ url: '/product/property/value/create', data }) +} + +// 更新属性值 +export const updatePropertyValue = (data: PropertyValueVO) => { + return request.put({ url: '/product/property/value/update', data }) +} + +// 删除属性值 +export const deletePropertyValue = (id: number) => { + return request.delete({ url: `/product/property/value/delete?id=${id}` }) +} + +// 获得属性值精简列表 +export const getPropertyValueSimpleList = (propertyId: number): Promise<PropertyValueVO[]> => { + return request.get({ url: '/product/property/value/simple-list', params: { propertyId } }) +} diff --git a/src/api/mall/product/spu.ts b/src/api/mall/product/spu.ts new file mode 100644 index 0000000..eee632d --- /dev/null +++ b/src/api/mall/product/spu.ts @@ -0,0 +1,109 @@ +import request from '@/config/axios' + +export interface Property { + propertyId?: number // 属性编号 + propertyName?: string // 属性名称 + valueId?: number // 属性值编号 + valueName?: string // 属性值名称 +} + +export interface Sku { + id?: number // 商品 SKU 编号 + name?: string // 商品 SKU 名称 + spuId?: number // SPU 编号 + properties?: Property[] // 属性数组 + price?: number | string // 商品价格 + marketPrice?: number | string // 市场价 + costPrice?: number | string // 成本价 + barCode?: string // 商品条码 + picUrl?: string // 图片地址 + stock?: number // 库存 + weight?: number // 商品重量,单位:kg 千克 + volume?: number // 商品体积,单位:m^3 平米 + firstBrokeragePrice?: number | string // 一级分销的佣金 + secondBrokeragePrice?: number | string // 二级分销的佣金 + salesCount?: number // 商品销量 +} + +export interface GiveCouponTemplate { + id?: number + name?: string // 优惠券名称 +} + +export interface Spu { + id?: number + name?: string // 商品名称 + categoryId?: number // 商品分类 + keyword?: string // 关键字 + unit?: number | undefined // 单位 + picUrl?: string // 商品封面图 + sliderPicUrls?: string[] // 商品轮播图 + introduction?: string // 商品简介 + deliveryTypes?: number[] // 配送方式 + deliveryTemplateId?: number | undefined // 运费模版 + brandId?: number // 商品品牌编号 + specType?: boolean // 商品规格 + subCommissionType?: boolean // 分销类型 + skus?: Sku[] // sku数组 + description?: string // 商品详情 + sort?: number // 商品排序 + giveIntegral?: number // 赠送积分 + virtualSalesCount?: number // 虚拟销量 + price?: number // 商品价格 + salesCount?: number // 商品销量 + marketPrice?: number // 市场价 + costPrice?: number // 成本价 + stock?: number // 商品库存 + createTime?: Date // 商品创建时间 + status?: number // 商品状态 +} + +// 获得 Spu 列表 +export const getSpuPage = (params: PageParam) => { + return request.get({ url: '/product/spu/page', params }) +} + +// 获得 Spu 列表 tabsCount +export const getTabsCount = () => { + return request.get({ url: '/product/spu/get-count' }) +} + +// 创建商品 Spu +export const createSpu = (data: Spu) => { + return request.post({ url: '/product/spu/create', data }) +} + +// 更新商品 Spu +export const updateSpu = (data: Spu) => { + return request.put({ url: '/product/spu/update', data }) +} + +// 更新商品 Spu status +export const updateStatus = (data: { id: number; status: number }) => { + return request.put({ url: '/product/spu/update-status', data }) +} + +// 获得商品 Spu +export const getSpu = (id: number) => { + return request.get({ url: `/product/spu/get-detail?id=${id}` }) +} + +// 获得商品 Spu 详情列表 +export const getSpuDetailList = (ids: number[]) => { + return request.get({ url: `/product/spu/list?spuIds=${ids}` }) +} + +// 删除商品 Spu +export const deleteSpu = (id: number) => { + return request.delete({ url: `/product/spu/delete?id=${id}` }) +} + +// 导出商品 Spu Excel +export const exportSpu = async (params) => { + return await request.download({ url: '/product/spu/export', params }) +} + +// 获得商品 SPU 精简列表 +export const getSpuSimpleList = async () => { + return request.get({ url: '/product/spu/list-all-simple' }) +} diff --git a/src/api/mall/promotion/article/index.ts b/src/api/mall/promotion/article/index.ts new file mode 100644 index 0000000..9184c7a --- /dev/null +++ b/src/api/mall/promotion/article/index.ts @@ -0,0 +1,42 @@ +import request from '@/config/axios' + +export interface ArticleVO { + id: number + categoryId: number + title: string + author: string + picUrl: string + introduction: string + browseCount: string + sort: number + status: number + spuId: number + recommendHot: boolean + recommendBanner: boolean + content: string +} + +// 查询文章管理列表 +export const getArticlePage = async (params: any) => { + return await request.get({ url: `/promotion/article/page`, params }) +} + +// 查询文章管理详情 +export const getArticle = async (id: number) => { + return await request.get({ url: `/promotion/article/get?id=` + id }) +} + +// 新增文章管理 +export const createArticle = async (data: ArticleVO) => { + return await request.post({ url: `/promotion/article/create`, data }) +} + +// 修改文章管理 +export const updateArticle = async (data: ArticleVO) => { + return await request.put({ url: `/promotion/article/update`, data }) +} + +// 删除文章管理 +export const deleteArticle = async (id: number) => { + return await request.delete({ url: `/promotion/article/delete?id=` + id }) +} diff --git a/src/api/mall/promotion/articleCategory/index.ts b/src/api/mall/promotion/articleCategory/index.ts new file mode 100644 index 0000000..47f5e93 --- /dev/null +++ b/src/api/mall/promotion/articleCategory/index.ts @@ -0,0 +1,39 @@ +import request from '@/config/axios' + +export interface ArticleCategoryVO { + id: number + name: string + picUrl: string + status: number + sort: number +} + +// 查询文章分类列表 +export const getArticleCategoryPage = async (params) => { + return await request.get({ url: `/promotion/article-category/page`, params }) +} + +// 查询文章分类精简信息列表 +export const getSimpleArticleCategoryList = async () => { + return await request.get({ url: `/promotion/article-category/list-all-simple` }) +} + +// 查询文章分类详情 +export const getArticleCategory = async (id: number) => { + return await request.get({ url: `/promotion/article-category/get?id=` + id }) +} + +// 新增文章分类 +export const createArticleCategory = async (data: ArticleCategoryVO) => { + return await request.post({ url: `/promotion/article-category/create`, data }) +} + +// 修改文章分类 +export const updateArticleCategory = async (data: ArticleCategoryVO) => { + return await request.put({ url: `/promotion/article-category/update`, data }) +} + +// 删除文章分类 +export const deleteArticleCategory = async (id: number) => { + return await request.delete({ url: `/promotion/article-category/delete?id=` + id }) +} diff --git a/src/api/mall/promotion/bargain/bargainActivity.ts b/src/api/mall/promotion/bargain/bargainActivity.ts new file mode 100644 index 0000000..9ad219a --- /dev/null +++ b/src/api/mall/promotion/bargain/bargainActivity.ts @@ -0,0 +1,68 @@ +import request from '@/config/axios' +import { Sku, Spu } from '@/api/mall/product/spu' + +export interface BargainActivityVO { + id?: number + name?: string + startTime?: Date + endTime?: Date + status?: number + helpMaxCount?: number // 达到该人数,才能砍到低价 + bargainCount?: number // 最大帮砍次数 + totalLimitCount?: number // 最大购买次数 + spuId: number + skuId: number + bargainFirstPrice: number // 砍价起始价格,单位分 + bargainMinPrice: number // 砍价底价 + stock: number // 活动库存 + randomMinPrice?: number // 用户每次砍价的最小金额,单位:分 + randomMaxPrice?: number // 用户每次砍价的最大金额,单位:分 +} + +// 砍价活动所需属性。选择的商品和属性的时候使用方便使用活动的通用封装 +export interface BargainProductVO { + spuId: number + skuId: number + bargainFirstPrice: number // 砍价起始价格,单位分 + bargainMinPrice: number // 砍价底价 + stock: number // 活动库存 +} + +// 扩展 Sku 配置 +export type SkuExtension = Sku & { + productConfig: BargainProductVO +} + +export interface SpuExtension extends Spu { + skus: SkuExtension[] // 重写类型 +} + +// 查询砍价活动列表 +export const getBargainActivityPage = async (params: any) => { + return await request.get({ url: '/promotion/bargain-activity/page', params }) +} + +// 查询砍价活动详情 +export const getBargainActivity = async (id: number) => { + return await request.get({ url: '/promotion/bargain-activity/get?id=' + id }) +} + +// 新增砍价活动 +export const createBargainActivity = async (data: BargainActivityVO) => { + return await request.post({ url: '/promotion/bargain-activity/create', data }) +} + +// 修改砍价活动 +export const updateBargainActivity = async (data: BargainActivityVO) => { + return await request.put({ url: '/promotion/bargain-activity/update', data }) +} + +// 关闭砍价活动 +export const closeBargainActivity = async (id: number) => { + return await request.put({ url: '/promotion/bargain-activity/close?id=' + id }) +} + +// 删除砍价活动 +export const deleteBargainActivity = async (id: number) => { + return await request.delete({ url: '/promotion/bargain-activity/delete?id=' + id }) +} diff --git a/src/api/mall/promotion/bargain/bargainHelp.ts b/src/api/mall/promotion/bargain/bargainHelp.ts new file mode 100644 index 0000000..4308ae6 --- /dev/null +++ b/src/api/mall/promotion/bargain/bargainHelp.ts @@ -0,0 +1,14 @@ +import request from '@/config/axios' + +export interface BargainHelpVO { + id: number + record: number + userId: number + reducePrice: number + endTime: Date +} + +// 查询砍价记录列表 +export const getBargainHelpPage = async (params) => { + return await request.get({ url: `/promotion/bargain-help/page`, params }) +} diff --git a/src/api/mall/promotion/bargain/bargainRecord.ts b/src/api/mall/promotion/bargain/bargainRecord.ts new file mode 100644 index 0000000..f90b784 --- /dev/null +++ b/src/api/mall/promotion/bargain/bargainRecord.ts @@ -0,0 +1,19 @@ +import request from '@/config/axios' + +export interface BargainRecordVO { + id: number + activityId: number + userId: number + spuId: number + skuId: number + bargainFirstPrice: number + bargainPrice: number + status: number + orderId: number + endTime: Date +} + +// 查询砍价记录列表 +export const getBargainRecordPage = async (params) => { + return await request.get({ url: `/promotion/bargain-record/page`, params }) +} diff --git a/src/api/mall/promotion/combination/combinationActivity.ts b/src/api/mall/promotion/combination/combinationActivity.ts new file mode 100644 index 0000000..062db5c --- /dev/null +++ b/src/api/mall/promotion/combination/combinationActivity.ts @@ -0,0 +1,66 @@ +import request from '@/config/axios' +import { Sku, Spu } from '@/api/mall/product/spu' + +export interface CombinationActivityVO { + id?: number + name?: string + spuId?: number + totalLimitCount?: number + singleLimitCount?: number + startTime?: Date + endTime?: Date + userSize?: number + totalCount?: number + successCount?: number + orderUserCount?: number + virtualGroup?: number + status?: number + limitDuration?: number + products: CombinationProductVO[] +} + +// 拼团活动所需属性 +export interface CombinationProductVO { + spuId: number + skuId: number + combinationPrice: number // 拼团价格 +} + +// 扩展 Sku 配置 +export type SkuExtension = Sku & { + productConfig: CombinationProductVO +} + +export interface SpuExtension extends Spu { + skus: SkuExtension[] // 重写类型 +} + +// 查询拼团活动列表 +export const getCombinationActivityPage = async (params) => { + return await request.get({ url: '/promotion/combination-activity/page', params }) +} + +// 查询拼团活动详情 +export const getCombinationActivity = async (id: number) => { + return await request.get({ url: '/promotion/combination-activity/get?id=' + id }) +} + +// 新增拼团活动 +export const createCombinationActivity = async (data: CombinationActivityVO) => { + return await request.post({ url: '/promotion/combination-activity/create', data }) +} + +// 修改拼团活动 +export const updateCombinationActivity = async (data: CombinationActivityVO) => { + return await request.put({ url: '/promotion/combination-activity/update', data }) +} + +// 关闭拼团活动 +export const closeCombinationActivity = async (id: number) => { + return await request.put({ url: '/promotion/combination-activity/close?id=' + id }) +} + +// 删除拼团活动 +export const deleteCombinationActivity = async (id: number) => { + return await request.delete({ url: '/promotion/combination-activity/delete?id=' + id }) +} diff --git a/src/api/mall/promotion/combination/combinationRecord.ts b/src/api/mall/promotion/combination/combinationRecord.ts new file mode 100644 index 0000000..b2b7d75 --- /dev/null +++ b/src/api/mall/promotion/combination/combinationRecord.ts @@ -0,0 +1,28 @@ +import request from '@/config/axios' + +export interface CombinationRecordVO { + id: number // 拼团记录编号 + activityId: number // 拼团活动编号 + nickname: string // 用户昵称 + avatar: string // 用户头像 + headId: number // 团长编号 + expireTime: string // 过期时间 + userSize: number // 可参团人数 + userCount: number // 已参团人数 + status: number // 拼团状态 + spuName: string // 商品名字 + picUrl: string // 商品图片 + virtualGroup: boolean // 是否虚拟成团 + startTime: string // 开始时间 (订单付款后开始的时间) + endTime: string // 结束时间(成团时间/失败时间) +} + +// 查询拼团记录列表 +export const getCombinationRecordPage = async (params: any) => { + return await request.get({ url: '/promotion/combination-record/page', params }) +} + +// 获得拼团记录的概要信息 +export const getCombinationRecordSummary = async () => { + return await request.get({ url: '/promotion/combination-record/get-summary' }) +} diff --git a/src/api/mall/promotion/coupon/coupon.ts b/src/api/mall/promotion/coupon/coupon.ts new file mode 100755 index 0000000..2ebff5d --- /dev/null +++ b/src/api/mall/promotion/coupon/coupon.ts @@ -0,0 +1,26 @@ +import request from '@/config/axios' + +// TODO @dhb52:vo 缺少 + +// 删除优惠劵 +export const deleteCoupon = async (id: number) => { + return request.delete({ + url: `/promotion/coupon/delete?id=${id}` + }) +} + +// 获得优惠劵分页 +export const getCouponPage = async (params: PageParam) => { + return request.get({ + url: '/promotion/coupon/page', + params: params + }) +} + +// 发送优惠券 +export const sendCoupon = async (data: any) => { + return request.post({ + url: '/promotion/coupon/send', + data: data + }) +} diff --git a/src/api/mall/promotion/coupon/couponTemplate.ts b/src/api/mall/promotion/coupon/couponTemplate.ts new file mode 100755 index 0000000..50ae226 --- /dev/null +++ b/src/api/mall/promotion/coupon/couponTemplate.ts @@ -0,0 +1,90 @@ +import request from '@/config/axios' + +export interface CouponTemplateVO { + id: number + name: string + status: number + totalCount: number + takeLimitCount: number + takeType: number + usePrice: number + productScope: number + productScopeValues: number[] + validityType: number + validStartTime: Date + validEndTime: Date + fixedStartTerm: number + fixedEndTerm: number + discountType: number + discountPercent: number + discountPrice: number + discountLimitPrice: number + takeCount: number + useCount: number +} + +// 创建优惠劵模板 +export function createCouponTemplate(data: CouponTemplateVO) { + return request.post({ + url: '/promotion/coupon-template/create', + data: data + }) +} + +// 更新优惠劵模板 +export function updateCouponTemplate(data: CouponTemplateVO) { + return request.put({ + url: '/promotion/coupon-template/update', + data: data + }) +} + +// 更新优惠劵模板的状态 +export function updateCouponTemplateStatus(id: number, status: [0, 1]) { + const data = { + id, + status + } + return request.put({ + url: '/promotion/coupon-template/update-status', + data: data + }) +} + +// 删除优惠劵模板 +export function deleteCouponTemplate(id: number) { + return request.delete({ + url: '/promotion/coupon-template/delete?id=' + id + }) +} + +// 获得优惠劵模板 +export function getCouponTemplate(id: number) { + return request.get({ + url: '/promotion/coupon-template/get?id=' + id + }) +} + +// 获得优惠劵模板分页 +export function getCouponTemplatePage(params: PageParam) { + return request.get({ + url: '/promotion/coupon-template/page', + params: params + }) +} + +// 获得优惠劵模板分页 +export function getCouponTemplateList(ids: number[]) { + return request.get({ + url: `/promotion/coupon-template/list?ids=${ids}` + }) +} + +// 导出优惠劵模板 Excel +export function exportCouponTemplateExcel(params: PageParam) { + return request.get({ + url: '/promotion/coupon-template/export-excel', + params: params, + responseType: 'blob' + }) +} diff --git a/src/api/mall/promotion/discount/discountActivity.ts b/src/api/mall/promotion/discount/discountActivity.ts new file mode 100644 index 0000000..e755c1b --- /dev/null +++ b/src/api/mall/promotion/discount/discountActivity.ts @@ -0,0 +1,60 @@ +import request from '@/config/axios' +import { Sku, Spu } from '@/api/mall/product/spu' + +export interface DiscountActivityVO { + id?: number + spuId?: number + name?: string + status?: number + remark?: string + startTime?: Date + endTime?: Date + products?: DiscountProductVO[] +} +// 限时折扣相关 属性 +export interface DiscountProductVO { + spuId: number + skuId: number + discountType: number + discountPercent: number + discountPrice: number +} + +// 扩展 Sku 配置 +export type SkuExtension = Sku & { + productConfig: DiscountProductVO +} + +export interface SpuExtension extends Spu { + skus: SkuExtension[] // 重写类型 +} + +// 查询限时折扣活动列表 +export const getDiscountActivityPage = async (params) => { + return await request.get({ url: '/promotion/discount-activity/page', params }) +} + +// 查询限时折扣活动详情 +export const getDiscountActivity = async (id: number) => { + return await request.get({ url: '/promotion/discount-activity/get?id=' + id }) +} + +// 新增限时折扣活动 +export const createDiscountActivity = async (data: DiscountActivityVO) => { + return await request.post({ url: '/promotion/discount-activity/create', data }) +} + +// 修改限时折扣活动 +export const updateDiscountActivity = async (data: DiscountActivityVO) => { + return await request.put({ url: '/promotion/discount-activity/update', data }) +} + +// 关闭限时折扣活动 +export const closeDiscountActivity = async (id: number) => { + return await request.put({ url: '/promotion/discount-activity/close?id=' + id }) +} + +// 删除限时折扣活动 +export const deleteDiscountActivity = async (id: number) => { + return await request.delete({ url: '/promotion/discount-activity/delete?id=' + id }) +} diff --git a/src/api/mall/promotion/diy/page.ts b/src/api/mall/promotion/diy/page.ts new file mode 100644 index 0000000..a834b24 --- /dev/null +++ b/src/api/mall/promotion/diy/page.ts @@ -0,0 +1,45 @@ +import request from '@/config/axios' + +export interface DiyPageVO { + id?: number + templateId?: number + name: string + remark: string + previewPicUrls: string[] + property: string +} + +// 查询装修页面列表 +export const getDiyPagePage = async (params: any) => { + return await request.get({ url: `/promotion/diy-page/page`, params }) +} + +// 查询装修页面详情 +export const getDiyPage = async (id: number) => { + return await request.get({ url: `/promotion/diy-page/get?id=` + id }) +} + +// 新增装修页面 +export const createDiyPage = async (data: DiyPageVO) => { + return await request.post({ url: `/promotion/diy-page/create`, data }) +} + +// 修改装修页面 +export const updateDiyPage = async (data: DiyPageVO) => { + return await request.put({ url: `/promotion/diy-page/update`, data }) +} + +// 删除装修页面 +export const deleteDiyPage = async (id: number) => { + return await request.delete({ url: `/promotion/diy-page/delete?id=` + id }) +} + +// 获得装修页面属性 +export const getDiyPageProperty = async (id: number) => { + return await request.get({ url: `/promotion/diy-page/get-property?id=` + id }) +} + +// 更新装修页面属性 +export const updateDiyPageProperty = async (data: DiyPageVO) => { + return await request.put({ url: `/promotion/diy-page/update-property`, data }) +} diff --git a/src/api/mall/promotion/diy/template.ts b/src/api/mall/promotion/diy/template.ts new file mode 100644 index 0000000..87134c9 --- /dev/null +++ b/src/api/mall/promotion/diy/template.ts @@ -0,0 +1,58 @@ +import request from '@/config/axios' +import { DiyPageVO } from '@/api/mall/promotion/diy/page' + +export interface DiyTemplateVO { + id?: number + name: string + used: boolean + usedTime?: Date + remark: string + previewPicUrls: string[] + property: string +} + +export interface DiyTemplatePropertyVO extends DiyTemplateVO { + pages: DiyPageVO[] +} + +// 查询装修模板列表 +export const getDiyTemplatePage = async (params: any) => { + return await request.get({ url: `/promotion/diy-template/page`, params }) +} + +// 查询装修模板详情 +export const getDiyTemplate = async (id: number) => { + return await request.get({ url: `/promotion/diy-template/get?id=` + id }) +} + +// 新增装修模板 +export const createDiyTemplate = async (data: DiyTemplateVO) => { + return await request.post({ url: `/promotion/diy-template/create`, data }) +} + +// 修改装修模板 +export const updateDiyTemplate = async (data: DiyTemplateVO) => { + return await request.put({ url: `/promotion/diy-template/update`, data }) +} + +// 删除装修模板 +export const deleteDiyTemplate = async (id: number) => { + return await request.delete({ url: `/promotion/diy-template/delete?id=` + id }) +} + +// 使用装修模板 +export const useDiyTemplate = async (id: number) => { + return await request.put({ url: `/promotion/diy-template/use?id=` + id }) +} + +// 获得装修模板属性 +export const getDiyTemplateProperty = async (id: number) => { + return await request.get<DiyTemplatePropertyVO>({ + url: `/promotion/diy-template/get-property?id=` + id + }) +} + +// 更新装修模板属性 +export const updateDiyTemplateProperty = async (data: DiyTemplateVO) => { + return await request.put({ url: `/promotion/diy-template/update-property`, data }) +} diff --git a/src/api/mall/promotion/kefu/conversation/index.ts b/src/api/mall/promotion/kefu/conversation/index.ts new file mode 100644 index 0000000..2dbf331 --- /dev/null +++ b/src/api/mall/promotion/kefu/conversation/index.ts @@ -0,0 +1,35 @@ +import request from '@/config/axios' + +export interface KeFuConversationRespVO { + id: number // 编号 + userId: number // 会话所属用户 + userAvatar: string // 会话所属用户头像 + userNickname: string // 会话所属用户昵称 + lastMessageTime: Date // 最后聊天时间 + lastMessageContent: string // 最后聊天内容 + lastMessageContentType: number // 最后发送的消息类型 + adminPinned: boolean // 管理端置顶 + userDeleted: boolean // 用户是否可见 + adminDeleted: boolean // 管理员是否可见 + adminUnreadMessageCount: number // 管理员未读消息数 + createTime?: string // 创建时间 +} + +// 客服会话 API +export const KeFuConversationApi = { + // 获得客服会话列表 + getConversationList: async () => { + return await request.get({ url: '/promotion/kefu-conversation/list' }) + }, + // 客服会话置顶 + updateConversationPinned: async (data: any) => { + return await request.put({ + url: '/promotion/kefu-conversation/update-conversation-pinned', + data + }) + }, + // 删除客服会话 + deleteConversation: async (id: number) => { + return await request.get({ url: '/promotion/kefu-conversation/delete?id' + id }) + } +} diff --git a/src/api/mall/promotion/kefu/message/index.ts b/src/api/mall/promotion/kefu/message/index.ts new file mode 100644 index 0000000..a12167f --- /dev/null +++ b/src/api/mall/promotion/kefu/message/index.ts @@ -0,0 +1,36 @@ +import request from '@/config/axios' + +export interface KeFuMessageRespVO { + id: number // 编号 + conversationId: number // 会话编号 + senderId: number // 发送人编号 + senderAvatar: string // 发送人头像 + senderType: number // 发送人类型 + receiverId: number // 接收人编号 + receiverType: number // 接收人类型 + contentType: number // 消息类型 + content: string // 消息 + readStatus: boolean // 是否已读 + createTime: Date // 创建时间 +} + +// 客服会话 API +export const KeFuMessageApi = { + // 发送客服消息 + sendKeFuMessage: async (data: any) => { + return await request.post({ + url: '/promotion/kefu-message/send', + data + }) + }, + // 更新客服消息已读状态 + updateKeFuMessageReadStatus: async (conversationId: number) => { + return await request.put({ + url: '/promotion/kefu-message/update-read-status?conversationId=' + conversationId + }) + }, + // 获得消息分页数据 + getKeFuMessagePage: async (params: any) => { + return await request.get({ url: '/promotion/kefu-message/page', params }) + } +} diff --git a/src/api/mall/promotion/reward/rewardActivity.ts b/src/api/mall/promotion/reward/rewardActivity.ts new file mode 100644 index 0000000..691db47 --- /dev/null +++ b/src/api/mall/promotion/reward/rewardActivity.ts @@ -0,0 +1,48 @@ +import request from '@/config/axios' + +export interface DiscountActivityVO { + id?: number + name?: string + startTime?: Date + endTime?: Date + remark?: string + conditionType?: number + productScope?: number + productSpuIds?: number[] + rules?: DiscountProductVO[] +} + +// 优惠规则 +export interface DiscountProductVO { + limit: number + discountPrice: number + freeDelivery: boolean + point: number + couponIds: number[] + couponCounts: number[] +} + +// 新增满减送活动 +export const createRewardActivity = async (data: DiscountActivityVO) => { + return await request.post({ url: '/promotion/reward-activity/create', data }) +} + +// 更新满减送活动 +export const updateRewardActivity = async (data: DiscountActivityVO) => { + return await request.put({ url: '/promotion/reward-activity/update', data }) +} + +// 查询满减送活动列表 +export const getRewardActivityPage = async (params) => { + return await request.get({ url: '/promotion/reward-activity/page', params }) +} + +// 查询满减送活动详情 +export const getReward = async (id: number) => { + return await request.get({ url: '/promotion/reward-activity/get?id=' + id }) +} + +// 删除限时折扣活动 +export const deleteRewardActivity = async (id: number) => { + return await request.delete({ url: '/promotion/reward-activity/delete?id=' + id }) +} diff --git a/src/api/mall/promotion/seckill/seckillActivity.ts b/src/api/mall/promotion/seckill/seckillActivity.ts new file mode 100644 index 0000000..e834641 --- /dev/null +++ b/src/api/mall/promotion/seckill/seckillActivity.ts @@ -0,0 +1,68 @@ +import request from '@/config/axios' +import { Sku, Spu } from '@/api/mall/product/spu' + +export interface SeckillActivityVO { + id?: number + spuId?: number + name?: string + status?: number + remark?: string + startTime?: Date + endTime?: Date + sort?: number + configIds?: string + orderCount?: number + userCount?: number + totalPrice?: number + totalLimitCount?: number + singleLimitCount?: number + stock?: number + totalStock?: number + products?: SeckillProductVO[] +} + +// 秒杀活动所需属性 +export interface SeckillProductVO { + skuId: number + seckillPrice: number + stock: number +} + +// 扩展 Sku 配置 +export type SkuExtension = Sku & { + productConfig: SeckillProductVO +} + +export interface SpuExtension extends Spu { + skus: SkuExtension[] // 重写类型 +} + +// 查询秒杀活动列表 +export const getSeckillActivityPage = async (params) => { + return await request.get({ url: '/promotion/seckill-activity/page', params }) +} + +// 查询秒杀活动详情 +export const getSeckillActivity = async (id: number) => { + return await request.get({ url: '/promotion/seckill-activity/get?id=' + id }) +} + +// 新增秒杀活动 +export const createSeckillActivity = async (data: SeckillActivityVO) => { + return await request.post({ url: '/promotion/seckill-activity/create', data }) +} + +// 修改秒杀活动 +export const updateSeckillActivity = async (data: SeckillActivityVO) => { + return await request.put({ url: '/promotion/seckill-activity/update', data }) +} + +// 关闭秒杀活动 +export const closeSeckillActivity = async (id: number) => { + return await request.put({ url: '/promotion/seckill-activity/close?id=' + id }) +} + +// 删除秒杀活动 +export const deleteSeckillActivity = async (id: number) => { + return await request.delete({ url: '/promotion/seckill-activity/delete?id=' + id }) +} diff --git a/src/api/mall/promotion/seckill/seckillConfig.ts b/src/api/mall/promotion/seckill/seckillConfig.ts new file mode 100644 index 0000000..37d9b54 --- /dev/null +++ b/src/api/mall/promotion/seckill/seckillConfig.ts @@ -0,0 +1,53 @@ +import request from '@/config/axios' + +// 秒杀时段 VO +export interface SeckillConfigVO { + id: number // 编号 + name: string // 秒杀时段名称 + startTime: string // 开始时间点 + endTime: string // 结束时间点 + sliderPicUrls: string[] // 秒杀轮播图 + status: number // 活动状态 +} + +// 秒杀时段 API +export const SeckillConfigApi = { + // 查询秒杀时段分页 + getSeckillConfigPage: async (params: any) => { + return await request.get({ url: `/promotion/seckill-config/page`, params }) + }, + + // 查询秒杀时段列表 + getSimpleSeckillConfigList: async () => { + return await request.get({ url: `/promotion/seckill-config/list` }) + }, + + // 查询秒杀时段详情 + getSeckillConfig: async (id: number) => { + return await request.get({ url: `/promotion/seckill-config/get?id=` + id }) + }, + + // 新增秒杀时段 + createSeckillConfig: async (data: SeckillConfigVO) => { + return await request.post({ url: `/promotion/seckill-config/create`, data }) + }, + + // 修改秒杀时段 + updateSeckillConfig: async (data: SeckillConfigVO) => { + return await request.put({ url: `/promotion/seckill-config/update`, data }) + }, + + // 删除秒杀时段 + deleteSeckillConfig: async (id: number) => { + return await request.delete({ url: `/promotion/seckill-config/delete?id=` + id }) + }, + + // 修改时段配置状态 + updateSeckillConfigStatus: async (id: number, status: number) => { + const data = { + id, + status + } + return request.put({ url: '/promotion/seckill-config/update-status', data: data }) + } +} diff --git a/src/api/mall/statistics/common.ts b/src/api/mall/statistics/common.ts new file mode 100644 index 0000000..3d96439 --- /dev/null +++ b/src/api/mall/statistics/common.ts @@ -0,0 +1,5 @@ +/** 数据对照 Response VO */ +export interface DataComparisonRespVO<T> { + value: T + reference: T +} diff --git a/src/api/mall/statistics/member.ts b/src/api/mall/statistics/member.ts new file mode 100644 index 0000000..d9accf9 --- /dev/null +++ b/src/api/mall/statistics/member.ts @@ -0,0 +1,123 @@ +import request from '@/config/axios' +import dayjs from 'dayjs' +import { DataComparisonRespVO } from '@/api/mall/statistics/common' +import { formatDate } from '@/utils/formatTime' + +/** 会员分析 Request VO */ +export interface MemberAnalyseReqVO { + times: dayjs.ConfigType[] +} + +/** 会员分析 Response VO */ +export interface MemberAnalyseRespVO { + visitUserCount: number + orderUserCount: number + payUserCount: number + atv: number + comparison: DataComparisonRespVO<MemberAnalyseComparisonRespVO> +} + +/** 会员分析对照数据 Response VO */ +export interface MemberAnalyseComparisonRespVO { + registerUserCount: number + visitUserCount: number + rechargeUserCount: number +} + +/** 会员地区统计 Response VO */ +export interface MemberAreaStatisticsRespVO { + areaId: number + areaName: string + userCount: number + orderCreateUserCount: number + orderPayUserCount: number + orderPayPrice: number +} + +/** 会员性别统计 Response VO */ +export interface MemberSexStatisticsRespVO { + sex: number + userCount: number +} + +/** 会员统计 Response VO */ +export interface MemberSummaryRespVO { + userCount: number + rechargeUserCount: number + rechargePrice: number + expensePrice: number +} + +/** 会员终端统计 Response VO */ +export interface MemberTerminalStatisticsRespVO { + terminal: number + userCount: number +} + +/** 会员数量统计 Response VO */ +export interface MemberCountRespVO { + /** 用户访问量 */ + visitUserCount: string + /** 注册用户数量 */ + registerUserCount: number +} + +/** 会员注册数量 Response VO */ +export interface MemberRegisterCountRespVO { + date: string + count: number +} + +// 查询会员统计 +export const getMemberSummary = () => { + return request.get<MemberSummaryRespVO>({ + url: '/statistics/member/summary' + }) +} + +// 查询会员分析数据 +export const getMemberAnalyse = (params: MemberAnalyseReqVO) => { + return request.get<MemberAnalyseRespVO>({ + url: '/statistics/member/analyse', + params: { times: [formatDate(params.times[0]), formatDate(params.times[1])] } + }) +} + +// 按照省份,查询会员统计列表 +export const getMemberAreaStatisticsList = () => { + return request.get<MemberAreaStatisticsRespVO[]>({ + url: '/statistics/member/area-statistics-list' + }) +} + +// 按照性别,查询会员统计列表 +export const getMemberSexStatisticsList = () => { + return request.get<MemberSexStatisticsRespVO[]>({ + url: '/statistics/member/sex-statistics-list' + }) +} + +// 按照终端,查询会员统计列表 +export const getMemberTerminalStatisticsList = () => { + return request.get<MemberTerminalStatisticsRespVO[]>({ + url: '/statistics/member/terminal-statistics-list' + }) +} + +// 获得用户数量量对照 +export const getUserCountComparison = () => { + return request.get<DataComparisonRespVO<MemberCountRespVO>>({ + url: '/statistics/member/user-count-comparison' + }) +} + +// 获得会员注册数量列表 +export const getMemberRegisterCountList = ( + beginTime: dayjs.ConfigType, + endTime: dayjs.ConfigType +) => { + return request.get<MemberRegisterCountRespVO[]>({ + url: '/statistics/member/register-count-list', + params: { times: [formatDate(beginTime), formatDate(endTime)] } + }) +} diff --git a/src/api/mall/statistics/pay.ts b/src/api/mall/statistics/pay.ts new file mode 100644 index 0000000..f5d14c9 --- /dev/null +++ b/src/api/mall/statistics/pay.ts @@ -0,0 +1,12 @@ +import request from '@/config/axios' + +/** 支付统计 */ +export interface PaySummaryRespVO { + /** 充值金额,单位分 */ + rechargePrice: number +} + +/** 获取钱包充值金额 */ +export const getWalletRechargePrice = async () => { + return await request.get<PaySummaryRespVO>({ url: `/statistics/pay/summary` }) +} diff --git a/src/api/mall/statistics/product.ts b/src/api/mall/statistics/product.ts new file mode 100644 index 0000000..798a2fa --- /dev/null +++ b/src/api/mall/statistics/product.ts @@ -0,0 +1,52 @@ +import request from '@/config/axios' +import { DataComparisonRespVO } from '@/api/mall/statistics/common' + +export interface ProductStatisticsVO { + id: number + day: string + spuId: number + spuName: string + spuPicUrl: string + browseCount: number + browseUserCount: number + favoriteCount: number + cartCount: number + orderCount: number + orderPayCount: number + orderPayPrice: number + afterSaleCount: number + afterSaleRefundPrice: number + browseConvertPercent: number +} + +// 商品统计 API +export const ProductStatisticsApi = { + // 获得商品统计分析 + getProductStatisticsAnalyse: (params: any) => { + return request.get<DataComparisonRespVO<ProductStatisticsVO>>({ + url: '/statistics/product/analyse', + params + }) + }, + // 获得商品状况明细 + getProductStatisticsList: (params: any) => { + return request.get<ProductStatisticsVO[]>({ + url: '/statistics/product/list', + params + }) + }, + // 导出获得商品状况明细 Excel + exportProductStatisticsExcel: (params: any) => { + return request.download({ + url: '/statistics/product/export-excel', + params + }) + }, + // 获得商品排行榜分页 + getProductStatisticsRankPage: async (params: any) => { + return await request.get({ + url: `/statistics/product/rank-page`, + params + }) + } +} diff --git a/src/api/mall/statistics/trade.ts b/src/api/mall/statistics/trade.ts new file mode 100644 index 0000000..e59952a --- /dev/null +++ b/src/api/mall/statistics/trade.ts @@ -0,0 +1,119 @@ +import request from '@/config/axios' +import dayjs from 'dayjs' +import { formatDate } from '@/utils/formatTime' +import { DataComparisonRespVO } from '@/api/mall/statistics/common' + +/** 交易统计 Response VO */ +export interface TradeSummaryRespVO { + yesterdayOrderCount: number + monthOrderCount: number + yesterdayPayPrice: number + monthPayPrice: number +} + +/** 交易状况 Request VO */ +export interface TradeTrendReqVO { + times: [dayjs.ConfigType, dayjs.ConfigType] +} + +/** 交易状况统计 Response VO */ +export interface TradeTrendSummaryRespVO { + time: string + turnoverPrice: number + orderPayPrice: number + rechargePrice: number + expensePrice: number + walletPayPrice: number + brokerageSettlementPrice: number + afterSaleRefundPrice: number +} + +/** 交易订单数量 Response VO */ +export interface TradeOrderCountRespVO { + /** 待发货 */ + undelivered?: number + /** 待核销 */ + pickUp?: number + /** 退款中 */ + afterSaleApply?: number + /** 提现待审核 */ + auditingWithdraw?: number +} + +/** 交易订单统计 Response VO */ +export interface TradeOrderSummaryRespVO { + /** 支付订单商品数 */ + orderPayCount?: number + /** 总支付金额,单位:分 */ + orderPayPrice?: number +} + +/** 订单量趋势统计 Response VO */ +export interface TradeOrderTrendRespVO { + /** 日期 */ + date: string + /** 订单数量 */ + orderPayCount: number + /** 订单支付金额 */ + orderPayPrice: number +} + +// 查询交易统计 +export const getTradeStatisticsSummary = () => { + return request.get<DataComparisonRespVO<TradeSummaryRespVO>>({ + url: '/statistics/trade/summary' + }) +} + +// 获得交易状况统计 +export const getTradeStatisticsAnalyse = (params: TradeTrendReqVO) => { + return request.get<DataComparisonRespVO<TradeTrendSummaryRespVO>>({ + url: '/statistics/trade/analyse', + params: formatDateParam(params) + }) +} + +// 获得交易状况明细 +export const getTradeStatisticsList = (params: TradeTrendReqVO) => { + return request.get<TradeTrendSummaryRespVO[]>({ + url: '/statistics/trade/list', + params: formatDateParam(params) + }) +} + +// 导出交易状况明细 +export const exportTradeStatisticsExcel = (params: TradeTrendReqVO) => { + return request.download({ + url: '/statistics/trade/export-excel', + params: formatDateParam(params) + }) +} + +// 获得交易订单数量 +export const getOrderCount = async () => { + return await request.get<TradeOrderCountRespVO>({ url: `/statistics/trade/order-count` }) +} + +// 获得交易订单数量对照 +export const getOrderComparison = async () => { + return await request.get<DataComparisonRespVO<TradeOrderSummaryRespVO>>({ + url: `/statistics/trade/order-comparison` + }) +} + +// 获得订单量趋势统计 +export const getOrderCountTrendComparison = ( + type: number, + beginTime: dayjs.ConfigType, + endTime: dayjs.ConfigType +) => { + return request.get<DataComparisonRespVO<TradeOrderTrendRespVO>[]>({ + url: '/statistics/trade/order-count-trend', + params: { type, beginTime: formatDate(beginTime), endTime: formatDate(endTime) } + }) +} + +/** 时间参数需要格式化, 确保接口能识别 */ +const formatDateParam = (params: TradeTrendReqVO) => { + return { times: [formatDate(params.times[0]), formatDate(params.times[1])] } as TradeTrendReqVO +} diff --git a/src/api/mall/trade/afterSale/index.ts b/src/api/mall/trade/afterSale/index.ts new file mode 100644 index 0000000..a109ee6 --- /dev/null +++ b/src/api/mall/trade/afterSale/index.ts @@ -0,0 +1,75 @@ +import request from '@/config/axios' + +export interface TradeAfterSaleVO { + id?: number | null // 售后编号,主键自增 + no?: string // 售后单号 + status?: number | null // 退款状态 + way?: number | null // 售后方式 + type?: number | null // 售后类型 + userId?: number | null // 用户编号 + applyReason?: string // 申请原因 + applyDescription?: string // 补充描述 + applyPicUrls?: string[] // 补充凭证图片 + orderId?: number | null // 交易订单编号 + orderNo?: string // 订单流水号 + orderItemId?: number | null // 交易订单项编号 + spuId?: number | null // 商品 SPU 编号 + spuName?: string // 商品 SPU 名称 + skuId?: number | null // 商品 SKU 编号 + properties?: ProductPropertiesVO[] // 属性数组 + picUrl?: string // 商品图片 + count?: number | null // 退货商品数量 + auditTime?: Date // 审批时间 + auditUserId?: number | null // 审批人 + auditReason?: string // 审批备注 + refundPrice?: number | null // 退款金额,单位:分。 + payRefundId?: number | null // 支付退款编号 + refundTime?: Date // 退款时间 + logisticsId?: number | null // 退货物流公司编号 + logisticsNo?: string // 退货物流单号 + deliveryTime?: Date // 退货时间 + receiveTime?: Date // 收货时间 + receiveReason?: string // 收货备注 +} + +export interface ProductPropertiesVO { + propertyId?: number | null // 属性的编号 + propertyName?: string // 属性的名称 + valueId?: number | null //属性值的编号 + valueName?: string // 属性值的名称 +} + +// 获得交易售后分页 +export const getAfterSalePage = async (params) => { + return await request.get({ url: `/trade/after-sale/page`, params }) +} + +// 获得交易售后详情 +export const getAfterSale = async (id: any) => { + return await request.get({ url: `/trade/after-sale/get-detail?id=${id}` }) +} + +// 同意售后 +export const agree = async (id: any) => { + return await request.put({ url: `/trade/after-sale/agree?id=${id}` }) +} + +// 拒绝售后 +export const disagree = async (data: any) => { + return await request.put({ url: `/trade/after-sale/disagree`, data }) +} + +// 确认收货 +export const receive = async (id: any) => { + return await request.put({ url: `/trade/after-sale/receive?id=${id}` }) +} + +// 拒绝收货 +export const refuse = async (id: any) => { + return await request.put({ url: `/trade/after-sale/refuse?id=${id}` }) +} + +// 确认退款 +export const refund = async (id: any) => { + return await request.put({ url: `/trade/after-sale/refund?id=${id}` }) +} diff --git a/src/api/mall/trade/brokerage/record/index.ts b/src/api/mall/trade/brokerage/record/index.ts new file mode 100644 index 0000000..7df9a22 --- /dev/null +++ b/src/api/mall/trade/brokerage/record/index.ts @@ -0,0 +1,11 @@ +import request from '@/config/axios' + +// 查询佣金记录列表 +export const getBrokerageRecordPage = async (params: any) => { + return await request.get({ url: `/trade/brokerage-record/page`, params }) +} + +// 查询佣金记录详情 +export const getBrokerageRecord = async (id: number) => { + return await request.get({ url: `/trade/brokerage-record/get?id=` + id }) +} diff --git a/src/api/mall/trade/brokerage/user/index.ts b/src/api/mall/trade/brokerage/user/index.ts new file mode 100644 index 0000000..1fed3bf --- /dev/null +++ b/src/api/mall/trade/brokerage/user/index.ts @@ -0,0 +1,39 @@ +import request from '@/config/axios' + +export interface BrokerageUserVO { + id: number + bindUserId: number + bindUserTime: Date + brokerageEnabled: boolean + brokerageTime: Date + price: number + frozenPrice: number + + nickname: string + avatar: string +} + +// 查询分销用户列表 +export const getBrokerageUserPage = async (params: any) => { + return await request.get({ url: `/trade/brokerage-user/page`, params }) +} + +// 查询分销用户详情 +export const getBrokerageUser = async (id: number) => { + return await request.get({ url: `/trade/brokerage-user/get?id=` + id }) +} + +// 修改推广员 +export const updateBindUser = async (data: any) => { + return await request.put({ url: `/trade/brokerage-user/update-bind-user`, data }) +} + +// 清除推广员 +export const clearBindUser = async (data: any) => { + return await request.put({ url: `/trade/brokerage-user/clear-bind-user`, data }) +} + +// 修改推广资格 +export const updateBrokerageEnabled = async (data: any) => { + return await request.put({ url: `/trade/brokerage-user/update-brokerage-enable`, data }) +} diff --git a/src/api/mall/trade/brokerage/withdraw/index.ts b/src/api/mall/trade/brokerage/withdraw/index.ts new file mode 100644 index 0000000..c93286a --- /dev/null +++ b/src/api/mall/trade/brokerage/withdraw/index.ts @@ -0,0 +1,39 @@ +import request from '@/config/axios' + +export interface BrokerageWithdrawVO { + id: number + userId: number + price: number + feePrice: number + totalPrice: number + type: number + name: string + accountNo: string + bankName: string + bankAddress: string + accountQrCodeUrl: string + status: number + auditReason: string + auditTime: Date + remark: string +} + +// 查询佣金提现列表 +export const getBrokerageWithdrawPage = async (params: any) => { + return await request.get({ url: `/trade/brokerage-withdraw/page`, params }) +} + +// 查询佣金提现详情 +export const getBrokerageWithdraw = async (id: number) => { + return await request.get({ url: `/trade/brokerage-withdraw/get?id=` + id }) +} + +// 佣金提现 - 通过申请 +export const approveBrokerageWithdraw = async (id: number) => { + return await request.put({ url: `/trade/brokerage-withdraw/approve?id=` + id }) +} + +// 审核佣金提现 - 驳回申请 +export const rejectBrokerageWithdraw = async (data: BrokerageWithdrawVO) => { + return await request.put({ url: `/trade/brokerage-withdraw/reject`, data }) +} diff --git a/src/api/mall/trade/config/index.ts b/src/api/mall/trade/config/index.ts new file mode 100644 index 0000000..43fdbdf --- /dev/null +++ b/src/api/mall/trade/config/index.ts @@ -0,0 +1,23 @@ +import request from '@/config/axios' + +export interface ConfigVO { + brokerageEnabled: boolean + brokerageEnabledCondition: number + brokerageBindMode: number + brokeragePosterUrls: string + brokerageFirstPercent: number + brokerageSecondPercent: number + brokerageWithdrawMinPrice: number + brokerageFrozenDays: number + brokerageWithdrawTypes: string +} + +// 查询交易中心配置详情 +export const getTradeConfig = async () => { + return await request.get({ url: `/trade/config/get` }) +} + +// 保存交易中心配置 +export const saveTradeConfig = async (data: ConfigVO) => { + return await request.put({ url: `/trade/config/save`, data }) +} diff --git a/src/api/mall/trade/delivery/express/index.ts b/src/api/mall/trade/delivery/express/index.ts new file mode 100644 index 0000000..0070bcd --- /dev/null +++ b/src/api/mall/trade/delivery/express/index.ts @@ -0,0 +1,45 @@ +import request from '@/config/axios' + +export interface DeliveryExpressVO { + id: number + code: string + name: string + logo: string + sort: number + status: number +} + +// 查询快递公司列表 +export const getDeliveryExpressPage = async (params: PageParam) => { + return await request.get({ url: '/trade/delivery/express/page', params }) +} + +// 查询快递公司详情 +export const getDeliveryExpress = async (id: number) => { + return await request.get({ url: '/trade/delivery/express/get?id=' + id }) +} + +// 获得快递公司精简信息列表 +export const getSimpleDeliveryExpressList = () => { + return request.get({ url: '/trade/delivery/express/list-all-simple' }) +} + +// 新增快递公司 +export const createDeliveryExpress = async (data: DeliveryExpressVO) => { + return await request.post({ url: '/trade/delivery/express/create', data }) +} + +// 修改快递公司 +export const updateDeliveryExpress = async (data: DeliveryExpressVO) => { + return await request.put({ url: '/trade/delivery/express/update', data }) +} + +// 删除快递公司 +export const deleteDeliveryExpress = async (id: number) => { + return await request.delete({ url: '/trade/delivery/express/delete?id=' + id }) +} + +// 导出快递公司 Excel +export const exportDeliveryExpressApi = async (params) => { + return await request.download({ url: '/trade/delivery/express/export-excel', params }) +} diff --git a/src/api/mall/trade/delivery/expressTemplate/index.ts b/src/api/mall/trade/delivery/expressTemplate/index.ts new file mode 100644 index 0000000..9ed23bc --- /dev/null +++ b/src/api/mall/trade/delivery/expressTemplate/index.ts @@ -0,0 +1,54 @@ +import request from '@/config/axios' + +export interface DeliveryExpressTemplateVO { + id: number + name: string + chargeMode: number + sort: number + templateCharge: ExpressTemplateChargeVO[] + templateFree: ExpressTemplateFreeVO[] +} + +export declare type ExpressTemplateChargeVO = { + areaIds: number[] + startCount: number + startPrice: number + extraCount: number + extraPrice: number +} + +export declare type ExpressTemplateFreeVO = { + areaIds: number[] + freeCount: number + freePrice: number +} + +// 查询快递运费模板列表 +export const getDeliveryExpressTemplatePage = async (params: PageParam) => { + return await request.get({ url: '/trade/delivery/express-template/page', params }) +} + +// 查询快递运费模板详情 +export const getDeliveryExpressTemplate = async (id: number) => { + return await request.get({ url: '/trade/delivery/express-template/get?id=' + id }) +} + +// 查询快递运费模板详情 +export const getSimpleTemplateList = async () => { + return await request.get({ url: '/trade/delivery/express-template/list-all-simple' }) +} + +// 新增快递运费模板 +export const createDeliveryExpressTemplate = async (data: DeliveryExpressTemplateVO) => { + return await request.post({ url: '/trade/delivery/express-template/create', data }) +} + +// 修改快递运费模板 +export const updateDeliveryExpressTemplate = async (data: DeliveryExpressTemplateVO) => { + return await request.put({ url: '/trade/delivery/express-template/update', data }) +} + +// 删除快递运费模板 +export const deleteDeliveryExpressTemplate = async (id: number) => { + return await request.delete({ url: '/trade/delivery/express-template/delete?id=' + id }) +} diff --git a/src/api/mall/trade/delivery/pickUpStore/index.ts b/src/api/mall/trade/delivery/pickUpStore/index.ts new file mode 100644 index 0000000..c317502 --- /dev/null +++ b/src/api/mall/trade/delivery/pickUpStore/index.ts @@ -0,0 +1,46 @@ +import request from '@/config/axios' + +export interface DeliveryPickUpStoreVO { + id: number + name: string + introduction: string + phone: string + areaId: number + detailAddress: string + logo: string + openingTime: string + closingTime: string + latitude: number + longitude: number + status: number +} + +// 查询自提门店列表 +export const getDeliveryPickUpStorePage = async (params) => { + return await request.get({ url: '/trade/delivery/pick-up-store/page', params }) +} + +// 查询自提门店详情 +export const getDeliveryPickUpStore = async (id: number) => { + return await request.get({ url: '/trade/delivery/pick-up-store/get?id=' + id }) +} + +// 查询自提门店精简列表 +export const getListAllSimple = async (): Promise<DeliveryPickUpStoreVO[]> => { + return await request.get({ url: '/trade/delivery/pick-up-store/list-all-simple' }) +} + +// 新增自提门店 +export const createDeliveryPickUpStore = async (data: DeliveryPickUpStoreVO) => { + return await request.post({ url: '/trade/delivery/pick-up-store/create', data }) +} + +// 修改自提门店 +export const updateDeliveryPickUpStore = async (data: DeliveryPickUpStoreVO) => { + return await request.put({ url: '/trade/delivery/pick-up-store/update', data }) +} + +// 删除自提门店 +export const deleteDeliveryPickUpStore = async (id: number) => { + return await request.delete({ url: '/trade/delivery/pick-up-store/delete?id=' + id }) +} diff --git a/src/api/mall/trade/order/index.ts b/src/api/mall/trade/order/index.ts new file mode 100644 index 0000000..37fee8c --- /dev/null +++ b/src/api/mall/trade/order/index.ts @@ -0,0 +1,188 @@ +import request from '@/config/axios' + +export interface OrderVO { + // ========== 订单基本信息 ========== + id?: number | null // 订单编号 + no?: string // 订单流水号 + createTime?: Date | null // 下单时间 + type?: number | null // 订单类型 + terminal?: number | null // 订单来源 + userId?: number | null // 用户编号 + userIp?: string // 用户 IP + userRemark?: string // 用户备注 + status?: number | null // 订单状态 + productCount?: number | null // 购买的商品数量 + finishTime?: Date | null // 订单完成时间 + cancelTime?: Date | null // 订单取消时间 + cancelType?: number | null // 取消类型 + remark?: string // 商家备注 + + // ========== 价格 + 支付基本信息 ========== + payOrderId?: number | null // 支付订单编号 + payStatus?: boolean // 是否已支付 + payTime?: Date | null // 付款时间 + payChannelCode?: string // 支付渠道 + totalPrice?: number | null // 商品原价(总) + discountPrice?: number | null // 订单优惠(总) + deliveryPrice?: number | null // 运费金额 + adjustPrice?: number | null // 订单调价(总) + payPrice?: number | null // 应付金额(总) + // ========== 收件 + 物流基本信息 ========== + deliveryType?: number | null // 发货方式 + pickUpStoreId?: number // 自提门店编号 + pickUpVerifyCode?: string // 自提核销码 + deliveryTemplateId?: number | null // 配送模板编号 + logisticsId?: number | null // 发货物流公司编号 + logisticsNo?: string // 发货物流单号 + deliveryTime?: Date | null // 发货时间 + receiveTime?: Date | null // 收货时间 + receiverName?: string // 收件人名称 + receiverMobile?: string // 收件人手机 + receiverPostCode?: number | null // 收件人邮编 + receiverAreaId?: number | null // 收件人地区编号 + receiverAreaName?: string //收件人地区名字 + receiverDetailAddress?: string // 收件人详细地址 + + // ========== 售后基本信息 ========== + afterSaleStatus?: number | null // 售后状态 + refundPrice?: number | null // 退款金额 + + // ========== 营销基本信息 ========== + couponId?: number | null // 优惠劵编号 + couponPrice?: number | null // 优惠劵减免金额 + pointPrice?: number | null // 积分抵扣的金额 + vipPrice?: number | null // VIP 减免金额 + + items?: OrderItemRespVO[] // 订单项列表 + // 下单用户信息 + user?: { + id?: number | null + nickname?: string + avatar?: string + } + // 推广用户信息 + brokerageUser?: { + id?: number | null + nickname?: string + avatar?: string + } + // 订单操作日志 + logs?: OrderLogRespVO[] +} + +export interface OrderLogRespVO { + content?: string + createTime?: Date + userType?: number +} + +export interface OrderItemRespVO { + // ========== 订单项基本信息 ========== + id?: number | null // 编号 + userId?: number | null // 用户编号 + orderId?: number | null // 订单编号 + // ========== 商品基本信息 ========== + spuId?: number | null // 商品 SPU 编号 + spuName?: string //商品 SPU 名称 + skuId?: number | null // 商品 SKU 编号 + picUrl?: string //商品图片 + count?: number | null //购买数量 + // ========== 价格 + 支付基本信息 ========== + originalPrice?: number | null //商品原价(总) + originalUnitPrice?: number | null //商品原价(单) + discountPrice?: number | null //商品优惠(总) + payPrice?: number | null //商品实付金额(总) + orderPartPrice?: number | null //子订单分摊金额(总) + orderDividePrice?: number | null //分摊后子订单实付金额(总) + // ========== 营销基本信息 ========== + // TODO 芋艿:在捉摸一下 + // ========== 售后基本信息 ========== + afterSaleStatus?: number | null // 售后状态 + properties?: ProductPropertiesVO[] //属性数组 +} + +export interface ProductPropertiesVO { + propertyId?: number | null // 属性的编号 + propertyName?: string // 属性的名称 + valueId?: number | null //属性值的编号 + valueName?: string // 属性值的名称 +} + +/** 交易订单统计 */ +export interface TradeOrderSummaryRespVO { + /** 订单数量 */ + orderCount?: number + /** 订单金额 */ + orderPayPrice?: string + /** 退款单数 */ + afterSaleCount?: number + /** 退款金额 */ + afterSalePrice?: string +} + +// 查询交易订单列表 +export const getOrderPage = async (params: any) => { + return await request.get({ url: `/trade/order/page`, params }) +} + +// 查询交易订单统计 +export const getOrderSummary = async (params: any) => { + return await request.get<TradeOrderSummaryRespVO>({ url: `/trade/order/summary`, params }) +} + +// 查询交易订单详情 +export const getOrder = async (id: number | null) => { + return await request.get({ url: `/trade/order/get-detail?id=` + id }) +} + +// 查询交易订单物流详情 +export const getExpressTrackList = async (id: number | null) => { + return await request.get({ url: `/trade/order/get-express-track-list?id=` + id }) +} + +export interface DeliveryVO { + id?: number // 订单编号 + logisticsId: number | null // 物流公司编号 + logisticsNo: string // 物流编号 +} + +// 订单发货 +export const deliveryOrder = async (data: DeliveryVO) => { + return await request.put({ url: `/trade/order/delivery`, data }) +} + +// 订单备注 +export const updateOrderRemark = async (data: any) => { + return await request.put({ url: `/trade/order/update-remark`, data }) +} + +// 订单调价 +export const updateOrderPrice = async (data: any) => { + return await request.put({ url: `/trade/order/update-price`, data }) +} + +// 修改订单地址 +export const updateOrderAddress = async (data: any) => { + return await request.put({ url: `/trade/order/update-address`, data }) +} + +// 订单核销 +export const pickUpOrder = async (id: number) => { + return await request.put({ url: `/trade/order/pick-up-by-id?id=${id}` }) +} + +// 订单核销 +export const pickUpOrderByVerifyCode = async (pickUpVerifyCode: string) => { + return await request.put({ + url: `/trade/order/pick-up-by-verify-code`, + params: { pickUpVerifyCode } + }) +} + +// 查询核销码对应的订单 +export const getOrderByPickUpVerifyCode = async (pickUpVerifyCode: string) => { + return await request.get<OrderVO>({ + url: `/trade/order/get-by-pick-up-verify-code`, + params: { pickUpVerifyCode } + }) +} diff --git a/src/api/member/address/index.ts b/src/api/member/address/index.ts new file mode 100644 index 0000000..a914f97 --- /dev/null +++ b/src/api/member/address/index.ts @@ -0,0 +1,15 @@ +import request from '@/config/axios' + +export interface AddressVO { + id: number + name: string + mobile: string + areaId: number + detailAddress: string + defaultStatus: boolean +} + +// 查询用户收件地址列表 +export const getAddressList = async (params) => { + return await request.get({ url: `/member/address/list`, params }) +} diff --git a/src/api/member/config/index.ts b/src/api/member/config/index.ts new file mode 100644 index 0000000..7ddca16 --- /dev/null +++ b/src/api/member/config/index.ts @@ -0,0 +1,19 @@ +import request from '@/config/axios' + +export interface ConfigVO { + id: number + pointTradeDeductEnable: number + pointTradeDeductUnitPrice: number + pointTradeDeductMaxPrice: number + pointTradeGivePoint: number +} + +// 查询积分设置详情 +export const getConfig = async () => { + return await request.get({ url: `/member/config/get` }) +} + +// 新增修改积分设置 +export const saveConfig = async (data: ConfigVO) => { + return await request.put({ url: `/member/config/save`, data }) +} diff --git a/src/api/member/experience-record/index.ts b/src/api/member/experience-record/index.ts new file mode 100644 index 0000000..6d40a48 --- /dev/null +++ b/src/api/member/experience-record/index.ts @@ -0,0 +1,22 @@ +import request from '@/config/axios' + +export interface ExperienceRecordVO { + id: number + userId: number + bizId: string + bizType: number + title: string + description: string + experience: number + totalExperience: number +} + +// 查询会员经验记录列表 +export const getExperienceRecordPage = async (params) => { + return await request.get({ url: `/member/experience-record/page`, params }) +} + +// 查询会员经验记录详情 +export const getExperienceRecord = async (id: number) => { + return await request.get({ url: `/member/experience-record/get?id=` + id }) +} diff --git a/src/api/member/group/index.ts b/src/api/member/group/index.ts new file mode 100644 index 0000000..df3054e --- /dev/null +++ b/src/api/member/group/index.ts @@ -0,0 +1,38 @@ +import request from '@/config/axios' + +export interface GroupVO { + id: number + name: string + remark: string + status: number +} + +// 查询用户分组列表 +export const getGroupPage = async (params: any) => { + return await request.get({ url: `/member/group/page`, params }) +} + +// 查询用户分组详情 +export const getGroup = async (id: number) => { + return await request.get({ url: `/member/group/get?id=` + id }) +} + +// 新增用户分组 +export const createGroup = async (data: GroupVO) => { + return await request.post({ url: `/member/group/create`, data }) +} + +// 查询用户分组 - 精简信息列表 +export const getSimpleGroupList = async () => { + return await request.get({ url: `/member/group/list-all-simple` }) +} + +// 修改用户分组 +export const updateGroup = async (data: GroupVO) => { + return await request.put({ url: `/member/group/update`, data }) +} + +// 删除用户分组 +export const deleteGroup = async (id: number) => { + return await request.delete({ url: `/member/group/delete?id=` + id }) +} diff --git a/src/api/member/level/index.ts b/src/api/member/level/index.ts new file mode 100644 index 0000000..0ded493 --- /dev/null +++ b/src/api/member/level/index.ts @@ -0,0 +1,42 @@ +import request from '@/config/axios' + +export interface LevelVO { + id: number + name: string + experience: number + value: number + discountPercent: number + icon: string + bgUrl: string + status: number +} + +// 查询会员等级列表 +export const getLevelList = async (params) => { + return await request.get({ url: `/member/level/list`, params }) +} + +// 查询会员等级详情 +export const getLevel = async (id: number) => { + return await request.get({ url: `/member/level/get?id=` + id }) +} + +// 查询会员等级 - 精简信息列表 +export const getSimpleLevelList = async () => { + return await request.get({ url: `/member/level/list-all-simple` }) +} + +// 新增会员等级 +export const createLevel = async (data: LevelVO) => { + return await request.post({ url: `/member/level/create`, data }) +} + +// 修改会员等级 +export const updateLevel = async (data: LevelVO) => { + return await request.put({ url: `/member/level/update`, data }) +} + +// 删除会员等级 +export const deleteLevel = async (id: number) => { + return await request.delete({ url: `/member/level/delete?id=` + id }) +} diff --git a/src/api/member/point/record/index.ts b/src/api/member/point/record/index.ts new file mode 100644 index 0000000..f47ae46 --- /dev/null +++ b/src/api/member/point/record/index.ts @@ -0,0 +1,18 @@ +import request from '@/config/axios' + +export interface RecordVO { + id: number + bizId: string + bizType: string + title: string + description: string + point: number + totalPoint: number + userId: number + createDate: Date +} + +// 查询用户积分记录列表 +export const getRecordPage = async (params) => { + return await request.get({ url: `/member/point/record/page`, params }) +} diff --git a/src/api/member/signin/config/index.ts b/src/api/member/signin/config/index.ts new file mode 100644 index 0000000..50a7d63 --- /dev/null +++ b/src/api/member/signin/config/index.ts @@ -0,0 +1,34 @@ +import request from '@/config/axios' + +export interface SignInConfigVO { + id?: number + day?: number + point?: number + experience?: number + status?: number +} + +// 查询积分签到规则列表 +export const getSignInConfigList = async () => { + return await request.get({ url: `/member/sign-in/config/list` }) +} + +// 查询积分签到规则详情 +export const getSignInConfig = async (id: number) => { + return await request.get({ url: `/member/sign-in/config/get?id=` + id }) +} + +// 新增积分签到规则 +export const createSignInConfig = async (data: SignInConfigVO) => { + return await request.post({ url: `/member/sign-in/config/create`, data }) +} + +// 修改积分签到规则 +export const updateSignInConfig = async (data: SignInConfigVO) => { + return await request.put({ url: `/member/sign-in/config/update`, data }) +} + +// 删除积分签到规则 +export const deleteSignInConfig = async (id: number) => { + return await request.delete({ url: `/member/sign-in/config/delete?id=` + id }) +} diff --git a/src/api/member/signin/record/index.ts b/src/api/member/signin/record/index.ts new file mode 100644 index 0000000..7d13702 --- /dev/null +++ b/src/api/member/signin/record/index.ts @@ -0,0 +1,13 @@ +import request from '@/config/axios' + +export interface SignInRecordVO { + id: number + userId: number + day: number + point: number +} + +// 查询用户签到积分列表 +export const getSignInRecordPage = async (params) => { + return await request.get({ url: `/member/sign-in/record/page`, params }) +} diff --git a/src/api/member/tag/index.ts b/src/api/member/tag/index.ts new file mode 100644 index 0000000..7ff6e9b --- /dev/null +++ b/src/api/member/tag/index.ts @@ -0,0 +1,36 @@ +import request from '@/config/axios' + +export interface TagVO { + id: number + name: string +} + +// 查询会员标签列表 +export const getMemberTagPage = async (params: any) => { + return await request.get({ url: `/member/tag/page`, params }) +} + +// 查询会员标签详情 +export const getMemberTag = async (id: number) => { + return await request.get({ url: `/member/tag/get?id=` + id }) +} + +// 查询会员标签 - 精简信息列表 +export const getSimpleTagList = async () => { + return await request.get({ url: `/member/tag/list-all-simple` }) +} + +// 新增会员标签 +export const createMemberTag = async (data: TagVO) => { + return await request.post({ url: `/member/tag/create`, data }) +} + +// 修改会员标签 +export const updateMemberTag = async (data: TagVO) => { + return await request.put({ url: `/member/tag/update`, data }) +} + +// 删除会员标签 +export const deleteMemberTag = async (id: number) => { + return await request.delete({ url: `/member/tag/delete?id=` + id }) +} diff --git a/src/api/member/user/index.ts b/src/api/member/user/index.ts new file mode 100644 index 0000000..e38206a --- /dev/null +++ b/src/api/member/user/index.ts @@ -0,0 +1,53 @@ +import request from '@/config/axios' + +export interface UserVO { + id: number + avatar: string | undefined + birthday: number | undefined + createTime: number | undefined + loginDate: number | undefined + loginIp: string + mark: string + mobile: string + name: string | undefined + nickname: string | undefined + registerIp: string + sex: number + status: number + areaId: number | undefined + areaName: string | undefined + levelName: string | null + point: number | undefined | null + totalPoint: number | undefined | null + experience: number | null | undefined +} + +// 查询会员用户列表 +export const getUserPage = async (params) => { + return await request.get({ url: `/member/user/page`, params }) +} + +// 查询会员用户详情 +export const getUser = async (id: number) => { + return await request.get({ url: `/member/user/get?id=` + id }) +} + +// 修改会员用户 +export const updateUser = async (data: UserVO) => { + return await request.put({ url: `/member/user/update`, data }) +} + +// 修改会员用户等级 +export const updateUserLevel = async (data: any) => { + return await request.put({ url: `/member/user/update-level`, data }) +} + +// 修改会员用户积分 +export const updateUserPoint = async (data: any) => { + return await request.put({ url: `/member/user/update-point`, data }) +} + +// 修改会员用户余额 +export const updateUserBalance = async (data: any) => { + return await request.put({ url: `/member/user/update-balance`, data }) +} diff --git a/src/api/mp/account/index.ts b/src/api/mp/account/index.ts new file mode 100644 index 0000000..e973cda --- /dev/null +++ b/src/api/mp/account/index.ts @@ -0,0 +1,46 @@ +import request from '@/config/axios' + +export interface AccountVO { + id: number + name: string +} + +// 创建公众号账号 +export const createAccount = async (data) => { + return await request.post({ url: '/mp/account/create', data }) +} + +// 更新公众号账号 +export const updateAccount = async (data) => { + return request.put({ url: '/mp/account/update', data: data }) +} + +// 删除公众号账号 +export const deleteAccount = async (id) => { + return request.delete({ url: '/mp/account/delete?id=' + id, method: 'delete' }) +} + +// 获得公众号账号 +export const getAccount = async (id) => { + return request.get({ url: '/mp/account/get?id=' + id }) +} + +// 获得公众号账号分页 +export const getAccountPage = async (query) => { + return request.get({ url: '/mp/account/page', params: query }) +} + +// 获取公众号账号精简信息列表 +export const getSimpleAccountList = async () => { + return request.get({ url: '/mp/account/list-all-simple' }) +} + +// 生成公众号二维码 +export const generateAccountQrCode = async (id) => { + return request.put({ url: '/mp/account/generate-qr-code?id=' + id }) +} + +// 清空公众号 API 配额 +export const clearAccountQuota = async (id) => { + return request.put({ url: '/mp/account/clear-quota?id=' + id }) +} diff --git a/src/api/mp/autoReply/index.ts b/src/api/mp/autoReply/index.ts new file mode 100644 index 0000000..5045e6d --- /dev/null +++ b/src/api/mp/autoReply/index.ts @@ -0,0 +1,39 @@ +import request from '@/config/axios' + +// 创建公众号的自动回复 +export const createAutoReply = (data) => { + return request.post({ + url: '/mp/auto-reply/create', + data: data + }) +} + +// 更新公众号的自动回复 +export const updateAutoReply = (data) => { + return request.put({ + url: '/mp/auto-reply/update', + data: data + }) +} + +// 删除公众号的自动回复 +export const deleteAutoReply = (id) => { + return request.delete({ + url: '/mp/auto-reply/delete?id=' + id + }) +} + +// 获得公众号的自动回复 +export const getAutoReply = (id) => { + return request.get({ + url: '/mp/auto-reply/get?id=' + id + }) +} + +// 获得公众号的自动回复分页 +export const getAutoReplyPage = (query) => { + return request.get({ + url: '/mp/auto-reply/page', + params: query + }) +} diff --git a/src/api/mp/draft/index.ts b/src/api/mp/draft/index.ts new file mode 100644 index 0000000..ce6a443 --- /dev/null +++ b/src/api/mp/draft/index.ts @@ -0,0 +1,35 @@ +import request from '@/config/axios' + +// 获得公众号草稿分页 +export const getDraftPage = (query) => { + return request.get({ + url: '/mp/draft/page', + params: query + }) +} + +// 创建公众号草稿 +export const createDraft = (accountId, articles) => { + return request.post({ + url: '/mp/draft/create?accountId=' + accountId, + data: { + articles + } + }) +} + +// 更新公众号草稿 +export const updateDraft = (accountId, mediaId, articles) => { + return request.put({ + url: '/mp/draft/update?accountId=' + accountId + '&mediaId=' + mediaId, + method: 'put', + data: articles + }) +} + +// 删除公众号草稿 +export const deleteDraft = (accountId, mediaId) => { + return request.delete({ + url: '/mp/draft/delete?accountId=' + accountId + '&mediaId=' + mediaId + }) +} diff --git a/src/api/mp/freePublish/index.ts b/src/api/mp/freePublish/index.ts new file mode 100644 index 0000000..beef026 --- /dev/null +++ b/src/api/mp/freePublish/index.ts @@ -0,0 +1,23 @@ +import request from '@/config/axios' + +// 获得公众号素材分页 +export const getFreePublishPage = (query) => { + return request.get({ + url: '/mp/free-publish/page', + params: query + }) +} + +// 删除公众号素材 +export const deleteFreePublish = (accountId, articleId) => { + return request.delete({ + url: '/mp/free-publish/delete?accountId=' + accountId + '&articleId=' + articleId + }) +} + +// 发布公众号素材 +export const submitFreePublish = (accountId, mediaId) => { + return request.post({ + url: '/mp/free-publish/submit?accountId=' + accountId + '&mediaId=' + mediaId + }) +} diff --git a/src/api/mp/material/index.ts b/src/api/mp/material/index.ts new file mode 100644 index 0000000..fcc37ab --- /dev/null +++ b/src/api/mp/material/index.ts @@ -0,0 +1,16 @@ +import request from '@/config/axios' + +// 获得公众号素材分页 +export const getMaterialPage = (query) => { + return request.get({ + url: '/mp/material/page', + params: query + }) +} + +// 删除公众号永久素材 +export const deletePermanentMaterial = (id) => { + return request.delete({ + url: '/mp/material/delete-permanent?id=' + id + }) +} diff --git a/src/api/mp/menu/index.ts b/src/api/mp/menu/index.ts new file mode 100644 index 0000000..cc78647 --- /dev/null +++ b/src/api/mp/menu/index.ts @@ -0,0 +1,26 @@ +import request from '@/config/axios' + +// 获得公众号菜单列表 +export const getMenuList = (accountId) => { + return request.get({ + url: '/mp/menu/list?accountId=' + accountId + }) +} + +// 保存公众号菜单 +export const saveMenu = (accountId, menus) => { + return request.post({ + url: '/mp/menu/save', + data: { + accountId, + menus + } + }) +} + +// 删除公众号菜单 +export const deleteMenu = (accountId) => { + return request.delete({ + url: '/mp/menu/delete?accountId=' + accountId + }) +} diff --git a/src/api/mp/message/index.ts b/src/api/mp/message/index.ts new file mode 100644 index 0000000..ad9b95d --- /dev/null +++ b/src/api/mp/message/index.ts @@ -0,0 +1,17 @@ +import request from '@/config/axios' + +// 获得公众号消息分页 +export const getMessagePage = (query: PageParam) => { + return request.get({ + url: '/mp/message/page', + params: query + }) +} + +// 给粉丝发送消息 +export const sendMessage = (data) => { + return request.post({ + url: '/mp/message/send', + data: data + }) +} diff --git a/src/api/mp/statistics/index.ts b/src/api/mp/statistics/index.ts new file mode 100644 index 0000000..72cae60 --- /dev/null +++ b/src/api/mp/statistics/index.ts @@ -0,0 +1,33 @@ +import request from '@/config/axios' + +// 获取消息发送概况数据 +export const getUpstreamMessage = (query) => { + return request.get({ + url: '/mp/statistics/upstream-message', + params: query + }) +} + +// 用户增减数据 +export const getUserSummary = (query) => { + return request.get({ + url: '/mp/statistics/user-summary', + params: query + }) +} + +// 获得用户累计数据 +export const getUserCumulate = (query) => { + return request.get({ + url: '/mp/statistics/user-cumulate', + params: query + }) +} + +// 获得接口分析数据 +export const getInterfaceSummary = (query) => { + return request.get({ + url: '/mp/statistics/interface-summary', + params: query + }) +} diff --git a/src/api/mp/tag/index.ts b/src/api/mp/tag/index.ts new file mode 100644 index 0000000..50183a5 --- /dev/null +++ b/src/api/mp/tag/index.ts @@ -0,0 +1,60 @@ +import request from '@/config/axios' + +export interface TagVO { + id?: number + name: string + accountId: number + createTime: Date +} + +// 创建公众号标签 +export const createTag = (data: TagVO) => { + return request.post({ + url: '/mp/tag/create', + data: data + }) +} + +// 更新公众号标签 +export const updateTag = (data: TagVO) => { + return request.put({ + url: '/mp/tag/update', + data: data + }) +} + +// 删除公众号标签 +export const deleteTag = (id: number) => { + return request.delete({ + url: '/mp/tag/delete?id=' + id + }) +} + +// 获得公众号标签 +export const getTag = (id: number) => { + return request.get({ + url: '/mp/tag/get?id=' + id + }) +} + +// 获得公众号标签分页 +export const getTagPage = (query: PageParam) => { + return request.get({ + url: '/mp/tag/page', + params: query + }) +} + +// 获取公众号标签精简信息列表 +export const getSimpleTagList = () => { + return request.get({ + url: '/mp/tag/list-all-simple' + }) +} + +// 同步公众号标签 +export const syncTag = (accountId: number) => { + return request.post({ + url: '/mp/tag/sync?accountId=' + accountId + }) +} diff --git a/src/api/mp/user/index.ts b/src/api/mp/user/index.ts new file mode 100644 index 0000000..b89acc7 --- /dev/null +++ b/src/api/mp/user/index.ts @@ -0,0 +1,31 @@ +import request from '@/config/axios' + +// 更新公众号粉丝 +export const updateUser = (data) => { + return request.put({ + url: '/mp/user/update', + data: data + }) +} + +// 获得公众号粉丝 +export const getUser = (id) => { + return request.get({ + url: '/mp/user/get?id=' + id + }) +} + +// 获得公众号粉丝分页 +export const getUserPage = (query) => { + return request.get({ + url: '/mp/user/page', + params: query + }) +} + +// 同步公众号粉丝 +export const syncUser = (accountId) => { + return request.post({ + url: '/mp/user/sync?accountId=' + accountId + }) +} diff --git a/src/api/pay/app/index.ts b/src/api/pay/app/index.ts new file mode 100644 index 0000000..4bb06b3 --- /dev/null +++ b/src/api/pay/app/index.ts @@ -0,0 +1,65 @@ +import request from '@/config/axios' + +export interface AppVO { + id: number + name: string + status: number + remark: string + payNotifyUrl: string + refundNotifyUrl: string + merchantId: number + merchantName: string + createTime: Date +} + +export interface AppPageReqVO extends PageParam { + name?: string + status?: number + remark?: string + payNotifyUrl?: string + refundNotifyUrl?: string + merchantName?: string + createTime?: Date[] +} + +export interface AppUpdateStatusReqVO { + id: number + status: number +} + +// 查询列表支付应用 +export const getAppPage = (params: AppPageReqVO) => { + return request.get({ url: '/pay/app/page', params }) +} + +// 查询详情支付应用 +export const getApp = (id: number) => { + return request.get({ url: '/pay/app/get?id=' + id }) +} + +// 新增支付应用 +export const createApp = (data: AppVO) => { + return request.post({ url: '/pay/app/create', data }) +} + +// 修改支付应用 +export const updateApp = (data: AppVO) => { + return request.put({ url: '/pay/app/update', data }) +} + +// 支付应用信息状态修改 +export const changeAppStatus = (data: AppUpdateStatusReqVO) => { + return request.put({ url: '/pay/app/update-status', data: data }) +} + +// 删除支付应用 +export const deleteApp = (id: number) => { + return request.delete({ url: '/pay/app/delete?id=' + id }) +} + +// 获得支付应用列表 +export const getAppList = () => { + return request.get({ + url: '/pay/app/list' + }) +} diff --git a/src/api/pay/channel/index.ts b/src/api/pay/channel/index.ts new file mode 100644 index 0000000..0f4ff42 --- /dev/null +++ b/src/api/pay/channel/index.ts @@ -0,0 +1,46 @@ +import request from '@/config/axios' + +export interface ChannelVO { + id: number + code: string + config: string + status: number + remark: string + feeRate: number + appId: number + createTime: Date +} + +// 查询列表支付渠道 +export const getChannelPage = (params: PageParam) => { + return request.get({ url: '/pay/channel/page', params }) +} + +// 查询详情支付渠道 +export const getChannel = (appId: string, code: string) => { + const params = { + appId: appId, + code: code + } + return request.get({ url: '/pay/channel/get', params: params }) +} + +// 新增支付渠道 +export const createChannel = (data: ChannelVO) => { + return request.post({ url: '/pay/channel/create', data }) +} + +// 修改支付渠道 +export const updateChannel = (data: ChannelVO) => { + return request.put({ url: '/pay/channel/update', data }) +} + +// 删除支付渠道 +export const deleteChannel = (id: number) => { + return request.delete({ url: '/pay/channel/delete?id=' + id }) +} + +// 导出支付渠道 +export const exportChannel = (params) => { + return request.download({ url: '/pay/channel/export-excel', params }) +} diff --git a/src/api/pay/demo/index.ts b/src/api/pay/demo/index.ts new file mode 100644 index 0000000..3824a8b --- /dev/null +++ b/src/api/pay/demo/index.ts @@ -0,0 +1,36 @@ +import request from '@/config/axios' + +export interface DemoOrderVO { + spuId: number + createTime: Date +} + +// 创建示例订单 +export function createDemoOrder(data: DemoOrderVO) { + return request.post({ + url: '/pay/demo-order/create', + data: data + }) +} + +// 获得示例订单 +export function getDemoOrder(id: number) { + return request.get({ + url: '/pay/demo-order/get?id=' + id + }) +} + +// 获得示例订单分页 +export function getDemoOrderPage(query: PageParam) { + return request.get({ + url: '/pay/demo-order/page', + params: query + }) +} + +// 退款示例订单 +export function refundDemoOrder(id) { + return request.put({ + url: '/pay/demo-order/refund?id=' + id + }) +} diff --git a/src/api/pay/demo/transfer/index.ts b/src/api/pay/demo/transfer/index.ts new file mode 100644 index 0000000..a95b0d5 --- /dev/null +++ b/src/api/pay/demo/transfer/index.ts @@ -0,0 +1,25 @@ +import request from '@/config/axios' + +export interface DemoTransferVO { + price: number + type: number + userName: string + alipayLogonId: string + openid: string +} + +// 创建示例转账单 +export function createDemoTransfer(data: DemoTransferVO) { + return request.post({ + url: '/pay/demo-transfer/create', + data: data + }) +} + +// 获得示例订单分页 +export function getDemoTransferPage(query: PageParam) { + return request.get({ + url: '/pay/demo-transfer/page', + params: query + }) +} diff --git a/src/api/pay/notify/index.ts b/src/api/pay/notify/index.ts new file mode 100644 index 0000000..dc8bd88 --- /dev/null +++ b/src/api/pay/notify/index.ts @@ -0,0 +1,16 @@ +import request from '@/config/axios' + +// 获得支付通知明细 +export const getNotifyTaskDetail = (id) => { + return request.get({ + url: '/pay/notify/get-detail?id=' + id + }) +} + +// 获得支付通知分页 +export const getNotifyTaskPage = (query) => { + return request.get({ + url: '/pay/notify/page', + params: query + }) +} diff --git a/src/api/pay/order/index.ts b/src/api/pay/order/index.ts new file mode 100644 index 0000000..71960a8 --- /dev/null +++ b/src/api/pay/order/index.ts @@ -0,0 +1,104 @@ +import request from '@/config/axios' + +export interface OrderVO { + id: number + merchantId: number + appId: number + channelId: number + channelCode: string + merchantOrderId: string + subject: string + body: string + notifyUrl: string + notifyStatus: number + amount: number + channelFeeRate: number + channelFeeAmount: number + status: number + userIp: string + expireTime: Date + successTime: Date + notifyTime: Date + successExtensionId: number + refundStatus: number + refundTimes: number + refundAmount: number + channelUserId: string + channelOrderNo: string + createTime: Date +} + +export interface OrderPageReqVO extends PageParam { + merchantId?: number + appId?: number + channelId?: number + channelCode?: string + merchantOrderId?: string + subject?: string + body?: string + notifyUrl?: string + notifyStatus?: number + amount?: number + channelFeeRate?: number + channelFeeAmount?: number + status?: number + expireTime?: Date[] + successTime?: Date[] + notifyTime?: Date[] + successExtensionId?: number + refundStatus?: number + refundTimes?: number + channelUserId?: string + channelOrderNo?: string + createTime?: Date[] +} + +export interface OrderExportReqVO { + merchantId?: number + appId?: number + channelId?: number + channelCode?: string + merchantOrderId?: string + subject?: string + body?: string + notifyUrl?: string + notifyStatus?: number + amount?: number + channelFeeRate?: number + channelFeeAmount?: number + status?: number + expireTime?: Date[] + successTime?: Date[] + notifyTime?: Date[] + successExtensionId?: number + refundStatus?: number + refundTimes?: number + channelUserId?: string + channelOrderNo?: string + createTime?: Date[] +} + +// 查询列表支付订单 +export const getOrderPage = async (params: OrderPageReqVO) => { + return await request.get({ url: '/pay/order/page', params }) +} + +// 查询详情支付订单 +export const getOrder = async (id: number) => { + return await request.get({ url: '/pay/order/get?id=' + id }) +} + +// 获得支付订单的明细 +export const getOrderDetail = async (id: number) => { + return await request.get({ url: '/pay/order/get-detail?id=' + id }) +} + +// 提交支付订单 +export const submitOrder = async (data: any) => { + return await request.post({ url: '/pay/order/submit', data }) +} + +// 导出支付订单 +export const exportOrder = async (params: OrderExportReqVO) => { + return await request.download({ url: '/pay/order/export-excel', params }) +} diff --git a/src/api/pay/refund/index.ts b/src/api/pay/refund/index.ts new file mode 100644 index 0000000..4b587f2 --- /dev/null +++ b/src/api/pay/refund/index.ts @@ -0,0 +1,116 @@ +import request from '@/config/axios' + +export interface RefundVO { + id: number + merchantId: number + appId: number + channelId: number + channelCode: string + orderId: string + tradeNo: string + merchantOrderId: string + merchantRefundNo: string + notifyUrl: string + notifyStatus: number + status: number + type: number + payAmount: number + refundAmount: number + reason: string + userIp: string + channelOrderNo: string + channelRefundNo: string + channelErrorCode: string + channelErrorMsg: string + channelExtras: string + expireTime: Date + successTime: Date + notifyTime: Date + createTime: Date +} + +export interface RefundPageReqVO extends PageParam { + merchantId?: number + appId?: number + channelId?: number + channelCode?: string + orderId?: string + tradeNo?: string + merchantOrderId?: string + merchantRefundNo?: string + notifyUrl?: string + notifyStatus?: number + status?: number + type?: number + payAmount?: number + refundAmount?: number + reason?: string + userIp?: string + channelOrderNo?: string + channelRefundNo?: string + channelErrorCode?: string + channelErrorMsg?: string + channelExtras?: string + expireTime?: Date[] + successTime?: Date[] + notifyTime?: Date[] + createTime?: Date[] +} + +export interface PayRefundExportReqVO { + merchantId?: number + appId?: number + channelId?: number + channelCode?: string + orderId?: string + tradeNo?: string + merchantOrderId?: string + merchantRefundNo?: string + notifyUrl?: string + notifyStatus?: number + status?: number + type?: number + payAmount?: number + refundAmount?: number + reason?: string + userIp?: string + channelOrderNo?: string + channelRefundNo?: string + channelErrorCode?: string + channelErrorMsg?: string + channelExtras?: string + expireTime?: Date[] + successTime?: Date[] + notifyTime?: Date[] + createTime?: Date[] +} + +// 查询列表退款订单 +export const getRefundPage = (params: RefundPageReqVO) => { + return request.get({ url: '/pay/refund/page', params }) +} + +// 查询详情退款订单 +export const getRefund = (id: number) => { + return request.get({ url: '/pay/refund/get?id=' + id }) +} + +// 新增退款订单 +export const createRefund = (data: RefundVO) => { + return request.post({ url: '/pay/refund/create', data }) +} + +// 修改退款订单 +export const updateRefund = (data: RefundVO) => { + return request.put({ url: '/pay/refund/update', data }) +} + +// 删除退款订单 +export const deleteRefund = (id: number) => { + return request.delete({ url: '/pay/refund/delete?id=' + id }) +} + +// 导出退款订单 +export const exportRefund = (params: PayRefundExportReqVO) => { + return request.download({ url: '/pay/refund/export-excel', params }) +} diff --git a/src/api/pay/transfer/index.ts b/src/api/pay/transfer/index.ts new file mode 100644 index 0000000..7a58abf --- /dev/null +++ b/src/api/pay/transfer/index.ts @@ -0,0 +1,27 @@ +import request from '@/config/axios' + +export interface TransferVO { + appId: number + channelCode: string + merchantTransferId: string + type: number + price: number + subject: string + userName: string + alipayLogonId: string + openid: string +} + +// 新增转账单 +export const createTransfer = async (data: TransferVO) => { + return await request.post({ url: `/pay/transfer/create`, data }) +} + +// 查询转账单列表 +export const getTransferPage = async (params) => { + return await request.get({ url: `/pay/transfer/page`, params }) +} + +export const getTransfer = async (id: number) => { + return await request.get({ url: '/pay/transfer/get?id=' + id }) +} diff --git a/src/api/pay/wallet/balance/index.ts b/src/api/pay/wallet/balance/index.ts new file mode 100644 index 0000000..3e5ab36 --- /dev/null +++ b/src/api/pay/wallet/balance/index.ts @@ -0,0 +1,26 @@ +import request from '@/config/axios' + +/** 用户钱包查询参数 */ +export interface PayWalletUserReqVO { + userId: number +} +/** 钱包 VO */ +export interface WalletVO { + id: number + userId: number + userType: number + balance: number + totalExpense: number + totalRecharge: number + freezePrice: number +} + +/** 查询用户钱包详情 */ +export const getWallet = async (params: PayWalletUserReqVO) => { + return await request.get<WalletVO>({ url: `/pay/wallet/get`, params }) +} + +// 查询会员钱包列表 +export const getWalletPage = async (params) => { + return await request.get({ url: `/pay/wallet/page`, params }) +} diff --git a/src/api/pay/wallet/rechargePackage/index.ts b/src/api/pay/wallet/rechargePackage/index.ts new file mode 100644 index 0000000..c8e4cc9 --- /dev/null +++ b/src/api/pay/wallet/rechargePackage/index.ts @@ -0,0 +1,34 @@ +import request from '@/config/axios' + +export interface WalletRechargePackageVO { + id: number + name: string + payPrice: number + bonusPrice: number + status: number +} + +// 查询套餐充值列表 +export const getWalletRechargePackagePage = async (params) => { + return await request.get({ url: '/pay/wallet-recharge-package/page', params }) +} + +// 查询套餐充值详情 +export const getWalletRechargePackage = async (id: number) => { + return await request.get({ url: '/pay/wallet-recharge-package/get?id=' + id }) +} + +// 新增套餐充值 +export const createWalletRechargePackage = async (data: WalletRechargePackageVO) => { + return await request.post({ url: '/pay/wallet-recharge-package/create', data }) +} + +// 修改套餐充值 +export const updateWalletRechargePackage = async (data: WalletRechargePackageVO) => { + return await request.put({ url: '/pay/wallet-recharge-package/update', data }) +} + +// 删除套餐充值 +export const deleteWalletRechargePackage = async (id: number) => { + return await request.delete({ url: '/pay/wallet-recharge-package/delete?id=' + id }) +} diff --git a/src/api/pay/wallet/transaction/index.ts b/src/api/pay/wallet/transaction/index.ts new file mode 100644 index 0000000..3377ffa --- /dev/null +++ b/src/api/pay/wallet/transaction/index.ts @@ -0,0 +1,14 @@ +import request from '@/config/axios' + +export interface WalletTransactionVO { + id: number + walletId: number + title: string + price: number + balance: number +} + +// 查询会员钱包流水列表 +export const getWalletTransactionPage = async (params) => { + return await request.get({ url: `/pay/wallet-transaction/page`, params }) +} diff --git a/src/api/system/area/index.ts b/src/api/system/area/index.ts new file mode 100644 index 0000000..e91a499 --- /dev/null +++ b/src/api/system/area/index.ts @@ -0,0 +1,11 @@ +import request from '@/config/axios' + +// 获得地区树 +export const getAreaTree = async () => { + return await request.get({ url: '/system/area/tree' }) +} + +// 获得 IP 对应的地区名 +export const getAreaByIp = async (ip: string) => { + return await request.get({ url: '/system/area/get-by-ip?ip=' + ip }) +} diff --git a/src/api/system/dept/index.ts b/src/api/system/dept/index.ts new file mode 100644 index 0000000..04d5c88 --- /dev/null +++ b/src/api/system/dept/index.ts @@ -0,0 +1,43 @@ +import request from '@/config/axios' + +export interface DeptVO { + id?: number + name: string + parentId: number + status: number + sort: number + leaderUserId: number + phone: string + email: string + createTime: Date +} + +// 查询部门(精简)列表 +export const getSimpleDeptList = async (): Promise<DeptVO[]> => { + return await request.get({ url: '/system/dept/simple-list' }) +} + +// 查询部门列表 +export const getDeptPage = async (params: PageParam) => { + return await request.get({ url: '/system/dept/list', params }) +} + +// 查询部门详情 +export const getDept = async (id: number) => { + return await request.get({ url: '/system/dept/get?id=' + id }) +} + +// 新增部门 +export const createDept = async (data: DeptVO) => { + return await request.post({ url: '/system/dept/create', data: data }) +} + +// 修改部门 +export const updateDept = async (params: DeptVO) => { + return await request.put({ url: '/system/dept/update', data: params }) +} + +// 删除部门 +export const deleteDept = async (id: number) => { + return await request.delete({ url: '/system/dept/delete?id=' + id }) +} diff --git a/src/api/system/dict/dict.data.ts b/src/api/system/dict/dict.data.ts new file mode 100644 index 0000000..f428648 --- /dev/null +++ b/src/api/system/dict/dict.data.ts @@ -0,0 +1,49 @@ +import request from '@/config/axios' + +export type DictDataVO = { + id: number | undefined + sort: number | undefined + label: string + value: string + dictType: string + status: number + colorType: string + cssClass: string + remark: string + createTime: Date +} + +// 查询字典数据(精简)列表 +export const getSimpleDictDataList = () => { + return request.get({ url: '/system/dict-data/simple-list' }) +} + +// 查询字典数据列表 +export const getDictDataPage = (params: PageParam) => { + return request.get({ url: '/system/dict-data/page', params }) +} + +// 查询字典数据详情 +export const getDictData = (id: number) => { + return request.get({ url: '/system/dict-data/get?id=' + id }) +} + +// 新增字典数据 +export const createDictData = (data: DictDataVO) => { + return request.post({ url: '/system/dict-data/create', data }) +} + +// 修改字典数据 +export const updateDictData = (data: DictDataVO) => { + return request.put({ url: '/system/dict-data/update', data }) +} + +// 删除字典数据 +export const deleteDictData = (id: number) => { + return request.delete({ url: '/system/dict-data/delete?id=' + id }) +} + +// 导出字典类型数据 +export const exportDictData = (params) => { + return request.download({ url: '/system/dict-data/export', params }) +} diff --git a/src/api/system/dict/dict.type.ts b/src/api/system/dict/dict.type.ts new file mode 100644 index 0000000..eaa5fb6 --- /dev/null +++ b/src/api/system/dict/dict.type.ts @@ -0,0 +1,44 @@ +import request from '@/config/axios' + +export type DictTypeVO = { + id: number | undefined + name: string + type: string + status: number + remark: string + createTime: Date +} + +// 查询字典(精简)列表 +export const getSimpleDictTypeList = () => { + return request.get({ url: '/system/dict-type/list-all-simple' }) +} + +// 查询字典列表 +export const getDictTypePage = (params: PageParam) => { + return request.get({ url: '/system/dict-type/page', params }) +} + +// 查询字典详情 +export const getDictType = (id: number) => { + return request.get({ url: '/system/dict-type/get?id=' + id }) +} + +// 新增字典 +export const createDictType = (data: DictTypeVO) => { + return request.post({ url: '/system/dict-type/create', data }) +} + +// 修改字典 +export const updateDictType = (data: DictTypeVO) => { + return request.put({ url: '/system/dict-type/update', data }) +} + +// 删除字典 +export const deleteDictType = (id: number) => { + return request.delete({ url: '/system/dict-type/delete?id=' + id }) +} +// 导出字典类型 +export const exportDictType = (params) => { + return request.download({ url: '/system/dict-type/export', params }) +} diff --git a/src/api/system/loginLog/index.ts b/src/api/system/loginLog/index.ts new file mode 100644 index 0000000..7296f25 --- /dev/null +++ b/src/api/system/loginLog/index.ts @@ -0,0 +1,25 @@ +import request from '@/config/axios' + +export interface LoginLogVO { + id: number + logType: number + traceId: number + userId: number + userType: number + username: string + result: number + status: number + userIp: string + userAgent: string + createTime: Date +} + +// 查询登录日志列表 +export const getLoginLogPage = (params: PageParam) => { + return request.get({ url: '/system/login-log/page', params }) +} + +// 导出登录日志 +export const exportLoginLog = (params) => { + return request.download({ url: '/system/login-log/export', params }) +} diff --git a/src/api/system/mail/account/index.ts b/src/api/system/mail/account/index.ts new file mode 100644 index 0000000..15e0391 --- /dev/null +++ b/src/api/system/mail/account/index.ts @@ -0,0 +1,42 @@ +import request from '@/config/axios' + +export interface MailAccountVO { + id: number + mail: string + username: string + password: string + host: string + port: number + sslEnable: boolean + starttlsEnable: boolean +} + +// 查询邮箱账号列表 +export const getMailAccountPage = async (params: PageParam) => { + return await request.get({ url: '/system/mail-account/page', params }) +} + +// 查询邮箱账号详情 +export const getMailAccount = async (id: number) => { + return await request.get({ url: '/system/mail-account/get?id=' + id }) +} + +// 新增邮箱账号 +export const createMailAccount = async (data: MailAccountVO) => { + return await request.post({ url: '/system/mail-account/create', data }) +} + +// 修改邮箱账号 +export const updateMailAccount = async (data: MailAccountVO) => { + return await request.put({ url: '/system/mail-account/update', data }) +} + +// 删除邮箱账号 +export const deleteMailAccount = async (id: number) => { + return await request.delete({ url: '/system/mail-account/delete?id=' + id }) +} + +// 获得邮箱账号精简列表 +export const getSimpleMailAccountList = async () => { + return request.get({ url: '/system/mail-account/simple-list' }) +} diff --git a/src/api/system/mail/log/index.ts b/src/api/system/mail/log/index.ts new file mode 100644 index 0000000..13172a7 --- /dev/null +++ b/src/api/system/mail/log/index.ts @@ -0,0 +1,30 @@ +import request from '@/config/axios' + +export interface MailLogVO { + id: number + userId: number + userType: number + toMail: string + accountId: number + fromMail: string + templateId: number + templateCode: string + templateNickname: string + templateTitle: string + templateContent: string + templateParams: string + sendStatus: number + sendTime: Date + sendMessageId: string + sendException: string +} + +// 查询邮件日志列表 +export const getMailLogPage = async (params: PageParam) => { + return await request.get({ url: '/system/mail-log/page', params }) +} + +// 查询邮件日志详情 +export const getMailLog = async (id: number) => { + return await request.get({ url: '/system/mail-log/get?id=' + id }) +} diff --git a/src/api/system/mail/template/index.ts b/src/api/system/mail/template/index.ts new file mode 100644 index 0000000..fb7ce5e --- /dev/null +++ b/src/api/system/mail/template/index.ts @@ -0,0 +1,50 @@ +import request from '@/config/axios' + +export interface MailTemplateVO { + id: number + name: string + code: string + accountId: number + nickname: string + title: string + content: string + params: string + status: number + remark: string +} + +export interface MailSendReqVO { + mail: string + templateCode: string + templateParams: Map<String, Object> +} + +// 查询邮件模版列表 +export const getMailTemplatePage = async (params: PageParam) => { + return await request.get({ url: '/system/mail-template/page', params }) +} + +// 查询邮件模版详情 +export const getMailTemplate = async (id: number) => { + return await request.get({ url: '/system/mail-template/get?id=' + id }) +} + +// 新增邮件模版 +export const createMailTemplate = async (data: MailTemplateVO) => { + return await request.post({ url: '/system/mail-template/create', data }) +} + +// 修改邮件模版 +export const updateMailTemplate = async (data: MailTemplateVO) => { + return await request.put({ url: '/system/mail-template/update', data }) +} + +// 删除邮件模版 +export const deleteMailTemplate = async (id: number) => { + return await request.delete({ url: '/system/mail-template/delete?id=' + id }) +} + +// 发送邮件 +export const sendMail = (data: MailSendReqVO) => { + return request.post({ url: '/system/mail-template/send-mail', data }) +} diff --git a/src/api/system/menu/index.ts b/src/api/system/menu/index.ts new file mode 100644 index 0000000..5a80668 --- /dev/null +++ b/src/api/system/menu/index.ts @@ -0,0 +1,49 @@ +import request from '@/config/axios' + +export interface MenuVO { + id: number + name: string + permission: string + type: number + sort: number + parentId: number + path: string + icon: string + component: string + componentName?: string + status: number + visible: boolean + keepAlive: boolean + alwaysShow?: boolean + createTime: Date +} + +// 查询菜单(精简)列表 +export const getSimpleMenusList = () => { + return request.get({ url: '/system/menu/simple-list' }) +} + +// 查询菜单列表 +export const getMenuList = (params) => { + return request.get({ url: '/system/menu/list', params }) +} + +// 获取菜单详情 +export const getMenu = (id: number) => { + return request.get({ url: '/system/menu/get?id=' + id }) +} + +// 新增菜单 +export const createMenu = (data: MenuVO) => { + return request.post({ url: '/system/menu/create', data }) +} + +// 修改菜单 +export const updateMenu = (data: MenuVO) => { + return request.put({ url: '/system/menu/update', data }) +} + +// 删除菜单 +export const deleteMenu = (id: number) => { + return request.delete({ url: '/system/menu/delete?id=' + id }) +} diff --git a/src/api/system/notice/index.ts b/src/api/system/notice/index.ts new file mode 100644 index 0000000..f643469 --- /dev/null +++ b/src/api/system/notice/index.ts @@ -0,0 +1,42 @@ +import request from '@/config/axios' + +export interface NoticeVO { + id: number | undefined + title: string + type: number + content: string + status: number + remark: string + creator: string + createTime: Date +} + +// 查询公告列表 +export const getNoticePage = (params: PageParam) => { + return request.get({ url: '/system/notice/page', params }) +} + +// 查询公告详情 +export const getNotice = (id: number) => { + return request.get({ url: '/system/notice/get?id=' + id }) +} + +// 新增公告 +export const createNotice = (data: NoticeVO) => { + return request.post({ url: '/system/notice/create', data }) +} + +// 修改公告 +export const updateNotice = (data: NoticeVO) => { + return request.put({ url: '/system/notice/update', data }) +} + +// 删除公告 +export const deleteNotice = (id: number) => { + return request.delete({ url: '/system/notice/delete?id=' + id }) +} + +// 推送公告 +export const pushNotice = (id: number) => { + return request.post({ url: '/system/notice/push?id=' + id }) +} diff --git a/src/api/system/notify/message/index.ts b/src/api/system/notify/message/index.ts new file mode 100644 index 0000000..e407c77 --- /dev/null +++ b/src/api/system/notify/message/index.ts @@ -0,0 +1,49 @@ +import request from '@/config/axios' +import qs from 'qs' + +export interface NotifyMessageVO { + id: number + userId: number + userType: number + templateId: number + templateCode: string + templateNickname: string + templateContent: string + templateType: number + templateParams: string + readStatus: boolean + readTime: Date + createTime: Date +} + +// 查询站内信消息列表 +export const getNotifyMessagePage = async (params: PageParam) => { + return await request.get({ url: '/system/notify-message/page', params }) +} + +// 获得我的站内信分页 +export const getMyNotifyMessagePage = async (params: PageParam) => { + return await request.get({ url: '/system/notify-message/my-page', params }) +} + +// 批量标记已读 +export const updateNotifyMessageRead = async (ids) => { + return await request.put({ + url: '/system/notify-message/update-read?' + qs.stringify({ ids: ids }, { indices: false }) + }) +} + +// 标记所有站内信为已读 +export const updateAllNotifyMessageRead = async () => { + return await request.put({ url: '/system/notify-message/update-all-read' }) +} + +// 获取当前用户的最新站内信列表 +export const getUnreadNotifyMessageList = async () => { + return await request.get({ url: '/system/notify-message/get-unread-list' }) +} + +// 获得当前用户的未读站内信数量 +export const getUnreadNotifyMessageCount = async () => { + return await request.get({ url: '/system/notify-message/get-unread-count' }) +} diff --git a/src/api/system/notify/template/index.ts b/src/api/system/notify/template/index.ts new file mode 100644 index 0000000..44355df --- /dev/null +++ b/src/api/system/notify/template/index.ts @@ -0,0 +1,49 @@ +import request from '@/config/axios' + +export interface NotifyTemplateVO { + id?: number + name: string + nickname: string + code: string + content: string + type?: number + params: string + status: number + remark: string +} + +export interface NotifySendReqVO { + userId: number | null + templateCode: string + templateParams: Map<String, Object> +} + +// 查询站内信模板列表 +export const getNotifyTemplatePage = async (params: PageParam) => { + return await request.get({ url: '/system/notify-template/page', params }) +} + +// 查询站内信模板详情 +export const getNotifyTemplate = async (id: number) => { + return await request.get({ url: '/system/notify-template/get?id=' + id }) +} + +// 新增站内信模板 +export const createNotifyTemplate = async (data: NotifyTemplateVO) => { + return await request.post({ url: '/system/notify-template/create', data }) +} + +// 修改站内信模板 +export const updateNotifyTemplate = async (data: NotifyTemplateVO) => { + return await request.put({ url: '/system/notify-template/update', data }) +} + +// 删除站内信模板 +export const deleteNotifyTemplate = async (id: number) => { + return await request.delete({ url: '/system/notify-template/delete?id=' + id }) +} + +// 发送站内信 +export const sendNotify = (data: NotifySendReqVO) => { + return request.post({ url: '/system/notify-template/send-notify', data }) +} diff --git a/src/api/system/oauth2/client.ts b/src/api/system/oauth2/client.ts new file mode 100644 index 0000000..6f71aca --- /dev/null +++ b/src/api/system/oauth2/client.ts @@ -0,0 +1,47 @@ +import request from '@/config/axios' + +export interface OAuth2ClientVO { + id: number + clientId: string + secret: string + name: string + logo: string + description: string + status: number + accessTokenValiditySeconds: number + refreshTokenValiditySeconds: number + redirectUris: string[] + autoApprove: boolean + authorizedGrantTypes: string[] + scopes: string[] + authorities: string[] + resourceIds: string[] + additionalInformation: string + isAdditionalInformationJson: boolean + createTime: Date +} + +// 查询 OAuth2 客户端的列表 +export const getOAuth2ClientPage = (params: PageParam) => { + return request.get({ url: '/system/oauth2-client/page', params }) +} + +// 查询 OAuth2 客户端的详情 +export const getOAuth2Client = (id: number) => { + return request.get({ url: '/system/oauth2-client/get?id=' + id }) +} + +// 新增 OAuth2 客户端 +export const createOAuth2Client = (data: OAuth2ClientVO) => { + return request.post({ url: '/system/oauth2-client/create', data }) +} + +// 修改 OAuth2 客户端 +export const updateOAuth2Client = (data: OAuth2ClientVO) => { + return request.put({ url: '/system/oauth2-client/update', data }) +} + +// 删除 OAuth2 +export const deleteOAuth2Client = (id: number) => { + return request.delete({ url: '/system/oauth2-client/delete?id=' + id }) +} diff --git a/src/api/system/oauth2/token.ts b/src/api/system/oauth2/token.ts new file mode 100644 index 0000000..ac89ae8 --- /dev/null +++ b/src/api/system/oauth2/token.ts @@ -0,0 +1,22 @@ +import request from '@/config/axios' + +export interface OAuth2TokenVO { + id: number + accessToken: string + refreshToken: string + userId: number + userType: number + clientId: string + createTime: Date + expiresTime: Date +} + +// 查询 token列表 +export const getAccessTokenPage = (params: PageParam) => { + return request.get({ url: '/system/oauth2-token/page', params }) +} + +// 删除 token +export const deleteAccessToken = (accessToken: string) => { + return request.delete({ url: '/system/oauth2-token/delete?accessToken=' + accessToken }) +} diff --git a/src/api/system/operatelog/index.ts b/src/api/system/operatelog/index.ts new file mode 100644 index 0000000..cdb713e --- /dev/null +++ b/src/api/system/operatelog/index.ts @@ -0,0 +1,30 @@ +import request from '@/config/axios' + +export type OperateLogVO = { + id: number + traceId: string + userType: number + userId: number + userName: string + type: string + subType: string + bizId: number + action: string + extra: string + requestMethod: string + requestUrl: string + userIp: string + userAgent: string + creator: string + creatorName: string + createTime: Date +} + +// 查询操作日志列表 +export const getOperateLogPage = (params: PageParam) => { + return request.get({ url: '/system/operate-log/page', params }) +} +// 导出操作日志 +export const exportOperateLog = (params: any) => { + return request.download({ url: '/system/operate-log/export', params }) +} diff --git a/src/api/system/permission/index.ts b/src/api/system/permission/index.ts new file mode 100644 index 0000000..b3c7696 --- /dev/null +++ b/src/api/system/permission/index.ts @@ -0,0 +1,42 @@ +import request from '@/config/axios' + +export interface PermissionAssignUserRoleReqVO { + userId: number + roleIds: number[] +} + +export interface PermissionAssignRoleMenuReqVO { + roleId: number + menuIds: number[] +} + +export interface PermissionAssignRoleDataScopeReqVO { + roleId: number + dataScope: number + dataScopeDeptIds: number[] +} + +// 查询角色拥有的菜单权限 +export const getRoleMenuList = async (roleId: number) => { + return await request.get({ url: '/system/permission/list-role-menus?roleId=' + roleId }) +} + +// 赋予角色菜单权限 +export const assignRoleMenu = async (data: PermissionAssignRoleMenuReqVO) => { + return await request.post({ url: '/system/permission/assign-role-menu', data }) +} + +// 赋予角色数据权限 +export const assignRoleDataScope = async (data: PermissionAssignRoleDataScopeReqVO) => { + return await request.post({ url: '/system/permission/assign-role-data-scope', data }) +} + +// 查询用户拥有的角色数组 +export const getUserRoleList = async (userId: number) => { + return await request.get({ url: '/system/permission/list-user-roles?userId=' + userId }) +} + +// 赋予用户角色 +export const assignUserRole = async (data: PermissionAssignUserRoleReqVO) => { + return await request.post({ url: '/system/permission/assign-user-role', data }) +} diff --git a/src/api/system/post/index.ts b/src/api/system/post/index.ts new file mode 100644 index 0000000..0e6f2ca --- /dev/null +++ b/src/api/system/post/index.ts @@ -0,0 +1,46 @@ +import request from '@/config/axios' + +export interface PostVO { + id?: number + name: string + code: string + sort: number + status: number + remark: string + createTime?: Date +} + +// 查询岗位列表 +export const getPostPage = async (params: PageParam) => { + return await request.get({ url: '/system/post/page', params }) +} + +// 获取岗位精简信息列表 +export const getSimplePostList = async (): Promise<PostVO[]> => { + return await request.get({ url: '/system/post/simple-list' }) +} + +// 查询岗位详情 +export const getPost = async (id: number) => { + return await request.get({ url: '/system/post/get?id=' + id }) +} + +// 新增岗位 +export const createPost = async (data: PostVO) => { + return await request.post({ url: '/system/post/create', data }) +} + +// 修改岗位 +export const updatePost = async (data: PostVO) => { + return await request.put({ url: '/system/post/update', data }) +} + +// 删除岗位 +export const deletePost = async (id: number) => { + return await request.delete({ url: '/system/post/delete?id=' + id }) +} + +// 导出岗位 +export const exportPost = async (params) => { + return await request.download({ url: '/system/post/export', params }) +} diff --git a/src/api/system/role/index.ts b/src/api/system/role/index.ts new file mode 100644 index 0000000..3325dde --- /dev/null +++ b/src/api/system/role/index.ts @@ -0,0 +1,61 @@ +import request from '@/config/axios' + +export interface RoleVO { + id: number + name: string + code: string + sort: number + status: number + type: number + dataScope: number + dataScopeDeptIds: number[] + createTime: Date +} + +export interface UpdateStatusReqVO { + id: number + status: number +} + +// 查询角色列表 +export const getRolePage = async (params: PageParam) => { + return await request.get({ url: '/system/role/page', params }) +} + +// 查询角色(精简)列表 +export const getSimpleRoleList = async (): Promise<RoleVO[]> => { + return await request.get({ url: '/system/role/simple-list' }) +} + +// 查询角色详情 +export const getRole = async (id: number) => { + return await request.get({ url: '/system/role/get?id=' + id }) +} + +// 新增角色 +export const createRole = async (data: RoleVO) => { + return await request.post({ url: '/system/role/create', data }) +} + +// 修改角色 +export const updateRole = async (data: RoleVO) => { + return await request.put({ url: '/system/role/update', data }) +} + +// 修改角色状态 +export const updateRoleStatus = async (data: UpdateStatusReqVO) => { + return await request.put({ url: '/system/role/update-status', data }) +} + +// 删除角色 +export const deleteRole = async (id: number) => { + return await request.delete({ url: '/system/role/delete?id=' + id }) +} + +// 导出角色 +export const exportRole = (params) => { + return request.download({ + url: '/system/role/export-excel', + params + }) +} diff --git a/src/api/system/sms/smsChannel/index.ts b/src/api/system/sms/smsChannel/index.ts new file mode 100644 index 0000000..bcdaa7f --- /dev/null +++ b/src/api/system/sms/smsChannel/index.ts @@ -0,0 +1,43 @@ +import request from '@/config/axios' + +export interface SmsChannelVO { + id: number + code: string + status: number + signature: string + remark: string + apiKey: string + apiSecret: string + callbackUrl: string + createTime: Date +} + +// 查询短信渠道列表 +export const getSmsChannelPage = (params: PageParam) => { + return request.get({ url: '/system/sms-channel/page', params }) +} + +// 获得短信渠道精简列表 +export function getSimpleSmsChannelList() { + return request.get({ url: '/system/sms-channel/simple-list' }) +} + +// 查询短信渠道详情 +export const getSmsChannel = (id: number) => { + return request.get({ url: '/system/sms-channel/get?id=' + id }) +} + +// 新增短信渠道 +export const createSmsChannel = (data: SmsChannelVO) => { + return request.post({ url: '/system/sms-channel/create', data }) +} + +// 修改短信渠道 +export const updateSmsChannel = (data: SmsChannelVO) => { + return request.put({ url: '/system/sms-channel/update', data }) +} + +// 删除短信渠道 +export const deleteSmsChannel = (id: number) => { + return request.delete({ url: '/system/sms-channel/delete?id=' + id }) +} diff --git a/src/api/system/sms/smsLog/index.ts b/src/api/system/sms/smsLog/index.ts new file mode 100644 index 0000000..f989171 --- /dev/null +++ b/src/api/system/sms/smsLog/index.ts @@ -0,0 +1,37 @@ +import request from '@/config/axios' + +export interface SmsLogVO { + id: number | null + channelId: number | null + channelCode: string + templateId: number | null + templateCode: string + templateType: number | null + templateContent: string + templateParams: Map<string, object> | null + apiTemplateId: string + mobile: string + userId: number | null + userType: number | null + sendStatus: number | null + sendTime: Date | null + apiSendCode: string + apiSendMsg: string + apiRequestId: string + apiSerialNo: string + receiveStatus: number | null + receiveTime: Date | null + apiReceiveCode: string + apiReceiveMsg: string + createTime: Date | null +} + +// 查询短信日志列表 +export const getSmsLogPage = (params: PageParam) => { + return request.get({ url: '/system/sms-log/page', params }) +} + +// 导出短信日志 +export const exportSmsLog = (params) => { + return request.download({ url: '/system/sms-log/export-excel', params }) +} diff --git a/src/api/system/sms/smsTemplate/index.ts b/src/api/system/sms/smsTemplate/index.ts new file mode 100644 index 0000000..868ddd4 --- /dev/null +++ b/src/api/system/sms/smsTemplate/index.ts @@ -0,0 +1,60 @@ +import request from '@/config/axios' + +export interface SmsTemplateVO { + id?: number + type?: number + status: number + code: string + name: string + content: string + remark: string + apiTemplateId: string + channelId?: number + channelCode?: string + params?: string[] + createTime?: Date +} + +export interface SendSmsReqVO { + mobile: string + templateCode: string + templateParams: Map<String, Object> +} + +// 查询短信模板列表 +export const getSmsTemplatePage = (params: PageParam) => { + return request.get({ url: '/system/sms-template/page', params }) +} + +// 查询短信模板详情 +export const getSmsTemplate = (id: number) => { + return request.get({ url: '/system/sms-template/get?id=' + id }) +} + +// 新增短信模板 +export const createSmsTemplate = (data: SmsTemplateVO) => { + return request.post({ url: '/system/sms-template/create', data }) +} + +// 修改短信模板 +export const updateSmsTemplate = (data: SmsTemplateVO) => { + return request.put({ url: '/system/sms-template/update', data }) +} + +// 删除短信模板 +export const deleteSmsTemplate = (id: number) => { + return request.delete({ url: '/system/sms-template/delete?id=' + id }) +} + +// 导出短信模板 +export const exportSmsTemplate = (params) => { + return request.download({ + url: '/system/sms-template/export-excel', + params + }) +} + +// 发送短信 +export const sendSms = (data: SendSmsReqVO) => { + return request.post({ url: '/system/sms-template/send-sms', data }) +} diff --git a/src/api/system/social/client/index.ts b/src/api/system/social/client/index.ts new file mode 100644 index 0000000..bf13ab4 --- /dev/null +++ b/src/api/system/social/client/index.ts @@ -0,0 +1,37 @@ +import request from '@/config/axios' + +export interface SocialClientVO { + id: number + name: string + socialType: number + userType: number + clientId: string + clientSecret: string + agentId: string + status: number +} + +// 查询社交客户端列表 +export const getSocialClientPage = async (params) => { + return await request.get({ url: `/system/social-client/page`, params }) +} + +// 查询社交客户端详情 +export const getSocialClient = async (id: number) => { + return await request.get({ url: `/system/social-client/get?id=` + id }) +} + +// 新增社交客户端 +export const createSocialClient = async (data: SocialClientVO) => { + return await request.post({ url: `/system/social-client/create`, data }) +} + +// 修改社交客户端 +export const updateSocialClient = async (data: SocialClientVO) => { + return await request.put({ url: `/system/social-client/update`, data }) +} + +// 删除社交客户端 +export const deleteSocialClient = async (id: number) => { + return await request.delete({ url: `/system/social-client/delete?id=` + id }) +} diff --git a/src/api/system/social/user/index.ts b/src/api/system/social/user/index.ts new file mode 100644 index 0000000..f11231b --- /dev/null +++ b/src/api/system/social/user/index.ts @@ -0,0 +1,24 @@ +import request from '@/config/axios' + +export interface SocialUserVO { + id: number + type: number + openid: string + token: string + rawTokenInfo: string + nickname: string + avatar: string + rawUserInfo: string + code: string + state: string +} + +// 查询社交用户列表 +export const getSocialUserPage = async (params) => { + return await request.get({ url: `/system/social-user/page`, params }) +} + +// 查询社交用户详情 +export const getSocialUser = async (id: number) => { + return await request.get({ url: `/system/social-user/get?id=` + id }) +} diff --git a/src/api/system/tenant/index.ts b/src/api/system/tenant/index.ts new file mode 100644 index 0000000..176c375 --- /dev/null +++ b/src/api/system/tenant/index.ts @@ -0,0 +1,62 @@ +import request from '@/config/axios' + +export interface TenantVO { + id: number + name: string + contactName: string + contactMobile: string + status: number + domain: string + packageId: number + username: string + password: string + expireTime: Date + accountCount: number + createTime: Date +} + +export interface TenantPageReqVO extends PageParam { + name?: string + contactName?: string + contactMobile?: string + status?: number + createTime?: Date[] +} + +export interface TenantExportReqVO { + name?: string + contactName?: string + contactMobile?: string + status?: number + createTime?: Date[] +} + +// 查询租户列表 +export const getTenantPage = (params: TenantPageReqVO) => { + return request.get({ url: '/system/tenant/page', params }) +} + +// 查询租户详情 +export const getTenant = (id: number) => { + return request.get({ url: '/system/tenant/get?id=' + id }) +} + +// 新增租户 +export const createTenant = (data: TenantVO) => { + return request.post({ url: '/system/tenant/create', data }) +} + +// 修改租户 +export const updateTenant = (data: TenantVO) => { + return request.put({ url: '/system/tenant/update', data }) +} + +// 删除租户 +export const deleteTenant = (id: number) => { + return request.delete({ url: '/system/tenant/delete?id=' + id }) +} + +// 导出租户 +export const exportTenant = (params: TenantExportReqVO) => { + return request.download({ url: '/system/tenant/export-excel', params }) +} diff --git a/src/api/system/tenantPackage/index.ts b/src/api/system/tenantPackage/index.ts new file mode 100644 index 0000000..e01375a --- /dev/null +++ b/src/api/system/tenantPackage/index.ts @@ -0,0 +1,42 @@ +import request from '@/config/axios' + +export interface TenantPackageVO { + id: number + name: string + status: number + remark: string + creator: string + updater: string + updateTime: string + menuIds: number[] + createTime: Date +} + +// 查询租户套餐列表 +export const getTenantPackagePage = (params: PageParam) => { + return request.get({ url: '/system/tenant-package/page', params }) +} + +// 获得租户 +export const getTenantPackage = (id: number) => { + return request.get({ url: '/system/tenant-package/get?id=' + id }) +} + +// 新增租户套餐 +export const createTenantPackage = (data: TenantPackageVO) => { + return request.post({ url: '/system/tenant-package/create', data }) +} + +// 修改租户套餐 +export const updateTenantPackage = (data: TenantPackageVO) => { + return request.put({ url: '/system/tenant-package/update', data }) +} + +// 删除租户套餐 +export const deleteTenantPackage = (id: number) => { + return request.delete({ url: '/system/tenant-package/delete?id=' + id }) +} +// 获取租户套餐精简信息列表 +export const getTenantPackageList = () => { + return request.get({ url: '/system/tenant-package/simple-list' }) +} diff --git a/src/api/system/user/index.ts b/src/api/system/user/index.ts new file mode 100644 index 0000000..beb6e51 --- /dev/null +++ b/src/api/system/user/index.ts @@ -0,0 +1,81 @@ +import request from '@/config/axios' + +export interface UserVO { + id: number + username: string + nickname: string + deptId: number + postIds: string[] + email: string + mobile: string + sex: number + avatar: string + loginIp: string + status: number + remark: string + loginDate: Date + createTime: Date +} + +// 查询用户管理列表 +export const getUserPage = (params: PageParam) => { + return request.get({ url: '/system/user/page', params }) +} + +// 查询所有用户列表 +export const getAllUser = () => { + return request.get({ url: '/system/user/all' }) +} + +// 查询用户详情 +export const getUser = (id: number) => { + return request.get({ url: '/system/user/get?id=' + id }) +} + +// 新增用户 +export const createUser = (data: UserVO) => { + return request.post({ url: '/system/user/create', data }) +} + +// 修改用户 +export const updateUser = (data: UserVO) => { + return request.put({ url: '/system/user/update', data }) +} + +// 删除用户 +export const deleteUser = (id: number) => { + return request.delete({ url: '/system/user/delete?id=' + id }) +} + +// 导出用户 +export const exportUser = (params) => { + return request.download({ url: '/system/user/export', params }) +} + +// 下载用户导入模板 +export const importUserTemplate = () => { + return request.download({ url: '/system/user/get-import-template' }) +} + +// 用户密码重置 +export const resetUserPwd = (id: number, password: string) => { + const data = { + id, + password + } + return request.put({ url: '/system/user/update-password', data: data }) +} + +// 用户状态修改 +export const updateUserStatus = (id: number, status: number) => { + const data = { + id, + status + } + return request.put({ url: '/system/user/update-status', data: data }) +} + +// 获取用户精简信息列表 +export const getSimpleUserList = (): Promise<UserVO[]> => { + return request.get({ url: '/system/user/simple-list' }) +} diff --git a/src/api/system/user/profile.ts b/src/api/system/user/profile.ts new file mode 100644 index 0000000..1e80e85 --- /dev/null +++ b/src/api/system/user/profile.ts @@ -0,0 +1,65 @@ +import request from '@/config/axios' + +export interface ProfileVO { + id: number + username: string + nickname: string + dept: { + id: number + name: string + } + roles: { + id: number + name: string + }[] + posts: { + id: number + name: string + }[] + socialUsers: { + type: number + openid: string + }[] + email: string + mobile: string + sex: number + avatar: string + status: number + remark: string + loginIp: string + loginDate: Date + createTime: Date +} + +export interface UserProfileUpdateReqVO { + nickname: string + email: string + mobile: string + sex: number +} + +// 查询用户个人信息 +export const getUserProfile = () => { + return request.get({ url: '/system/user/profile/get' }) +} + +// 修改用户个人信息 +export const updateUserProfile = (data: UserProfileUpdateReqVO) => { + return request.put({ url: '/system/user/profile/update', data }) +} + +// 用户密码重置 +export const updateUserPassword = (oldPassword: string, newPassword: string) => { + return request.put({ + url: '/system/user/profile/update-password', + data: { + oldPassword: oldPassword, + newPassword: newPassword + } + }) +} + +// 用户头像上传 +export const uploadAvatar = (data) => { + return request.upload({ url: '/system/user/profile/update-avatar', data: data }) +} diff --git a/src/api/system/user/socialUser.ts b/src/api/system/user/socialUser.ts new file mode 100644 index 0000000..79f4d40 --- /dev/null +++ b/src/api/system/user/socialUser.ts @@ -0,0 +1,31 @@ +import request from '@/config/axios' + +// 社交绑定,使用 code 授权码 +export const socialBind = (type, code, state) => { + return request.post({ + url: '/system/social-user/bind', + data: { + type, + code, + state + } + }) +} + +// 取消社交绑定 +export const socialUnbind = (type, openid) => { + return request.delete({ + url: '/system/social-user/unbind', + data: { + type, + openid + } + }) +} + +// 社交授权的跳转 +export const socialAuthRedirect = (type, redirectUri) => { + return request.get({ + url: '/system/auth/social-auth-redirect?type=' + type + '&redirectUri=' + redirectUri + }) +} diff --git a/src/assets/ai/copy-style2.svg b/src/assets/ai/copy-style2.svg new file mode 100644 index 0000000..2d56a87 --- /dev/null +++ b/src/assets/ai/copy-style2.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715606039621" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4256" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M878.250667 981.333333H375.338667a104.661333 104.661333 0 0 1-104.661334-104.661333V375.338667a104.661333 104.661333 0 0 1 104.661334-104.661334h502.912a104.661333 104.661333 0 0 1 104.661333 104.661334v502.912C981.333333 934.485333 934.485333 981.333333 878.250667 981.333333zM375.338667 364.373333a10.666667 10.666667 0 0 0-10.922667 10.965334v502.912c0 6.229333 4.693333 10.922667 10.922667 10.922666h502.912a10.666667 10.666667 0 0 0 10.922666-10.922666V375.338667a10.666667 10.666667 0 0 0-10.922666-10.922667H375.338667z" fill="#ffffff" p-id="4257"></path><path d="M192.597333 753.322667H147.328A104.661333 104.661333 0 0 1 42.666667 648.661333V147.328A104.661333 104.661333 0 0 1 147.328 42.666667H650.24a104.661333 104.661333 0 0 1 104.618667 104.661333v49.962667c0 26.538667-20.309333 46.848-46.848 46.848a46.037333 46.037333 0 0 1-46.848-46.848V147.328a10.666667 10.666667 0 0 0-10.922667-10.965333H147.328a10.666667 10.666667 0 0 0-10.965333 10.965333V650.24c0 6.229333 4.693333 10.922667 10.965333 10.922667h45.269333c26.538667 0 46.848 20.309333 46.848 46.848 0 26.538667-21.845333 45.312-46.848 45.312z" fill="#ffffff" p-id="4258"></path></svg> \ No newline at end of file diff --git a/src/assets/ai/copy.svg b/src/assets/ai/copy.svg new file mode 100644 index 0000000..f51f8d8 --- /dev/null +++ b/src/assets/ai/copy.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715352878351" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1499" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M624.5 786.3c92.9 0 168.2-75.3 168.2-168.2V309c0-92.4-75.3-168.2-168.2-168.2H303.6c-92.4 0-168.2 75.3-168.2 168.2v309.1c0 92.4 75.3 168.2 168.2 168.2h320.9zM178.2 618.1V309c0-69.4 56.1-125.5 125.5-125.5h320.9c69.4 0 125.5 56.1 125.5 125.5v309.1c0 69.4-56.1 125.5-125.5 125.5h-321c-69.4 0-125.4-56.1-125.4-125.5z" p-id="1500" fill="#8a8a8a"></path><path d="M849.8 295.1v361.5c0 102.7-83.6 186.3-186.3 186.3H279.1v42.7h384.4c126.3 0 229.1-102.8 229.1-229.1V295.1h-42.8zM307.9 361.8h312.3c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.9 9.6 21.4 21.4 21.4zM307.9 484.6h312.3c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.9 9.6 21.4 21.4 21.4z" p-id="1501" fill="#8a8a8a"></path><path d="M620.2 607.4c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.8 9.6 21.4 21.4 21.4h312.3z" p-id="1502" fill="#8a8a8a"></path></svg> \ No newline at end of file diff --git a/src/assets/ai/dall2.jpg b/src/assets/ai/dall2.jpg new file mode 100644 index 0000000..c07374d Binary files /dev/null and b/src/assets/ai/dall2.jpg differ diff --git a/src/assets/ai/dall3.jpg b/src/assets/ai/dall3.jpg new file mode 100644 index 0000000..7f45803 Binary files /dev/null and b/src/assets/ai/dall3.jpg differ diff --git a/src/assets/ai/delete.svg b/src/assets/ai/delete.svg new file mode 100644 index 0000000..d2ee18e --- /dev/null +++ b/src/assets/ai/delete.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715354120346" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3256" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M907.1 263.7H118.9c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4H907c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3257"></path><path d="M772.5 928.3H257.4c-27.7 0-50.2-22.5-50.2-50.2V247.2c0-9.1 7.3-16.4 16.4-16.4H801c12.1 0 21.9 9.8 21.9 21.9v625.2c0 27.8-22.6 50.4-50.4 50.4zM240 263.7v614.4c0 9.6 7.8 17.4 17.4 17.4h515.2c9.7 0 17.5-7.9 17.5-17.5V263.7H240zM657.4 131.1H368.6c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4h288.7c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3258"></path><path d="M416 754.5c-9.1 0-16.4-7.3-16.4-16.4V517.8c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0.1 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" p-id="3259"></path><path d="M416 465.2c-9.1 0-16.4-7.3-16.4-16.4v-59.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v59.4c0.1 9.1-7.3 16.4-16.4 16.4zM604.9 754.5c-9.1 0-16.4-7.3-16.4-16.4v-67.2c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" opacity=".4" p-id="3260"></path><path d="M604.9 619.1c-9.1 0-16.4-7.3-16.4-16.4V389.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v213.3c0 9.1-7.3 16.4-16.4 16.4z" fill="#8a8a8a" p-id="3261"></path></svg> \ No newline at end of file diff --git a/src/assets/ai/gpt.svg b/src/assets/ai/gpt.svg new file mode 100644 index 0000000..603e2e9 --- /dev/null +++ b/src/assets/ai/gpt.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1716345268026" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5622" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M956.408445 419.226665a250.670939 250.670939 0 0 0-22.425219-209.609236A263.163526 263.163526 0 0 0 652.490412 85.715535 259.784384 259.784384 0 0 0 457.728923 0.008192a261.422756 261.422756 0 0 0-249.44216 178.582564 258.453206 258.453206 0 0 0-172.848261 123.901894c-57.03583 96.868753-44.031251 219.132275 32.153053 302.279661a250.670939 250.670939 0 0 0 22.32282 209.609237 263.163526 263.163526 0 0 0 281.595213 123.901893A259.067596 259.067596 0 0 0 566.271077 1023.990784a260.60357 260.60357 0 0 0 249.339762-178.889759 258.453206 258.453206 0 0 0 172.848261-123.901893c57.445423-96.868753 44.13365-218.82508-32.050655-302.074865zM566.578272 957.124721c-45.362429 0-89.496079-15.666934-124.516283-44.543243 1.638372-0.921584 4.198329-2.150363 6.143895-3.481541l206.537289-117.757998a32.35785 32.35785 0 0 0 16.895713-29.081105V474.82892l87.243317 49.97035c1.023983 0.307195 1.638372 1.228779 1.638372 2.252762v238.075953c0 105.8798-86.936122 191.689541-193.942303 191.996736zM148.588578 781.102113a189.846373 189.846373 0 0 1-23.346803-128.612213c1.535974 1.023983 4.09593 2.559956 6.143895 3.48154L337.922959 773.729439c10.444622 6.143896 23.346803 6.143896 34.098621 0l252.30931-143.664758v99.531108c0 1.023983-0.307195 1.945567-1.331177 2.559956l-208.892449 118.986778a196.297463 196.297463 0 0 1-265.518686-70.04041zM94.112704 335.97688c22.630015-39.013737 58.367008-68.81163 101.16948-84.171369V494.591784c0 11.7758 6.45109 22.93721 16.793315 28.978707l252.30931 143.767156L377.141493 716.796006a3.174346 3.174346 0 0 1-2.867152 0.307195l-208.892448-118.986777A190.870355 190.870355 0 0 1 94.215102 335.874482z m717.607001 164.861198L559.410394 357.070922 646.653711 307.20297a3.174346 3.174346 0 0 1 2.969549-0.307195l208.892449 118.986777a190.358364 190.358364 0 0 1 70.961994 262.139544 194.556693 194.556693 0 0 1-101.16948 84.171369V529.407192a31.538664 31.538664 0 0 0-16.588518-28.671513z m87.03852-129.329002c-1.74077-1.023983-4.300727-2.559956-6.246294-3.48154l-206.639687-117.757999a34.09862 34.09862 0 0 0-33.996222 0L399.566711 393.934295v-99.531108c0-1.023983 0.307195-1.945567 1.331178-2.559956l208.892449-119.089176a195.990268 195.990268 0 0 1 265.518686 70.450003c22.732414 38.706542 31.129071 84.171369 23.346803 128.305018zM352.258716 548.862861l-87.243317-49.560757a2.457558 2.457558 0 0 1-1.638372-2.252762V258.870991c0-105.8798 87.243317-191.996736 194.556692-191.689541a194.556693 194.556693 0 0 1 124.209089 44.543243c-1.638372 0.921584-4.198329 2.252762-6.143896 3.48154l-206.639687 117.757999a31.948257 31.948257 0 0 0-16.793315 29.081105l-0.307194 286.715126z m47.307995-100.759887L512 384.001664l112.535687 63.998912v127.997824l-112.228492 63.998912-112.535687-63.998912-0.307195-127.997824z" p-id="5623" fill="#707070"></path></svg> \ No newline at end of file diff --git a/src/assets/ai/qingxi.jpg b/src/assets/ai/qingxi.jpg new file mode 100644 index 0000000..d76b815 Binary files /dev/null and b/src/assets/ai/qingxi.jpg differ diff --git a/src/assets/ai/ziran.jpg b/src/assets/ai/ziran.jpg new file mode 100644 index 0000000..6290724 Binary files /dev/null and b/src/assets/ai/ziran.jpg differ diff --git a/src/assets/audio/response.mp3 b/src/assets/audio/response.mp3 new file mode 100644 index 0000000..b7cb777 Binary files /dev/null and b/src/assets/audio/response.mp3 differ diff --git a/src/assets/imgs/avatar.gif b/src/assets/imgs/avatar.gif new file mode 100644 index 0000000..fdbd32c Binary files /dev/null and b/src/assets/imgs/avatar.gif differ diff --git a/src/assets/imgs/avatar.jpg b/src/assets/imgs/avatar.jpg new file mode 100644 index 0000000..d46a70a Binary files /dev/null and b/src/assets/imgs/avatar.jpg differ diff --git a/src/assets/imgs/diy/app-nav-bar-mp.png b/src/assets/imgs/diy/app-nav-bar-mp.png new file mode 100644 index 0000000..c982804 Binary files /dev/null and b/src/assets/imgs/diy/app-nav-bar-mp.png differ diff --git a/src/assets/imgs/diy/statusBar.png b/src/assets/imgs/diy/statusBar.png new file mode 100644 index 0000000..b85562e Binary files /dev/null and b/src/assets/imgs/diy/statusBar.png differ diff --git a/src/assets/imgs/logo.png b/src/assets/imgs/logo.png new file mode 100644 index 0000000..7e1043f Binary files /dev/null and b/src/assets/imgs/logo.png differ diff --git a/src/assets/imgs/profile.jpg b/src/assets/imgs/profile.jpg new file mode 100644 index 0000000..e4bcf87 Binary files /dev/null and b/src/assets/imgs/profile.jpg differ diff --git a/src/assets/imgs/wechat.png b/src/assets/imgs/wechat.png new file mode 100644 index 0000000..6afc5e4 Binary files /dev/null and b/src/assets/imgs/wechat.png differ diff --git a/src/assets/map/json/china.json b/src/assets/map/json/china.json new file mode 100644 index 0000000..bbc0a83 --- /dev/null +++ b/src/assets/map/json/china.json @@ -0,0 +1,856 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "710000", + "properties": { + "id": "710000", + "cp": [121.509062, 24.044332], + "name": "台湾", + "childNum": 6 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@°Ü¯Û"], + [ + "@@ƛĴÕƊÉɼģºðʀ\\ƎsÆNŌÔĚänÜƤɊĂǀĆĴĤNJŨxĚĮǂƺòƌâÔ®ĮXŦţƸZûÐƕƑGđ¨ĭMó·ęcëƝɉlÝƯֹÅŃ^Ó·śŃNjƏďíåɛGɉ¿@ăƑ¥ĘWǬÏĶŁâ" + ], + ["@@\\p|WoYG¿¥Ij@¢"], + ["@@ ¡@V^RqBbAnTXeRz¤L«³I"], + ["@@ÆEEkWqë @"], + ["@@fced"], + ["@@¯ɜÄèaì¯ØǓIġĽ"], + ["@@çûĖëĄhòř "] + ], + "encodeOffsets": [ + [[122886, 24033]], + [[123335, 22980]], + [[122375, 24193]], + [[122518, 24117]], + [[124427, 22618]], + [[124862, 26043]], + [[126259, 26318]], + [[127671, 26683]] + ] + } + }, + { + "type": "Feature", + "id": "130000", + "properties": { + "id": "130000", + "cp": [114.502461, 38.045474], + "name": "河北", + "childNum": 3 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@o~Z]ªrºc_ħ²G¼s`jΟnüsÂłNX_M`ǽÓnUK Ĝēs¤©yrý§uģcJe"], + ["@@U`Ts¿mÂ"], + [ + "@@oºƋÄdeVDJj£J|ÅdzÂFt~KŨ¸IÆv|¢r}èonb}`RÎÄn°ÒdÞ²^®lnÐèĄlðÓ×]ªÆ}LiñÖ`^°Ç¶p®đDcŋ`ZÔ¶êqvFÆN®ĆTH®¦O¾IbÐã´BĐɢŴÆíȦpĐÞXR·nndO¤OÀĈƒQgµFo|gȒęSWb©osx|hYhgŃfmÖĩnºTÌSp¢dYĤ¶UĈjlǐpäìë|³kÛfw²Xjz~ÂqbTÑěŨ@|oMzv¢ZrÃVw¬ŧˏf°ÐTªqs{S¯r æÝlNd®²Ğ džiGĘJ¼lr}~K¨ŸƐÌWöÆzR¤lêmĞLÎ@¡|q]SvKÑcwpÏÏĿćènĪWlĄkT}J¤~ÈTdpddʾĬBVtEÀ¢ôPĎƗè@~kü\\rÊĔÖæW_§¼F´©òDòjYÈrbĞāøŀG{ƀ|¦ðrb|ÀH`pʞkvGpuARhÞÆǶgĘTǼƹS£¨¡ù³ŘÍ]¿ÂyôEP xX¶¹ÜO¡gÚ¡IwÃé¦ÅBÏ|ǰ N«úmH¯âDùyŜŲIÄuШD¸dɂFOhđ©OiÃ`ww^ÌkÑH«ƇǤŗĺtFu {Z}Ö@U´ ʚLg®¯Oı°Ãw ^VbÉsmA ê]]w§RRl£ȭµu¯b{ÍDěïÿȧuT£ġěŗƃĝQ¨fVƋƅna@³@ďyýIĹÊKŭfċŰóxV@tƯJ]eR¾fe|rHA|h~Ėƍl§ÏlTíb ØoÅbbx³^zÃͶSj®AyÂhðk`«P˵EFÛ¬Y¨Ļrõqi¼Wi°§Ð±´°^[À|ĠO@ÆxO\\ta\\tĕtû{ġȧXýĪÓjùÎRb^ÎfK[ÝděYfíÙTyuUSyŌŏů@Oi½éŅaVcř§ax¹XŻácWU£ôãºQ¨÷Ñws¥qEHÙ|šYQoŕÇyáĂ£MðoťÊP¡mWO¡v{ôvîēÜISpÌhp¨ jdeŔQÖjX³àĈ[n`Yp@UcM`RKhEbpŞlNut®EtqnsÁgAiúoHqCXhfgu~ÏWP½¢G^}¯ÅīGCÑ^ãziMáļMTÃƘrMc|O_¯Ŏ´|morDkO\\mĆJfl@c̬¢aĦtRıÒ¾ùƀ^juųœKUFyƝ īÛ÷ąV×qƥV¿aȉd³BqPBmaËđŻģmÅ®V¹d^KKonYg¯XhqaLdu¥ÍpDž¡KąÅkĝęěhq}HyÃ]¹ǧ£ Í÷¿qáµ§g¤o^á¾ZE¤i`ij{nOl»WÝĔįhgF[¿¡ßkOüš_ūiDZàUtėGyl}ÓM}jpEC~¡FtoQiHkk{Ãmï" + ] + ], + "encodeOffsets": [[[119712, 40641]], [[121616, 39981]], [[116462, 37237]]] + } + }, + { + "type": "Feature", + "id": "140000", + "properties": { + "id": "140000", + "cp": [111.849248, 36.857014], + "name": "山西", + "childNum": 1 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@ÞĩÒSra}ÁyWix±Üe´lèßÓǏokćiµVZģ¡coTS˹ĪmnÕńehZg{gtwªpXaĚThȑp{¶Eh®RćƑP¿£Pmc¸mQÝWďȥoÅîɡųAďä³aÏJ½¥PGąSM EÅruµéYÓŌ_dĒCoȵ]¯_²ÕjāK~©ÅØ^ÔkïçămÏk]±cݯÑÃmQÍ~_apm ~ç¡qu{JÅŧ·Ls}EyÁÆcI{¤IiCfUcƌÃp§]ě«vD@¡SÀµMÅwuYY¡DbÑc¡h×]nkoQdaMç~eDÛtT©±@¥ù@É¡ZcW|WqOJmĩl«ħşvOÓ«IqăV¥D[mI~Ó¢cehiÍ]Ɠ~ĥqX·eƷn±}v[ěďŕ]_œ`¹§ÕōIo©bs^}Ét±ū«³p£ÿ·Wµ|¡¥ăFÏs×¥ŅxÊdÒ{ºvĴÎêÌɊ²¶ü¨|ÞƸµȲLLúÉƎ¤ϊęĔV`_bªS^|dzY|dz¥pZbÆ£¶ÒK}tĦÔņƠPYznÍvX¶Ěn ĠÔzý¦ª÷ÑĸÙUȌ¸dòÜJð´ìúNM¬XZ´¤ŊǸ_tldI{¦ƀðĠȤ¥NehXnYGR° ƬDj¬¸|CĞKqºfƐiĺ©ª~ĆOQª ¤@ìǦɌ²æBÊTŸʂōĖĴŞȀÆÿȄlŤĒötνî¼ĨXh|ªM¤Ðz" + ], + "encodeOffsets": [[116874, 41716]] + } + }, + { + "type": "Feature", + "id": "150000", + "properties": { + "id": "150000", + "cp": [111.670801, 41.818311], + "name": "内蒙古", + "childNum": 2 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + "@@¯PqFB |S³C|kñHdiÄ¥sʼnÅ PóÑÑE^ÅPpy_YtShQ·aHwsOnʼnÃs©iqjUSiº]ïW«gW¡ARë¥_sgÁnUI«m ]jvV¼euhwqAaW_µj »çjioQR¹ēÃßt@r³[ÛlćË^ÍÉáGOUÛOB±XkŹ£k|e]olkVͼÕqtaÏõjgÁ£§U^RLËnX°ÇBz^~wfvypV ¯ƫĉ˭ȫƗŷɿÿĿƑ˃ĝÿÃǃßËőó©ǐȍŒĖM×ÍEyxþp]ÉvïèvƀnÂĴÖ@V~Ĉv¦wĖtējyÄDXÄxGQuv_i¦aBçw˛wD©{tāmQ{EJ§KPśƘƿ¥@sCTÉ}ɃwƇy±gÑ}T[÷kÐ禫 SÒ¥¸ëBX½HáŵÀğtSÝÂa[ƣ°¯¦Pï¡]£ġÒk®G²èQ°óMq}EóƐÇ\\@áügQÍu¥FTÕ¿Jû]|mvāÎYua^WoÀa·ząÒot×¶CLƗi¯¤mƎHNJ¤îìɾŊìTdåwsRÖgĒųúÍġäÕ}Q¶¿A[¡{d×uQAMxVvMOmăl«ct[wº_ÇÊjb£ĦS_éQZ_lwgOiýe`YYLq§IÁdz£ÙË[ÕªuƏ³ÍTs·bÁĽäė[b[ŗfãcn¥îC¿÷µ[ŏÀQōĉm¿Á^£mJVmL[{Ï_£F¥Ö{ŹA} ×Wu©ÅaųijƳhB{·TQqÙIķËZđ©Yc|M¡ LeVUóK_QWk_ĥ¿ãZ»X\\ĴuUèlG®ěłTĠğDŃOrÍdÆÍz]± ŭ©Å]ÅÐ}UË¥©TċïxgckfWgi\\ÏĒ¥HkµEë{»ÏetcG±ahUiñiWsɁ·cCÕk]wȑ|ća}w VaĚá G°ùnM¬¯{ÈÐÆA¥ÄêJxÙ¢hP¢ÛºµwWOóFÁz^ÀŗÎú´§¢T¤ǻƺSėǵhÝÅQgvBHouʝl_o¿Ga{ïq{¥|ſĿHĂ÷aĝÇqZñiñC³ª »E`¨åXēÕqÉû[l}ç@čƘóO¿¡FUsAʽīccocÇS}£IS~ălkĩXçmĈ ŀÐoÐdxÒuL^T{r@¢ÍĝKén£kQyÅõËXŷƏL§~}kq»IHėDžjĝ»ÑÞoå°qTt|r©ÏS¯·eŨĕx«È[eM¿yupN~¹ÏyN£{©għWí»Í¾səšDž_ÃĀɗ±ąijĉʍŌŷSÉA±åǥɋ@ë£R©ąP©}ĹªƏj¹erLDĝ·{i«ƫC£µsKC GS|úþXgp{ÁX¿ć{ƱȏñZáĔyoÁhA}ŅĆfdʼn_¹Y°ėǩÑ¡H¯¶oMQqð¡Ë|Ñ`ƭŁX½·óÛxğįÅcQs«tȋDžFù^it«Č¯[hAi©á¥ÇĚ×l|¹y¯YȵƓñǙµïċĻ|Düȭ¶¡oŽäÕG\\ÄT¿Òõr¯LguÏYęRƩɷŌO\\İТæ^Ŋ IJȶȆbÜGĝ¬¿ĚVĎgª^íu½jÿĕęjık@Ľ]ėl¥ËĭûÁėéV©±ćn©ȇÍq¯½YÃÔʼnÉNÑÅÝy¹NqáʅDǡËñƁYÅy̱os§ȋµʽǘǏƬɱàưN¢ƔÊuľýľώȪƺɂļxZĈ}ÌʼnŪĺœĭFЛĽ̅ȣͽÒŵìƩÇϋÿȮǡŏçƑůĕ~ǼȳÐUfdIxÿ\\G zâɏÙOº·pqy£@qþ@Ǟ˽IBäƣzsÂZÁàĻdñ°ŕzéØűzșCìDȐĴĺf®Àľưø@ɜÖÞKĊŇƄ§͑těï͡VAġÑÑ»d³öǍÝXĉĕÖ{þĉu¸ËʅğU̎éhɹƆ̗̮ȘNJ֥ड़ࡰţાíϲäʮW¬®ҌeרūȠkɬɻ̼ãüfƠSצɩςåȈHϚÎKdzͲOðÏȆƘ¼CϚǚ࢚˼ФÔ¤ƌĞ̪Qʤ´¼mȠJˀƲÀɠmǐnǔĎȆÞǠN~ʢĜ¶ƌĆĘźʆȬ˪ĚǏĞGȖƴƀj`ĢçĶāàŃºēĢĖćYÀŎüôQÐÂŎŞdžŞêƖoˆDĤÕºÑǘÛˤ³̀gńƘĔÀ^ªƂ`ªt¾äƚêĦ¼ÐĔǎ¨Ȕ»͠^ˮÊȦƤøxRrŜH¤¸ÂxDÄ|ø˂˜ƮЬɚwɲFjĔ²Äw°dždÀÉ_ĸdîàŎjÊêTЪŌŜWÈ|tqĢUB~´°ÎFCU¼pĀēƄN¦¾O¶łKĊOjĚj´ĜYp{¦SĚÍ\\TתV÷Ší¨ÅDK°ßtŇĔK¨ǵÂcḷ̌ĚǣȄĽFlġUĵŇȣFʉɁMğįʏƶɷØŭOǽ«ƽū¹Ʊő̝Ȩ§ȞʘĖiɜɶʦ}¨֪ࠜ̀ƇǬ¹ǨE˦ĥªÔêFxúQEr´Wrh¤Ɛ \\talĈDJÜ|[Pll̚¸ƎGú´P¬W¦^¦H]prRn|or¾wLVnÇIujkmon£cX^Bh`¥V¦U¤¸}xRj[^xN[~ªxQ[`ªHÆÂExx^wN¶Ê|¨ìMrdYpoRzNyÀDs~bcfÌ`L¾n|¾T°c¨È¢ar¤`[|òDŞĔöxElÖdHÀI`Ď\\Àì~ÆR¼tf¦^¢ķ¶eÐÚMptgjɡČÅyġLûŇV®ÄÈƀϰP|ªVVªj¬ĚÒêp¬E|ŬÂc|ÀtƐK f{ĘFĒƌXƲąo½Ę\\¥o}Ûu£çkX{uĩ«āíÓUŅßŢqŤ¥lyň[oi{¦LńðFȪȖĒL¿Ìf£K£ʺoqNwğc`uetOj×°KJ±qÆġmĚŗos¬ qehqsuH{¸kH¡ ÊRǪÇƌbȆ¢´äÜ¢NìÉʖ¦â©Ġu¦öČ^â£ĂhĖMÈÄw\\fŦ°W ¢¾luŸDw\\̀ʉÌÛM Ā[bÓEn}¶Vc ês" + ] + ], + "encodeOffsets": [[[129102, 52189]]] + } + }, + { + "type": "Feature", + "id": "210000", + "properties": { + "id": "210000", + "cp": [123.429096, 41.796767], + "name": "辽宁", + "childNum": 16 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@L@@sa"], + ["@@MnNm"], + ["@@dc"], + ["@@eÀC@b"], + ["@@f XwkbrÄ`qg"], + ["@@^jtWQ"], + ["@@~ Y]c"], + ["@@G`ĔN^_¿ZÃM"], + ["@@iX¶BY"], + ["@@YZ"], + ["@@L_{Epf"], + ["@@^WqCT\\"], + ["@@\\[§t|¤_"], + ["@@m`n_"], + ["@@Ïxnj{q_×^Giip"], + [ + "@@@é^BntaÊU]x ¯ÄPIJ°hʙK³VÕ@Y~|EvĹsǦL^pòŸÒG Ël]xxÄ_fT¤Ď¤cPC¨¸TVjbgH²sdÎdHt`B²¬GJję¶[ÐhjeXdlwhðSȦªVÊÏÆZÆŶ®²^ÎyÅÎcPqńĚDMħĜŁHkçvV[ij¼WYÀäĦ`XlR`ôLUVfK¢{NZdĒªYĸÌÚJRr¸SA|ƴgŴĴÆbvªØX~źB|¦ÕE¤Ð`\\|KUnnI]¤ÀÂĊnŎR®Ő¿¶\\ÀøíDm¦ÎbŨabaĘ\\ľã¸atÎSƐ´©v\\ÖÚÌǴ¤Â¨JKrZ_ZfjþhPkx`YRIjJcVf~sCN¤ EhæmsHy¨SðÑÌ\\\\ĐRZk°IS§fqŒßýáĞÙÉÖ[^¯ǤŲê´\\¦¬ĆPM¯£»uïpùzExanµyoluqe¦W^£ÊL}ñrkqWňûPUP¡ôJoo·U}£[·¨@XĸDXmÛݺGUCÁª½{íĂ^cjk¶Ã[q¤LÉö³cux«zZf²BWÇ®Yß½ve±ÃCý£W{Ú^q^sÑ·¨ÍOt¹·C¥GDrí@wÕKţëV·i}xËÍ÷i©ĝɝǡ]{c±OW³Ya±_ç©HĕoƫŇqr³Lys[ñ³¯OSďOMisZ±ÅFC¥Pq{Ã[Pg}\\¿ghćO k^ģÁFıĉĥMoEqqZûěʼn³F¦oĵhÕP{¯~TÍlªNßYÐ{Ps{ÃVUeĎwk±ʼnVÓ½ŽJãÇÇ»Jm°dhcÀffdF~ĀeĖd`sx² ®EżĀdQÂd^~ăÔH¦\\LKpĄVez¤NP ǹÓRÆąJSha[¦´ÂghwmBШźhI|VV|p] ¼èNä¶ÜBÖ¼L`¼bØæKVpoúNZÞÒKxpw|ÊEMnzEQIZZNBčÚFÜçmĩWĪñtÞĵÇñZ«uD±|Əlij¥ãn·±PmÍada CLǑkùó¡³Ï«QaċÏOÃ¥ÕđQȥċƭy³ÃA" + ] + ], + "encodeOffsets": [ + [[123686, 41445]], + [[126019, 40435]], + [[124393, 40128]], + [[126117, 39963]], + [[125322, 40140]], + [[126686, 40700]], + [[126041, 40374]], + [[125584, 40168]], + [[125453, 40165]], + [[125362, 40214]], + [[125280, 40291]], + [[125774, 39997]], + [[125976, 40496]], + [[125822, 39993]], + [[125509, 40217]], + [[122731, 40949]] + ] + } + }, + { + "type": "Feature", + "id": "220000", + "properties": { "id": "220000", "cp": [125.3245, 43.886841], "name": "吉林", "childNum": 1 }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@pä³PClFbbÍzwBGĭZÅi»lYċ²SgkÇ£^Sqd¯R ©é£¯S\\cZ¹iűƏCuƍÓXoR}M^o£ R}oªUF uuXHlEÅÏ©¤ÛmTþ¤D²ÄufàÀXXȱAeyYw¬dvõ´KÊ£\\rµÄlidā]|DÂVH¹Þ®ÜWnCķ W§@\\¸~¤Vp¸póIO¢VOŇürXql~òÉK]¤¥Xrfkvzpm¶bwyFoúv𼤠N°ąO¥«³[éǡű_°Õ\\ÚÊĝþâőàerR¨JYlďQ[ ÏYëЧTGztnß¡gFkMāGÁ¤ia Éȹ`\\xs¬dĆkNnuNUuP@vRY¾\\¢ GªóĄ~RãÖÎĢùđŴÕhQxtcæëSɽʼníëlj£ƍG£nj°KƘµDsØÑpyƸ®¿bXp]vbÍZuĂ{n^IüÀSÖ¦EvRÎûh@â[ƏÈô~FNr¯ôçR±HÑlĢ^¤¢OðævxsŒ]ÞÁTĠs¶¿âÆGW¾ìA¦·TѬè¥ÏÐJ¨¼ÒÖ¼ƦɄxÊ~StD@Ă¼Ŵ¡jlºWvÐzƦZвCH AxiukdGgetqmcÛ£Ozy¥cE}| ¾cZ k¿uŐã[oxGikfeäT@ SUwpiÚFM©£è^Ú`@v¶eňf heP¶täOlÃUgÞzŸU`l}ÔÆUvØ_Ō¬Öi^ĉi§²ÃB~¡ĈÚEgc|DC_Ȧm²rBx¼MÔ¦ŮdĨÃâYxƘDVÇĺĿg¿cwÅ\\¹¥Yĭl¤OvLjM_a W`zļMž·\\swqÝSAqŚij¯°kRē°wx^ĐkǂÒ\\]nrĂ}²ĊŲÒøãh·M{yMzysěnĒġV·°G³¼XÀ¤¹i´o¤ŃÈ`ÌDzÄUĞd\\iÖmÈBĤÜɲDEh LG¾ƀľ{WaYÍÈĢĘÔRîĐj}ÇccjoUb½{h§Ǿ{KƖµÎ÷GĀÖŠåưÎslyiē«`å§H¥Ae^§GK}iã\\c]v©ģZmÃ|[M}ģTɟĵÂÂ`ÀçmFK¥ÚíÁbX³ÌQÒHof{]ept·GŋĜYünĎųVY^ydõkÅZW«WUa~U·SbwGçǑiW^qFuNĝ·EwUtW·Ýďæ©PuqEzwAVXRãQ`©GMehccďÏd©ÑW_ÏYƅ» é\\ɹ~ǙG³mØ©BšuT§Ĥ½¢Ã_ýL¡ýqT^rme\\PpZZbyuybQefµ]UhĿDCmûvaÙNSkCwncćfv~ YÇG" + ], + "encodeOffsets": [[130196, 42528]] + } + }, + { + "type": "Feature", + "id": "230000", + "properties": { + "id": "230000", + "cp": [128.642464, 46.756967], + "name": "黑龙江", + "childNum": 2 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + "@@UµNÿ¥īèçHÍøƕ¶Lǽ|g¨|a¾pVidd~ÈiíďÓQġėÇZÎXb½|ſÃH½KFgɱCģÛÇAnjÕc[VĝDZÃËÇ_ £ń³pj£º¿»WH´¯U¸đĢmtĜyzzNN|g¸÷äűѱĉā~mq^[ǁÑďlw]¯xQĔ¯l°řĴrBÞTxr[tޏĻN_yX`biNKu P£kZĮ¦[ºxÆÀdhĹŀUÈƗCwáZħÄŭcÓ¥»NAw±qȥnD`{ChdÙFć}¢A±Äj¨]ĊÕjŋ«×`VuÓÅ~_kŷVÝyhVkÄãPsOµfgeŇ µf@u_Ù ÙcªNªÙEojVxT@ãSefjlwH\\pŏäÀvlY½d{F~¦dyz¤PÜndsrhfHcvlwjF£G±DÏƥYyÏu¹XikĿ¦ÏqƗǀOŜ¨LI|FRĂn sª|C˜zxAè¥bfudTrFWÁ¹Am|ĔĕsķÆF´N}ć UÕ@Áijſmuçuð^ÊýowFzØÎĕNőǏȎôªÌŒDŽàĀÄ˄ĞŀƒʀĀƘŸˮȬƬĊ°Uzouxe]} AyÈW¯ÌmKQ]Īºif¸ÄX|sZt|½ÚUÎ lk^p{f¤lºlÆW A²PVÜPHÊâ]ÎĈÌÜk´\\@qàsĔÄQºpRij¼èi`¶bXrBgxfv»uUi^v~J¬mVp´£´VWrnP½ì¢BX¬hðX¹^TjVriªjtŊÄmtPGx¸bgRsT`ZozÆO]ÒFôÒOÆŊvÅpcGêsx´DR{AEOr°x|íb³Wm~DVjºéNNËܲɶGxŷCSt}]ûōSmtuÇÃĕNāg»íT«u}ç½BĵÞʣ¥ëÊ¡MÛ³ãȅ¡ƋaǩÈÉQG¢·lG|tvgrrf«ptęŘnÅĢrI²¯LiØsPf_vĠdxM prʹL¤¤eËÀđKïÙVY§]Ióáĥ]ķK¥j|pŇ\\kzţ¦šnņäÔVĂîά|vW®l¤èØrxm¶ă~lÄƯĄ̈́öȄEÔ¤ØQĄĄ»ƢjȦOǺ¨ìSŖÆƬyQv`cwZSÌ®ü±DŽ]ŀç¬B¬©ńzƺŷɄeeOĨSfm ĊƀP̎ēz©ĊÄÕÊmgÇsJ¥ƔŊśæÎÑqv¿íUOµªÂnĦÁ_½ä@êí £P}Ġ[@gġ}gɊ×ûÏWXá¢užƻÌsNͽƎÁ§čŐAēeL³àydl¦ĘVçŁpśdžĽĺſÊQíÜçÛġÔsĕ¬Ǹ¯YßċġHµ ¡eå`ļrĉŘóƢFìĎWøxÊkƈdƬv|I|·©NqńRŀ¤éeŊŀàŀU²ŕƀBQ£Ď}L¹Îk@©ĈuǰųǨÚ§ƈnTËÇéƟÊcfčŤ^XmHĊĕË«W·ċëx³ǔķÐċJāwİ_ĸȀ^ôWr°oú¬Ħ ŨK~ȰCĐ´Ƕ£fNÎèâw¢XnŮeÂÆĶ¾¾xäLĴĘlļO¤ÒĨA¢Êɚ¨®ØCÔ ŬGƠƦYĜĘÜƬDJg_ͥœ@čŅĻA¶¯@wÎqC½Ĉ»NăëKďÍQÙƫ[«ÃígßÔÇOÝáWñuZ¯ĥŕā¡ÑķJu¤E 寰WKɱ_d_}}vyõu¬ï¹ÓU±½@gÏ¿rýDg Cdµ°MFYxw¿CG£Rƛ½Õ{]L§{qqą¿BÇƻğëܭNJË|c²}Fµ}ÙRsÓpg±QNqǫŋRwŕnéÑÉK«SeYR ŋ@{¤SJ}D Ûǖ֍]gr¡µŷjqWÛham³~S«Þ]" + ] + ], + "encodeOffsets": [[[134456, 44547]]] + } + }, + { + "type": "Feature", + "id": "320000", + "properties": { + "id": "320000", + "cp": [119.767413, 33.041544], + "name": "江苏", + "childNum": 1 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@cþÅPi`ZRu¥É\\]~°Y`µÓ^phÁbnÀşúòaĬºTÖŒbe¦¦{¸ZâćNp©Hr|^mjhSEb\\afv`sz^lkljÄtg¤D¾X¿À|ĐiZȀåB·î}GL¢õcßjayBFµÏC^ĭcÙt¿sğH]j{s©HM¢QnDÀ©DaÜÞ·jgàiDbPufjDk`dPOîhw¡ĥ¥GP²ĐobºrYî¶aHŢ´ ]´rılw³r_{£DB_Ûdåuk|Ũ¯F Cºyr{XFye³Þċ¿ÂkĭB¿MvÛpm`rÚã@ƹhågËÖƿxnlč¶Åì½Ot¾dJlVJĂǀŞqvnO^JZż·Q}êÍÅmµÒ]ƍ¦Dq}¬R^èĂ´ŀĻĊIÔtIJyQŐĠMNtR®òLhĚs©»}OÓGZz¶A\\jĨFäOĤHYJvÞHNiÜaĎÉnFQlNM¤B´ĄNöɂtpŬdfå qm¿QûùŞÚb¤uŃJŴu»¹ĄlȖħŴw̌ŵ²ǹǠ͛hĭłƕrçü±Yxcitğ®jű¢KOķCoy`å®VTa_Ā]ŐÝɞï²ʯÊ^]afYǸÃĆēĪȣJđ͍ôƋÄÄÍīçÛɈǥ£ÛmY`ó£Z«§°Ó³QafusNıDž_k}¢m[ÝóDµ¡RLčiXyÅNïă¡¸iĔÏNÌŕoēdōîåŤûHcs}~Ûwbù¹£¦ÓCtOPrE^ÒogĉIµÛÅʹK ¤½phMü`oæŀ" + ], + "encodeOffsets": [[121740, 32276]] + } + }, + { + "type": "Feature", + "id": "330000", + "properties": { + "id": "330000", + "cp": [120.153576, 29.287459], + "name": "浙江", + "childNum": 45 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@E^dQ]K"], + ["@@jX^j"], + ["@@sfbU"], + ["@@qP\\xz[ck"], + ["@@R¢FX}°[s_"], + ["@@Cb\\}"], + ["@@e|v\\la{u"], + ["@@v~u}"], + ["@@QxÂF¯}"], + ["@@¹nvÞs¯o"], + ["@@rSkUEj"], + ["@@biZP"], + ["@@p[}INf"], + ["@@À¿"], + ["@@¹dnb "], + ["@@rSBnR"], + ["@@g~h}"], + ["@@FlEk"], + ["@@OdPc"], + ["@@v[u\\"], + ["@@FjâL~wyoo~sµL\\"], + ["@@¬e¹aN"], + ["@@\\nÔ¡q]L³ë\\ÿ®QÖ"], + ["@@ÊA©[¬"], + ["@@Kxv"], + ["@@@hlIk]"], + ["@@pW{o||j"], + ["@@Md|_mC"], + ["@@¢ X£ÏylD¼XtH"], + ["@@hlÜ[LykAvyfw^E¤"], + ["@@fp¤MusR"], + ["@@®_ma~LÁ¬Z"], + ["@@iMxZ"], + ["@@ZcYd"], + ["@@Z~dOSo|A¿qZv"], + ["@@@`EN¡v"], + ["@@|TY{"], + ["@@@n@m"], + ["@@XWkCT\\"], + ["@@ºwZRkĕWO¢"], + ["@@X®±Grƪ\\ÔáXq{"], + ["@@ůTG°ĄLHm°UC"], + [ + "@@¤aÜx~}dtüGæţŎíĔcŖpMËÐj碷ðĄÆMzjWKĎ¢Q¶À_ê_Bıi«pZgf¤Nrq]§ĂN®«H±yƳí¾×ŸīàLłčŴǝĂíÀBŖÕªÁŖHŗʼnåqûõi¨hÜ·ñt»¹ýv_[«¸mYL¯Qª mĉÅdMgÇjcº«ę¬K´B«Âącoċ\\xKd¡gěŧ«®á[~ıxu·ÅKsËÉc¢Ù\\ĭƛëbf¹ģSĜkáƉÔĈZB{aMµfzʼnfåÂŧįƋǝÊĕġć£g³neą»@¦S®\\ßðChiqªĭiAuAµ_W¥ƣO\\lċĢttC¨£t`PZäuXßBsĻyekOđġĵHuXBµ]×\\°®¬F¢¾pµ¼kŘó¬Wät¸|@L¨¸µrºù³Ù~§WIZW®±Ð¨ÒÉx`²pĜrOògtÁZ}þÙ]¡FKwsPlU[}¦Rvn`hq¬\\nQ´ĘRWb_ rtČFIÖkĦPJ¶ÖÀÖJĈĄTĚòC ²@Pú Øz©PCÈÚDZhŖl¬â~nm¨f©iļ«mntuÖZÜÄjL®EÌFª²iÊxبIÈhhst" + ], + ["@@o\\VzRZ}y"], + ["@@@°¡mÛGĕ¨§Ianá[ýƤjfæØLäGr"] + ], + "encodeOffsets": [ + [[125592, 31553]], + [[125785, 31436]], + [[125729, 31431]], + [[125513, 31380]], + [[125223, 30438]], + [[125115, 30114]], + [[124815, 29155]], + [[124419, 28746]], + [[124095, 28635]], + [[124005, 28609]], + [[125000, 30713]], + [[125111, 30698]], + [[125078, 30682]], + [[125150, 30684]], + [[124014, 28103]], + [[125008, 31331]], + [[125411, 31468]], + [[125329, 31479]], + [[125626, 30916]], + [[125417, 30956]], + [[125254, 30976]], + [[125199, 30997]], + [[125095, 31058]], + [[125083, 30915]], + [[124885, 31015]], + [[125218, 30798]], + [[124867, 30838]], + [[124755, 30788]], + [[124802, 30809]], + [[125267, 30657]], + [[125218, 30578]], + [[125200, 30562]], + [[124968, 30474]], + [[125167, 30396]], + [[124955, 29879]], + [[124714, 29781]], + [[124762, 29462]], + [[124325, 28754]], + [[123990, 28459]], + [[125366, 31477]], + [[125115, 30363]], + [[125369, 31139]], + [[122495, 31878]], + [[125329, 30690]], + [[125192, 30787]] + ] + } + }, + { + "type": "Feature", + "id": "340000", + "properties": { "id": "340000", "cp": [117.283042, 31.26119], "name": "安徽", "childNum": 3 }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@^iuLX^"], + ["@@e©Ehl"], + [ + "@@°ZÆëϵmkǀwÌÕæhºgBĝâqÙĊzÖgņtÀÁĂÆáhEz|WzqD¹°Eŧl{ævÜcA`¤C`|´qxIJkq^³³GšµbíZ ¹qpa±ď OH¦Ħx¢gPícOl_iCveaOjCh߸iÝbÛªCC¿mRV§¢A|t^iĠGÀtÚsd]ĮÐDE¶zAb àiödK¡~H¸íæAǿYj{ď¿À½W®£ChÃsikkly]_teu[bFaTign{]GqªoĈMYá|·¥f¥őaSÕėNµñĞ«Im_m¿Âa]uĜp Z_§{Cäg¤°r[_YjÆOdý[I[á·¥Q_nùgL¾mvˊBÜÆ¶ĊJhpc¹O]iŠ]¥ jtsggJǧw×jÉ©±EFËKiÛÃÕYv sm¬njĻª§emná}k«ŕgđ²ÙDÇ¤í¡ªOy×Où±@DñSęćăÕIÕ¿IµĥOjNÕËT¡¿tNæŇàåyķrĕq§ÄĩsWÆßF¶X®¿mw RIÞfßoG³¾©uyHį{Ɓħ¯AFnuP ÍÔzVdàôº^Ðæd´oG¤{S¬ćxã}ŧ×Kǥĩ«ÕOEзÖdÖsƘѨ[Û^Xr¢¼§xvÄÆµ`K§ tÒ´Cvlo¸fzŨð¾NY´ı~ÉĔē ßúLÃÃ_ÈÏ|]ÂÏFlg`ben¾¢pUh~ƴ˶_r sĄ~cƈ]|r c~`¼{À{ȒiJjz`îÀT¥Û³ ]u}f ïQl{skloNdjäËzDvčoQďHI¦rbtHĔ~BmlRV_ħTLnñH±DL¼Lªl§Ťa¸ĚlK²\\RòvDcÎJbt[¤D@®hh~kt°ǾzÖ@¾ªdbYhüóZ ň¶vHrľ\\ÊJuxAT|dmÀO[ÃÔG·ĚąĐlŪÚpSJ¨ĸLvÞcPæķŨ®mÐálwKhïgA¢ųƩޤOÈm°K´" + ] + ], + "encodeOffsets": [[[121722, 32278]], [[119475, 30423]], [[119168, 35472]]] + } + }, + { + "type": "Feature", + "id": "350000", + "properties": { + "id": "350000", + "cp": [118.306239, 26.075302], + "name": "福建", + "childNum": 18 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@zht´]"], + ["@@aj^~ĆG©O"], + ["@@ed¨C}}i"], + ["@@@vPGsQ"], + ["@@sBzddW]Q"], + ["@@S¨Q{"], + ["@@NVucW"], + ["@@qptBAq"], + ["@@¸[mu"], + ["@@Q\\pD]_"], + ["@@jSwUadpF"], + ["@@eXª~"], + ["@@AjvFso"], + ["@@fT_Çí\\v|ba¦jZÆy°"], + ["@@IjJi"], + ["@@wJIx«¼AoNe{M"], + ["@@K±¡ÓČäeZ"], + [ + "@@k¡¹Eh~c®wBkUplÀ¡I~Māe£bN¨gZý¡a±Öcp©PhI¢Qq ÇGj|¥U g[Ky¬ŏv@OptÉEF\\@ åA¬V{XģĐBy cpě ¼³Ăp·¤¥ohqqÚ¡ŅLs^á§qlÀhH¨MCe»åÇGD¥zPO£čÙkJA¼ßėuĕeûÒiÁŧSW¥Qûŗ½ùěcݧSùĩąSWó«íęACµeRåǃRCÒÇZÍ¢ź±^dlstjD¸ZpuÔâÃH¾oLUêÃÔjjēò´ĄWƛ ^Ñ¥Ħ@ÇòmOw¡õyJyD}¢ďÑÈġfZda©º²z£NjD°Ötj¶¬ZSÎ~¾c°¶ÐmxO¸¢Pl´SL|¥AȪĖMņIJg®áIJČĒü` QF¬h|ĂJ@zµ |ê³È ¸UÖŬŬÀEttĸr]ðM¤ĶIJHtÏ AĬkvsq^aÎbvdfÊòSD´Z^xPsĂrvƞŀjJd×ŘÉ ®AΦĤdxĆqAZRÀMźnĊ»İÐZ YXæJyĊ²·¶q§·K@·{sXãô«lŗ¶»o½E¡«¢±¨Y®Ø¶^AvWĶGĒĢPlzfļtàAvWYãO_¤sD§ssČġ[kƤPX¦`¶®BBvĪjv©jx[L¥àï[F ¼ÍË»ğV`«Ip}ccÅĥZEãoP ´B@D¸m±z«Ƴ¿å³BRضWlâþäą`]Z£Tc ĹGµ¶Hm@_©k¾xĨôȉðX«½đCIbćqK³ÁÄš¬OAwã»aLʼnËĥW[ÂGIÂNxij¤D¢îĎÎB§°_JGs¥E@ ¤uć PåcuMuw¢BI¿]zG¹guĮck\\_" + ] + ], + "encodeOffsets": [ + [[123250, 27563]], + [[122541, 27268]], + [[123020, 27189]], + [[122916, 27125]], + [[122887, 26845]], + [[122808, 26762]], + [[122568, 25912]], + [[122778, 26197]], + [[122515, 26757]], + [[122816, 26587]], + [[123388, 27005]], + [[122450, 26243]], + [[122578, 25962]], + [[121255, 25103]], + [[120987, 24903]], + [[122339, 25802]], + [[121042, 25093]], + [[122439, 26024]] + ] + } + }, + { + "type": "Feature", + "id": "360000", + "properties": { + "id": "360000", + "cp": [115.592151, 27.676493], + "name": "江西", + "childNum": 1 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@ĢĨƐgļ¼ÂMD~ņªe^\\^§ý©j×cZبzdÒa¶lÒJìõ`oz÷@¤u޸´ôęöY¼HČƶajlÞƩ¥éZ[|h}^U ¥pĄžƦO lt¸Æ Q\\aÆ|CnÂOjtĚĤdÈF`¶@Ðë ¦ōÒ¨SêvHĢûXD® QgÄWiØPÞìºr¤džNĠ¢lĄtZoCƞÔºCxrpĠV®Ê{f_Y`_eq®Aot`@oDXfkp¨|s¬\\DÄSfè©Hn¬ ^DhÆyøJhØxĢĀLÊƠPżċĄwȠ̦G®ǒĤäTŠÆ~Ħw«|TF¡nc³Ïå¹]ĉđxe{ÎÓvOEm°BƂĨİ|Gvz½ª´HàpeJÝQxnÀWEµàXÅĪt¨ÃĖrÄwÀFÎ|ňÓMå¼ibµ¯»åDT±m[r«_gmQu~¥V\\OkxtL E¢Ú^~ýêPóqoě±_Êw§ÑªåƗā¼mĉŹ¿NQ YBąrwģcÍ¥BŗÊcØiIƝĿuqtāwO]³YCñTeÉcaubÍ]trluī BÐGsĵıN£ï^ķqss¿FūūVÕ·´Ç{éĈýÿOER_đûIċâJhŅıNȩĕB ¦K{Tk³¡OP·wnµÏd¯}½TÍ«YiµÕsC¯iM¤¦¯P|ÿUHvhe¥oFTuõ\\OSsMòđƇiaºćXĊĵà·çhƃ÷Ç{ígu^đgm[×zkKN¶Õ»lčÓ{XSÆv©_ÈëJbVkĔVÀ¤P¾ºÈMÖxlò~ªÚàGĂ¢B±ÌKyáV¼Ã~ `gsÙfIƋlę¹e|~udjuTlXµf`¿Jd[\\L²" + ], + "encodeOffsets": [[116689, 26234]] + } + }, + { + "type": "Feature", + "id": "370000", + "properties": { + "id": "370000", + "cp": [118.000923, 36.275807], + "name": "山东", + "childNum": 13 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@Xjd]{K"], + ["@@itbFHy"], + ["@@HlGk"], + ["@@TGy"], + ["@@K¬U"], + ["@@WdXc"], + ["@@PtOs"], + ["@@LnXhc"], + ["@@ppVu]Or"], + ["@@cdzAUa"], + ["@@udRhnCI"], + ["@@oIpR"], + [ + "@@Ľč{fzƤîKÎMĮ]ZF½Y]â£ph¶¨râøÀÎǨ¤^ºÄGz~grĚĜlĞÆLĆdž¢Îo¦cvKbgr°WhmZp L]LºcUÆnżĤÌĒbAnrOA´ȊcÀbƦUØrĆUÜøĬƞEzVL®öØBkŖÝĐ˹ŧ̄±ÀbÎÉnb²ĦhņBĖįĦåXćì@L¯´ywƕCéõė ƿ¸lµ¾Z|ZWyFY¨Mf~C¿`à_RÇzwƌfQnny´INoƬèôº|sTJULîVjǎ¾ĒØDz²XPn±ŴPè¸ŔLƔÜƺ_TüÃĤBBċÈöA´faM¨{«M`¶d¡ôÖ°mȰBÔjj´PM|c^d¤u¤Û´ä«ƢfPk¶Môl]Lb}su^ke{lC MrDÇ]NÑFsmoõľHyGă{{çrnÓEƕZGª¹Fj¢ïW uøCǷë¡ąuhÛ¡^KxC`C\\bÅxì²ĝÝ¿_NīCȽĿåB¥¢·IŖÕy\\¹kxãČ×GDyäÁçFQ¡KtŵƋ]CgÏAùSedcÚźuYfyMmhUWpSyGwMPqŀÁ¼zK¶GY§Ë@´śÇµƕBm@IogZ¯uTMx}CVKï{éƵP_K«pÛÙqċtkkù]gTğwoɁsMõ³ăAN£MRkmEÊčÛbMjÝGu IZGPģãħE[iµBEuDPÔ~ª¼ęt]ûG§¡QMsğNPŏįzs£Ug{đJĿļā³]ç«Qr~¥CƎÑ^n¶ÆéÎR~ݏYI] PumŝrƿIā[xedzL¯v¯s¬ÁY ~} ťuŁgƋpÝĄ_ņī¶ÏSR´ÁP~¿Cyċßdwk´SsX|t`Ä ÈðAªìÎT°¦Dda^lĎDĶÚY°`ĪŴǒàŠv\\ebZHŖR¬ŢƱùęOÑM³FÛWp[" + ] + ], + "encodeOffsets": [ + [[123806, 39303]], + [[123821, 39266]], + [[123742, 39256]], + [[123702, 39203]], + [[123649, 39066]], + [[123847, 38933]], + [[123580, 38839]], + [[123894, 37288]], + [[123043, 36624]], + [[123344, 38676]], + [[123522, 38857]], + [[123628, 38858]], + [[118260, 36742]] + ] + } + }, + { + "type": "Feature", + "id": "410000", + "properties": { + "id": "410000", + "cp": [113.665412, 33.757975], + "name": "河南", + "childNum": 1 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@ýLùµP³swIÓxcŢĞð´E®ÚPtĴXØx¶@«ŕŕQGYfa[şußǩđš_X³ijÕčC]kbc¥CS¯ëÍB©÷³Si_}mYTt³xlàcČzÀD}ÂOQ³ÐTĨ¯ƗòËŖ[hłŦv~}ÂZ«¤lPÇ£ªÝŴÅR§ØnhctâknÏľŹUÓÝdKuķI§oTũÙďkęĆH¸Ó\\Ä¿PcnS{wBIvÉĽ[GqµuŇôYgûZca©@½Õǽys¯}lgg@C\\£asIdÍuCQñ[L±ęk·ţb¨©kK»KC²òGKmĨS`UQnk}AGēsqaJ¥ĐGRĎpCuÌy ã iMcplk|tRkðev~^´¦ÜSí¿_iyjI|ȑ|¿_»d}q^{Ƈdă}tqµ`Ƴĕg}V¡om½faÇo³TTj¥tĠRyK{ùÓjuµ{t}uËRivGçJFjµÍyqÎàQÂFewixGw½Yŷpµú³XU½ġyłåkÚwZX·l¢Á¢KzOÎÎjc¼htoDHr |J½}JZ_¯iPq{tę½ĕ¦Zpĵø«kQ Ť]MÛfaQpě±ǽ¾]uFu÷nčįADp}AjmcEÇaª³o³ÆÍSƇĈÙDIzËčľ^KLiÞñ[aA²zzÌ÷D|[íijgfÕÞd®|`Ć~oĠƑô³ŊD×°¯CsøÀ«ìUMhTº¨¸ǡîSÔDruÂÇZÖEvPZW~ØÐtĄE¢¦Ðy¸bô´oŬ¬²Ês~]®tªapŎJ¨Öº_Ŕ`Ŗ^Đ\\Ĝu~m²Ƹ¸fWĦrƔ}Î^gjdfÔ¡J}\\n C¦þWxªJRÔŠu¬ĨĨmFdM{\\d\\YÊ¢ú@@¦ª²SÜsC}fNècbpRmlØ^gd¢aÒ¢CZZxvƶN¿¢T@uC¬^ĊðÄn|lGlRjsp¢ED}Fio~ÔN~zkĘHVsDzßjŬŢ`Pûàl¢\\ÀEhİgÞē X¼Pk|m" + ], + "encodeOffsets": [[118256, 37017]] + } + }, + { + "type": "Feature", + "id": "420000", + "properties": { + "id": "420000", + "cp": [113.298572, 30.684355], + "name": "湖北", + "childNum": 3 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@AB"], + ["@@lskt"], + [ + "@@¾«}{ra®pîÃ\\{øCËyyB±b\\òÝjKL ]ĎĽÌJyÚCƈćÎT´Å´pb©ÈdFin~BCo°BĎÃømv®E^vǾ½Ĝ²RobÜeN^ĺ£R¬lĶ÷YoĖ¥Ě¾|sOr°jY`~I¾®I{GqpCgyl{£ÍÍyPL¡¡¸kWxYlÙæŁĢz¾V´W¶ùŸo¾ZHxjwfxGNÁ³Xéæl¶EièIH ujÌQ~v|sv¶Ôi|ú¢FhQsğ¦SiŠBgÐE^ÁÐ{čnOÂÈUÎóĔÊēIJ}Z³½Mŧïeyp·uk³DsѨL¶_Åuèw»¡WqÜ]\\Ò§tƗcÕ¸ÕFÏǝĉăxŻČƟOKÉġÿ×wg÷IÅzCg]m«ªGeçÃTC«[t§{loWeC@ps_Bprf_``Z|ei¡oċMqow¹DƝÓDYpûsYkıǃ}s¥ç³[§cY§HK«Qy]¢wwö¸ïx¼ņ¾Xv®ÇÀµRĠÐHM±cÏdƒǍũȅȷ±DSyúĝ£ŤĀàtÖÿï[îb\\}pĭÉI±Ñy ¿³x¯No|¹HÏÛmjúË~TuęjCöAwě¬Rđl¯ ÑbŇTĿ_[IčĄʿnM¦ğ\\É[T·k¹©oĕ@A¾wya¥Y\\¥Âaz¯ãÁ¡k¥ne£ÛwE©Êō¶˓uoj_U¡cF¹[WvP©whuÕyBF`RqJUw\\i¡{jEPïÿ½fć QÑÀQ{°fLÔ~wXgītêݾĺHd³fJd]HJ² EoU¥HhwQsƐ»Xmg±çve]DmÍPoCc¾_hhøYrŊU¶eD°Č_N~øĹĚ·`z]Äþp¼ äÌQv\\rCé¾TnkžŐÚÜa¼ÝƆ̶Ûo d ĔňТJqPb ¾|J¾fXƐîĨ_Z¯À}úƲN_ĒÄ^ĈaŐyp»CÇÄKñL³ġM²wrIÒŭxjb[n«øæà ^²h¯ÚŐªÞ¸Y²ĒVø}Ā^İ´LÚm¥ÀJÞ{JVųÞŃx×sxxƈē ģMřÚðòIfĊŒ\\Ʈ±ŒdʧĘDvČ_Àæ~Dċ´A®µ¨ØLV¦êHÒ¤" + ] + ], + "encodeOffsets": [[[113712, 34000]], [[115612, 30507]], [[113649, 34054]]] + } + }, + { + "type": "Feature", + "id": "430000", + "properties": { "id": "430000", "cp": [111.782279, 28.09409], "name": "湖南", "childNum": 3 }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@nFTs"], + ["@@ßÅÆá½ÔXrCO ËRïÿĩTooQyÓ[ŅBE¬ÎÓXaį§Ã¸G °ITxpúxÚij¥Ï̾edÄ©ĸG àGhM¤Â_U}Ċ}¢pczfþg¤ÇòAVM"], + [ + "@@©KA·³CQ±Á«³BUƑ¹AtćOwD]JiØSm¯b£ylX HËѱH«C^õľAŧ¤É¥ïyuǙuA¢^{ÌC´¦ŷJ£^[ª¿ĕ~Ƈ N skóā¹¿ï]ă~÷O§@Vm¡Qđ¦¢Ĥ{ºjÔª¥nf´~Õo×ÛąMąıuZmZcÒ IJβSÊDŽŶ¨ƚCÖŎªQؼrŭ«}NÏürʬmjr@ĘrTW SsdHzƓ^ÇÂyUi¯DÅYlŹu{hT}mĉ¹¥ěDÿë©ıÓ[Oº£¥ótł¹MÕƪ`P DiÛU¾ÅâìUñBÈ£ýhedy¡oċ`pfmjP~kZa ZsÐd°wj§@Ĵ®w~^kÀÅKvNmX\\¨aŃqvíó¿F¤¡@ũÑVw}S@j}¾«pĂrªg àÀ²NJ¶¶Dô K|^ª°LX¾ŴäPα£EXd^¶IJÞÜ~u¸ǔMRhsR e`ÄofIÔ\\Ø ićymnú¨cj ¢»GČìƊÿШXeĈ¾Oð Fi ¢|[jVxrIQ_EzAN¦zLU`cªxOTu RLÄ¢dVi`p˔vŎµªÉF~Ød¢ºgİàw¸Áb[¦Zb¦z½xBĖ@ªpºlS¸Ö\\Ĕ[N¥ˀmĎăJ\\ŀ` ňSÚĖÁĐiOĜ«BxDõĚivSÌ}iùÜnкG{p°M´wÀÒzJ²ò¨ oTçüöoÛÿñőФùTz²CȆȸǎŪƑÐc°dPÎğ˶[Ƚu¯½WM¡ÉB·rínZÒ `¨GA¾\\pēXhÃRCüWGġu Té§ŎÑ©ò³I±³}_EÃħg®ęisÁPDmÅ{b[RÅs·kPŽƥóRoOV~]{g\\êYƪ¦kÝbiċƵGZ»Ěõ ó·³vŝ£ø@pyö_ëIkѵbcѧy ×dYتiþ¨[]f]Ņ©C}ÁN»hĻħƏĩ" + ] + ], + "encodeOffsets": [[[115640, 30489]], [[112543, 27312]], [[116690, 26230]]] + } + }, + { + "type": "Feature", + "id": "440000", + "properties": { + "id": "440000", + "cp": [113.280637, 23.125178], + "name": "广东", + "childNum": 24 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@QdAua"], + ["@@lxDLo"], + ["@@sbhNLo"], + ["@@Ă ā"], + ["@@WltO[["], + ["@@Kr]S"], + ["@@eI]y"], + ["@@I|Mym"], + ["@@Û³LS¼Y"], + ["@@nvºBëui©`¾"], + ["@@zdÛJw®"], + ["@@° ¯"], + ["@@a yAª¸ËJIxØ@ĀHAmÃV¡ofuo"], + ["@@sŗÃÔėAƁZÄ ~°ČPäh"], + ["@@¶ÝÌvmĞhıQ"], + ["@@HdSjĒ¢D}war u«ZqadYM"], + ["@@el\\LqqU"], + ["@@~rMo\\"], + ["@@f^C"], + ["@@øPªoj÷ÍÝħXČx°Q¨ıXNv"], + ["@@gÇƳo[~tly"], + ["@@EÆC¿"], + ["@@OP"], + [ + "@@wđógĝ[³¡VÙæÅöM̳¹pÁaËýý©D©ÜJŹƕģGą¤{Ùū ÇO²«BƱéAÒĥ¡«BhlmtÃPµyU¯ucd·w_bŝcīímGO|KPȏŹãŝIŕŭŕ@Óoo¿ē±ß} ŭIJWÈCőâUâǙIğʼn©IijE× Á³AówXJþ±ÌÜÓĨ£L]ĈÙƺZǾĆĖMĸĤfÎĵlŨnÈĐtFFĤêk¶^k°f¶g}®Faf`vXŲxl¦ÔÁ²¬Ð¦pqÊ̲iXØRDÎ}Ä@ZĠsx®AR~®ETtĄZƈfŠŠHâÒÐAµ\\S¸^wĖkRzalŜ|E¨ÈNĀňZTpBh£\\ĎƀuXĖtKL¶G|»ĺEļĞ~ÜĢÛĊrOÙîvd]n¬VÊĜ°RÖpMƂªFbwEÀ©\\ ¤]ŸI®¥D³|Ë]CöAŤ¦ æ´¥¸Lv¼¢ĽBaôF~®²GÌÒEYzk¤°ahlVÕI^CxĈPsBƒºV¸@¾ªR²ĨN]´_eavSivc}p}Đ¼ƌkJÚe th_¸ ºx±ò_xN˲@ă¡ßH©Ùñ}wkNÕ¹ÇO½¿£ĕ]ly_WìIǪ`uTÅxYĒÖ¼kÖµMjJÚwn\\hĒv]îh|ÈƄøèg¸Ķß ĉĈWb¹ƀdéĘNTtP[öSvrCZaGubo´ŖÒÇĐ~¡zCI özx¢PnÈñ @ĥÒ¦]ƞV}³ăĔñiiÄÓVépKG½ÄÓávYoC·sitiaÀyŧΡÈYDÑům}ý|m[węõĉZÅxUO}÷N¹³ĉo_qtăqwµŁYÙǝŕ¹tïÛUïmRCº ĭ|µÕÊK½Rē ó]GªęAx»HO£|ām¡diď×YïYWªʼnOeÚtĐ«zđ¹T āúEá²\\ķÍ}jYàÙÆſ¿Çdğ·ùTßÇţʄ¡XgWÀLJğ·¿ÃOj YÇ÷Qěi" + ] + ], + "encodeOffsets": [ + [[117381, 22988]], + [[116552, 22934]], + [[116790, 22617]], + [[116973, 22545]], + [[116444, 22536]], + [[116931, 22515]], + [[116496, 22490]], + [[116453, 22449]], + [[113301, 21439]], + [[118726, 21604]], + [[118709, 21486]], + [[113210, 20816]], + [[115482, 22082]], + [[113171, 21585]], + [[113199, 21590]], + [[115232, 22102]], + [[115739, 22373]], + [[115134, 22184]], + [[113056, 21175]], + [[119573, 21271]], + [[119957, 24020]], + [[115859, 22356]], + [[116561, 22649]], + [[116285, 22746]] + ] + } + }, + { + "type": "Feature", + "id": "450000", + "properties": { "id": "450000", "cp": [108.320004, 22.82402], "name": "广西", "childNum": 2 }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@H TQ§A"], + [ + "@@ĨʪLƊDÎĹĐCǦė¸zÚGn£¾rªŀÜt¬@ÖÚSx~øOŒŶÐÂæȠ\\ÈÜObĖw^oÞLf¬°bI lTØBÌF£Ć¹gñĤaYt¿¤VSñK¸¤nM¼JE±½¸ñoÜCƆæĪ^ĚQÖ¦^f´QüÜÊz¯lzUĺš@ìp¶n]sxtx¶@~ÒĂJb©gk{°~c°`Ô¬rV\\la¼¤ôá`¯¹LCÆbxEræOv[H[~|aB£ÖsºdAĐzNÂðsÞÆ Ĥªbab`ho¡³F«èVlo¤ÔRzpp®SĪº¨ÖºN ijd`a¦¤F³ºDÎńĀìCĜº¦Ċ~nS|gźvZkCÆj°zVÈÁƔ]LÊFZg čPkini«qÇczÍY®¬Ů»qR×ō©DÕ§ƙǃŵTÉĩ±ıdÑnYYIJvNĆĆØÜ Öp}e³¦m©iÓ|¹ħņ|ª¦QF¢Â¬ʖovg¿em^ucà÷gÕuíÙćĝ}FϼĹ{µHKsLSđƃrč¤[AgoSŇYMÿ§Ç{FśbkylQxĕ]T·¶[B ÑÏGáşşƇe ăYSsFQ}BwtYğÃ@~ CÍQ ×Wj˱rÉ¥oÏ ±«ÓÂ¥kwWűmcih³K~µh¯e]lµélEģEďsmÇŧē`ãògK_ÛsUʝćğ¶höO¤Ǜn³c`¡y¦CezYwa[ďĵűMę§]XÎ_íÛ]éÛUćİÕBƣ± dy¹T^dûÅÑŦ·PĻþÙ`K¦ ¢ÍeĥR¿³£[~äu¼dltW¸oRM¢ď\\z}Æzdvň{ÎXF¶°Â_ÒÂÏL©ÖTmu¼ãlīkiqéfA·Êµ\\őDc¥ÝFyÔćcűH_hLÜêĺШc}rn`½Ì@¸¶ªVLhŒ\\Ţĺk~Ġið°|gtTĭĸ^xvKVGréAébUuMJVÃO¡ qĂXËSģãlýà_juYÛÒBG^éÖ¶§EGÅzěƯ¤EkN[kdåucé¬dnYpAyČ{`]þ¯TbÜÈk¡ĠvàhÂƄ¢Jî¶²" + ] + ], + "encodeOffsets": [[[111707, 21520]], [[107619, 25527]]] + } + }, + { + "type": "Feature", + "id": "460000", + "properties": { "id": "460000", "cp": [109.83119, 19.031971], "name": "海南", "childNum": 1 }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@¦Ŝil¢XƦƞòïè§ŞCêɕrŧůÇąĻõ·ĉ³œ̅kÇm@ċȧŧĥĽʉƅſȓÒ˦ŝE}ºƑ[ÍĜȋ gÎfǐÏĤ¨êƺ\\Ɔ¸ĠĎvʄȀоjNðĀÒRZdžzÐŘΰH¨Ƣb²_Ġ " + ], + "encodeOffsets": [[112750, 20508]] + } + }, + { + "type": "Feature", + "id": "510000", + "properties": { + "id": "510000", + "cp": [104.065735, 30.659462], + "name": "四川", + "childNum": 2 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@LqKr"], + [ + "@@[ĻéV£_ţġñpG réÏ·~ąSfy×Í·ºſƽiÍıƣıĻmHH}siaX@iǰÁÃ×t«T¤JJJyJÈ`Ohߦ¡uËhIyCjmÿw ZG TiSsOB²fNmsPa{M{õE^Hj}gYpaeu¯oáwHjÁ½M¡pMuåmni{fk\\oÎqCwEZ¼KĝAy{m÷LwO×SimRI¯rKõBS«sFe]fµ¢óY_ÆPRcue°Cbo×bd£ŌIHgtrnyPt¦foaXďxlBowz_{ÊéWiêEGhܸºuFĈIxf®Y½ĀǙ]¤EyF²ċw¸¿@g¢§RGv»áW`ÃĵJwi]t¥wO½a[×]`ÃiüL¦LabbTÀåc}ÍhÆh®BHî|îºÉk¤Sy£ia©taį·Ɖ`ō¥UhO ĝLk}©Fos´JmµlŁu ønÑJWΪYÀïAetTŅÓGË«bo{ıwodƟ½OġܵxàNÖ¾P²§HKv¾]|BÆåoZ`¡Ø`ÀmºĠ~ÌЧnÇ ¿¤]wğ@srğu~Io[é±¹ ¿ſđÓ@qg¹zƱřaí°KtǤV»Ã[ĩǭƑ^ÇÓ@áťsZÏÅĭƋěpwDóÖáŻneQËq·GCœýS]x·ýq³OÕ¶Qzßti{řáÍÇWŝŭñzÇWpç¿JXĩè½cFÂLiVjx}\\NŇĖ¥GeJA¼ÄHfÈu~¸Æ«dE³ÉMA|bÒ ćhG¬CMõƤąAvüVéŀ_V̳ĐwQj´·ZeÈÁ¨X´Æ¡Qu·»ÕZ³ġqDoy`L¬gdp°şp¦ėìÅĮZ°Iähzĵf²å ĚÑKpIN|Ñz]ń ·FU×é»R³MÉ»GM«kiér}Ã`¹ăÞmÈnÁîRǀ³ĜoİzŔwǶVÚ£À]ɜ»ĆlƂ²Ġ þTº·àUȞÏʦ¶I«dĽĢdĬ¿»Ĕ×h\\c¬ä²GêëĤł¥ÀǿżÃÆMº}BÕĢyFVvwxBèĻĒ©ĈtCĢɽŠȣ¦āæ·HĽîôNÔ~^¤Ɗu^s¼{TA¼ø°¢İªDè¾Ň¶ÝJ®Z´ğ~Sn|ªWÚ©òzPOȸbð¢|øĞŒQìÛÐ@ĞǎRS¤Á§d i´ezÝúØã]HqkIþËQǦÃsǤ[E¬ÉŪÍxXƒ·ÖƁİlƞ¹ª¹|XÊwnÆƄmÀêErĒtD®ċæcQE®³^ĭ¥©l}äQtoŖÜqÆkµªÔĻĴ¡@Ċ°B²Èw^^RsºT£ڿQPJvÄz^Đ¹Æ¯fLà´GC²dtĀRt¼¤ĦOðğfÔðDŨŁĞƘïPÈ®âbMüÀXZ ¸£@Å»»QÉ]dsÖ×_Í_ÌêŮPrĔĐÕGĂeZÜîĘqBhtO ¤tE[h|YÔZśÎs´xº±Uñt|OĩĠºNbgþJy^dÂY Į]Řz¦gC³R`Āz¢Aj¸CL¤RÆ»@Ŏk\\Ç´£YW}z@Z}öoû¶]´^NÒ}èNªPÍy¹`S°´ATeVamdUĐwʄvĮÕ\\uÆŗ¨Yp¹àZÂmWh{á}WØǍÉüwga§áCNęÎ[ĀÕĪgÖɪXøx¬½Ů¦¦[NÎLÜUÖ´òrÙŠxR^JkijnDX{U~ET{ļº¦PZcjF²Ė@pg¨B{u¨ŦyhoÚD®¯¢ WòàFΤ¨GDäz¦kŮPġqË¥À]eâÚ´ªKxīPÖ|æ[xäJÞĥsNÖ½I¬nĨY´®ÐƐmDŝuäđđEb ee_v¡}ìęNJē}qÉåT¯µRs¡M@}ůaa¯wvƉåZw\\Z{åû^" + ] + ], + "encodeOffsets": [[[108815, 30935]], [[110617, 31811]]] + } + }, + { + "type": "Feature", + "id": "520000", + "properties": { + "id": "520000", + "cp": [106.713478, 26.578343], + "name": "贵州", + "childNum": 3 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@G\\lY£in"], + ["@@q|mc¯tÏVSÎ"], + [ + "@@hÑ£IsNgßHHªķÃh_¹¡ĝħń¦uÙùgS¯JH|sÝÅtÁïyMDč»eÕtA¤{b\\}G®u\\åPFqwÅaD K°ºâ_£ùbµmÁÛĹM[q|hlaªāI}ѵ@swtwm^oµD鼊yVky°ÉûÛR ³e¥]RÕěħ[ƅåÛDpJiVÂF²I »mN·£LbÒYbWsÀbpkiTZĄă¶Hq` ĥ_J¯ae«KpÝx]aĕÛPÇȟ[ÁåŵÏő÷Pw}TÙ@Õs«ĿÛq©½m¤ÙH·yǥĘĉBµĨÕnđ]K©œáGçş§ÕßgǗĦTèƤƺ{¶ÉHÎd¾ŚÊ·OÐjXWrãLyzÉAL¾ę¢bĶėy_qMĔąro¼hĊw¶øV¤w²Ĉ]ÊKx|`ź¦ÂÈdrcÈbe¸`I¼čTF´¼Óýȃr¹ÍJ©k_șl³´_pĐ`oÒh¶pa^ÓĔ}D»^Xy`d[Kv JPhèhCrĂĚÂ^Êƌ wZLĠ£ÁbrzOIlMMĪŐžËr×ÎeŦtw|¢mKjSǘňĂStÎŦEtqFT¾E쬬ôxÌO¢ K³ŀºäYPVgŎ¦Ŋm޼VZwVlz¤ £Tl®ctĽÚó{GAÇge~Îd¿æaSba¥KKûj®_Ä^\\ؾbP®¦x^sxjĶI_Ä Xâ¼Hu¨Qh¡À@Ëô}±GNìĎlT¸ `V~R°tbÕĊ`¸úÛtÏFDu[MfqGH·¥yAztMFe|R_GkChZeÚ°tov`xbDnÐ{E}ZèxNEÞREn[Pv@{~rĆAB§EO¿|UZ~ìUf¨J²ĂÝÆsªB`s¶fvö¦Õ~dÔq¨¸º»uù[[§´sb¤¢zþF¢Æ ÀhÂW\\ıËIÝo±ĭŠ£þÊs}¡R]ěDg´VG¢j±®èºÃmpU[Á뺰rÜbNu¸}º¼`niºÔXĄ¤¼ÔdaµÁ_à ftQQgR·Ǔv}Ý×ĵ]µWc¤F²OĩųãW½¯K© ]{LóµCIµ±Mß¿h©āq¬o½~@i~TUxŪÒ¢@£ÀEîôruńb[§nWuMÆLl¿]x}ij½" + ] + ], + "encodeOffsets": [[[112158, 27383]], [[112105, 27474]], [[112095, 27476]]] + } + }, + { + "type": "Feature", + "id": "530000", + "properties": { + "id": "530000", + "cp": [101.512251, 24.740609], + "name": "云南", + "childNum": 1 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@[ùx½}ÑRHYīĺûsÍniEoã½Ya²ė{c¬ĝgĂsAØÅwďõzFjw}«Dx¿}Uũlê@HÅF¨ÇoJ´Ónũuą¡Ã¢pÒÅØ TF²xa²ËXcÊlHîAßËŁkŻƑŷÉ©hWæßUËs¡¦}teèÆ¶StÇÇ}Fd£jĈZĆÆ¤Tč\\D}O÷£U§~ŃGåŃDĝ¸Tsd¶¶Bª¤u¢ŌĎo~t¾ÍŶÒtD¦ÚiôözØX²ghįh½Û±¯ÿm·zR¦Ɵ`ªŊÃh¢rOÔ´£Ym¼èêf¯ŪĽncÚbw\\zlvWªâ ¦gmĿBĹ£¢ƹřbĥkǫßeeZkÙIKueT»sVesbaĕ ¶®dNĄÄpªy¼³BE®lGŭCǶwêżĔÂepÍÀQƞpC¼ŲÈAÎô¶RäQ^Øu¬°_Èôc´¹ò¨P΢hlϦ´ĦÆ´sâÇŲPnÊD^¯°Upv}®BP̪jǬxSöwlfòªvqĸ|`HviļndĜĆhňem·FyÞqóSᝳX_ĞçêtryvL¤§z¦c¦¥jnŞklD¤øz½ĜàĂŧMÅ|áƆàÊcðÂFÜáŢ¥\\\\ºİøÒÐJĴîD¦zK²ǏÎEh~CDhMn^ÌöÄ©ČZÀaüfɭyœpį´ěFűk]Ôě¢qlÅĆÙa¶~ÄqêljN¬¼HÊNQ´ê¼VظE^ŃÒyM{JLoÒęæe±Ķygã¯JYÆĭĘëo¥Šo¯hcK«z_prC´ĢÖY¼ v¸¢RÅW³Â§fǸYi³xR´ďUË`êĿUûuĆBƣöNDH«ĈgÑaB{ÊNF´¬c·Åv}eÇÃGB»If¦HňĕM ~[iwjUÁKE¾dĪçWIèÀoÈXòyŞŮÈXâÎŚj|àsRyµÖPr´þ ¸^wþTDŔHr¸RÌmfżÕâCôoxĜƌÆĮÐYtâŦÔ@]ÈǮƒ\\μģUsȯLbîƲŚºyhr@ĒÔƀÀ²º\\êpJ}ĠvqtĠ@^xÀ£È¨mËÏğ}n¹_¿¢×Y_æpÅA^{½Lu¨GO±Õ½ßM¶wÁĢÛPƢ¼pcIJx|ap̬HÐŊSfsðBZ¿©XÏÒKk÷Eû¿S rEFsÕūkóVǥʼniTL¡n{uxţÏhôŝ¬ğōNNJkyPaqÂğ¤K®YxÉƋÁ]āęDqçgOgILu\\_gz]W¼~CÔē]bµogpÑ_oď`´³Țkl`IªºÎȄqÔþ»E³ĎSJ»_f·adÇqÇc¥Á_Źw{L^ɱćxU£µ÷xgĉp»ĆqNē`rĘzaĵĚ¡K½ÊBzyäKXqiWPÏɸ½řÍcÊG|µƕƣGË÷k°_^ý|_zċBZocmø¯hhcæ\\lMFlư£ĜÆyHF¨µêÕ]HA àÓ^it `þßäkĤÎT~Wlÿ¨ÔPzUCNVv [jâôDôď[}z¿msSh¯{jïğl}šĹ[őgK©U·µË@¾m_~q¡f¹ ÅË^»f³ø}Q¡Ö˳gͱ^Ç \\ëÃA_¿bWÏ[¶ƛé£F{īZgm@|kHǭƁć¦UĔť×ë}ǝeďºȡȘÏíBÉ£āĘPªij¶ʼnÿy©nď£G¹¡I±LÉĺÑdĉÜW¥}gÁ{aqÃ¥aıęÏZï`" + ], + "encodeOffsets": [[104636, 22969]] + } + }, + { + "type": "Feature", + "id": "540000", + "properties": { "id": "540000", "cp": [89.132212, 30.860361], "name": "西藏", "childNum": 1 }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@ÂhľxŖxÒVºÅâAĪÝȆµę¯Ňa±r_w~uSÕňqOj]ɄQ £Z UDûoY»©M[L¼qãË{VÍçWVi]ë©Ä÷àyƛhÚU°adcQ~Mx¥cc¡ÙaSyFÖkuRýq¿ÔµQĽ³aG{¿FµëªéĜÿª@¬·K·àariĕĀ«V»ŶĴūgèLǴŇƶaftèBŚ£^âǐÝ®M¦ÁǞÿ¬LhJ¾óƾƺcxwf]Y ´¦|QLn°adĊ \\¨oǀÍŎ´ĩĀd`tÊQŞŕ|¨C^©Ĉ¦¦ÎJĊ{ëĎjª²rÐl`¼Ą[t|¦Stè¾PÜK¸dƄı]s¤î_v¹ÎVòŦj£Əsc¬_Ğ´|٦Av¦w`ăaÝaa¢e¤ı²©ªSªÈMĄwÉØŔì@T¤Ę\\õª@þo´xA sÂtŎKzó´ÇĊµ¢r^nĊƬ×üG¢³ {âĊ]G~bÀgVjzlhǶfOfdªB]pjTOtĊn¤}®¦Č¥d¢¼»ddY¼t¢eȤJ¤}Ǿ¡°§¤AÐlc@ĝsªćļđAçwxUuzEÖġ~AN¹ÄÅȀݦ¿ģŁéì±H ãd«g[ؼēÀcīľġ¬cJµ ÐʥVȝ¸ßS¹ý±ğkƁ¼ą^ɛ¤Ûÿb[}¬ōõÃ]ËNm®g@Bg}ÍF±ǐyL¥íCIijÏ÷Ñį[¹¦[âšEÛïÁÉdƅß{âNÆāŨß¾ě÷yC£k´ÓH@¹TZ¥¢į·ÌAЧ®Zc v½Z¹|ÅWZqgW|ieZÅYVÓqdqbc²R@c¥Rã»GeeƃīQ}J[ÒK ¬Ə|oėjġĠÑN¡ð¯EBčnwôɍėª²CλŹġǝʅįĭạ̃ūȹ]ΓͧgšsgȽóϧµǛęgſ¶ҍć`ĘąŌJÞä¤rÅň¥ÖÁUětęuůÞiĊÄÀ\\Æs¦ÓRb|Â^řÌkÄŷ¶½÷f±iMÝ@ĥ°G¬ÃM¥n£Øąğ¯ß§aëbéüÑOčk£{\\eµª×MÉfm«Ƒ{Å×Gŏǩãy³©WÑăû··Qòı}¯ãIéÕÂZ¨īès¶ZÈsæĔTŘvgÌsN@îá¾ó@ÙwU±ÉT廣TđWxq¹Zobs[ׯcĩvėŧ³BM|¹kªħ¥TzNYnÝßpęrñĠĉRS~½ěVVµõ«M££µBĉ¥áºae~³AuĐh`ܳç@BÛïĿa©|z²Ý¼D£àč²ŸIûI āóK¥}rÝ_Á´éMaň¨~ªSĈ½½KÙóĿeƃÆB·¬ën×W|Uº}LJrƳlŒµ`bÔ`QÐÓ@s¬ñIÍ@ûws¡åQÑßÁ`ŋĴ{ĪTÚÅTSijYo|Ç[ǾµMW¢ĭiÕØ¿@Mh pÕ]jéò¿OƇĆƇpêĉâlØwěsǩĵ¸c bU¹ř¨WavquSMzeo_^gsÏ·¥Ó@~¯¿RiīB\\qTGªÇĜçPoÿfñòą¦óQīÈáPābß{ZŗĸIæÅhnszÁCËìñÏ·ąĚÝUm®óL·ăUÈíoù´Êj°ŁŤ_uµ^°ìÇ@tĶĒ¡ÆM³Ģ«İĨÅ®ğRāðggheÆ¢zÊ©Ô\\°ÝĎz~ź¤PnMĪÖB£kné§żćĆKǰ¼L¶èâz¨u¦¥LDĘz¬ýÎmĘd¾ßFzhg²Fy¦ĝ¤ċņbÎ@yĄæm°NĮZRÖíJ²öLĸÒ¨Y®ƌÐVàtt_ÚÂyĠz]ŢhzĎ{ÂĢXc|ÐqfO¢¤ögÌHNPKŖUú´xx[xvĐCûĀìÖT¬¸^}Ìsòd´_KgžLĴ ÀBon|H@Êx¦BpŰŌ¿fµƌA¾zLjRx¶FkĄźRzŀ~¶[´HnªVƞuĒȨƎcƽÌm¸ÁÈM¦x͊ëÀxdžBú^´W£dkɾĬpw˂ØɦļĬIŚÊnŔa¸~J°îlɌxĤÊÈðhÌ®gT´øàCÀ^ªerrƘd¢İP|Ė ŸWªĦ^¶´ÂLaT±üWƜǀRÂŶUńĖ[QhlLüAÜ\\qRĄ©" + ], + "encodeOffsets": [[90849, 37210]] + } + }, + { + "type": "Feature", + "id": "610000", + "properties": { + "id": "610000", + "cp": [108.948024, 34.263161], + "name": "陕西", + "childNum": 1 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@p¢ȮµûGĦ}Ħðǚ¶òƄjɂz°{ºØkÈęâ¦jªBg\\ċ°s¬]jú EȌdž¬stRÆdĠİwܸôW¾ƮłÒ_{Ìû¼jº¹¢GǪÒ¯ĘZ`ºŊecņą~BÂgzpâēòYǠȰÌTΨÂW|fcă§uF@N¢XLRMº[ğȣſï|¥Jkc`sʼnǷY¹W@µ÷K ãï³ÛIcñ·VȋÚÒķø©þ¥yÓğęmWµÎumZyOŅƟĥÓ~sÑL¤µaÅ Y¦ocyZ{y c]{Ta©`U_Ěē£ωÊƍKùK¶ȱÝƷ§{û»ÅÁȹÍéuij|¹cÑdìUYOuFÕÈYvÁCqÓTǢí§·S¹NgV¬ë÷Át°DدC´ʼnƒópģ}ċcEË FéGU¥×K §¶³BČ}C¿åċ`wġB·¤őcƭ²ő[Å^axwQO ÿEËߌĤNĔwƇÄńwĪo[_KÓª³ÙnKÇěÿ]ďă_d©·©Ýŏ°Ù®g]±ßå¬÷m\\iaǑkěX{¢|ZKlçhLtŇîŵœè[É@ƉĄEtƇϳħZ«mJ ×¾MtÝĦ£IwÄå\\Õ{OwĬ©LÙ³ÙgBƕŀrÌĢŭO¥lãyC§HÍ£ßEñX¡°ÙCgpťzb`wIvA|§hoĕ@E±iYd¥OϹS|}F@¾oAO²{tfÜ¢FǂÒW²°BĤh^Wx{@¬F¸¡ķn£P|ªĴ@^ĠĈæbÔc¶lYi ^MicϰÂ[ävï¶gv@ÀĬ·lJ¸sn|¼u~a]ÆÈtŌºJpþ£KKf~¦UbyäIĺãnÔ¿^ŵMThĠܤko¼Ŏìąǜh`[tRd²IJ_XPrɲlXiL§à¹H°Ȧqº®QCbAŌJ¸ĕÚ³ĺ§ `d¨YjiZvRĺ±öVKkjGȊÄePĞZmļKÀ[`ösìhïÎoĬdtKÞ{¬èÒÒBÔpIJÇĬJŊ¦±J«Y§@·pHµàåVKepWftsAÅqC·¬ko«pHÆuK@oHĆÛķhxenS³àǍrqƶRbzy¸ËÐl¼EºpĤ¼x¼½~Ğà@ÚüdK^mÌSj" + ], + "encodeOffsets": [[110234, 38774]] + } + }, + { + "type": "Feature", + "id": "620000", + "properties": { + "id": "620000", + "cp": [103.823557, 36.058039], + "name": "甘肃", + "childNum": 2 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@VuUv"], + [ + "@@ũEĠtt~nkh`Q¦ÅÄÜdwAb×ĠąJ¤DüègĺqBqj°lI¡ĨÒ¤úSHbjÎB°aZ¢KJO[|A£Dx}NìHUnrk kp¼Y kMJn[aGáÚÏ[½rc}aQxOgsPMnUsncZ sKúvAtÞġ£®ĀYKdnFw¢JE°Latf`¼h¬we|Æbj}GA·~W`¢MC¤tL©IJ°qdfObÞĬ¹ttu`^ZúE`[@Æsîz®¡CƳƜG²R¢RmfwĸgÜą G@pzJM½mhVy¸uÈÔO±¨{LfæU¶ßGĂq\\ª¬²I¥IʼnÈīoıÓÑAçÑ|«LÝcspīðÍg të_õ\\ĉñLYnĝgRǡÁiHLlõUĹ²uQjYi§Z_c¨´ĹĖÙ·ŋI aBDR¹ȥr¯GºßK¨jWkɱOqWij\\aQ\\sg_ĆǛōëp»£lğÛgSŶN®À]ÓämĹãJaz¥V}Le¤Lýo¹IsŋÅÇ^bz ³tmEÁ´a¹cčecÇNĊãÁ\\č¯dNj]jZµkÓdaćå]ğij@ ©O{¤ĸm¢E·®«|@Xwg]A챝XǁÑdzªcwQÚŝñsÕ³ÛV_ý¥\\ů¥©¾÷w©WÕÊĩhÿÖÁRo¸V¬âDb¨hûxÊ×nj~Zâg|XÁnßYoº§ZÅŘv[ĭÖʃuďxcVbnUSf B¯³_TzºÎO©çMÑ~M³]µ^püµÄY~y@X~¤Z³[Èōl@®Å¼£QK·Di¡ByÿQ_´D¥hŗy^ĭÁZ]cIzýah¹MĪğPs{ò²Vw¹t³ŜË[Ñ}X\\gsF£sPAgěp×ëfYHāďÖqēŭOÏëdLü\\it^c®Rʺ¶¢H°mrY£B¹čIoľu¶uI]vģSQ{UŻÅ}QÂ|̰ƅ¤ĩŪU ęĄÌZÒ\\v²PĔ»ƢNHĂyAmƂwVm`]ÈbH`Ì¢²ILvĜH®¤Dlt_¢JJÄämèÔDëþgºƫaʎÌrêYi~ ÎݤNpÀA¾Ĕ¼b ð÷®üszMzÖĖQdȨýv§Tè|ªHþa¸|Ð ƒwKĢx¦ivr^ÿ ¸l öæfƟĴ·PJv}n\\h¹¶v·À|\\ƁĚN´ĜçèÁz]ġ¤²¨QÒŨTIlªťØ}¼˗ƦvÄùØE«FïËIqōTvāÜŏíÛßÛVj³âwGăÂíNOPìyV³ʼnĖýZso§HÑiYw[ß\\X¦¥c]ÔƩÜ·«jÐqvÁ¦m^ċ±R¦ƈťĚgÀ»IïĨʗƮ°ƝĻþÍAƉſ±tÍEÕÞāNUÍ¡\\ſčåÒʻĘm ƭÌŹöʥëQ¤µÇcƕªoIýIÉ_mkl³ăƓ¦j¡YzŇi}Msßõīʋ }ÁVm_[n}eıUĥ¼ªI{ΧDÓƻėojqYhĹT©oūĶ£]ďxĩǑMĝq`B´ƃ˺Чç~²ņj@¥@đ´ί}ĥtPńǾV¬ufÓÉCtÓ̻ ¹£G³]ƖƾŎĪŪĘ̖¨ʈĢƂlɘ۪üºňUðǜȢƢż̌ȦǼĤŊɲĖÂKq´ï¦ºĒDzņɾªǀÞĈĂD½ĄĎÌŗĞrôñnN¼â¾ʄľԆ|DŽ֦ज़ȗlj̘̭ɺƅêgV̍ʆĠ·ÌĊv|ýĖÕWĊǎÞ´õ¼cÒÒBĢ͢UĜð͒s¨ňƃLĉÕÝ@ɛƯ÷¿ĽĹeȏijëCȚDŲyê×Ŗyò¯ļcÂßY tÁƤyAã˾J@ǝrý@¤ rz¸oP¹ɐÚyáHĀ[Jw cVeȴÏ»ÈĖ}ƒŰŐèȭǢόĀƪÈŶë;Ñ̆ȤМľĮEŔĹŊũ~ËUă{ĻƹɁύȩþĽvĽƓÉ@ēĽɲßǐƫʾǗĒpäWÐxnsÀ^ƆwW©¦cÅ¡Ji§vúF¶¨c~c¼īeXǚ\\đ¾JwÀďksãAfÕ¦L}waoZD½Ml«]eÒÅaɲáo½FõÛ]ĻÒ¡wYR£¢rvÓ®y®LFLzĈôe]gx}|KK}xklL]c¦£fRtív¦PĤoH{tK" + ] + ], + "encodeOffsets": [[[108619, 36299]], [[108589, 36341]]] + } + }, + { + "type": "Feature", + "id": "630000", + "properties": { "id": "630000", "cp": [96.778916, 35.623178], "name": "青海", "childNum": 2 }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@InJm"], + [ + "@@CƽOŃĦsΰ~dz¦@@Ņi±è}ШƄ˹A³r_ĞǒNĪĐw¤^ŬĵªpĺSZgrpiƼĘÔ¨C|ÍJ©Ħ»®VIJ~f\\m `UnÂ~ʌĬàöNt~ňjy¢ZiƔ¥Ąk´nl`JÊJþ©pdƖ®È£¶ìRʦźõƮËnʼėæÑƀĎ[¢VÎĂMÖÝÎF²sƊƀÎBļýƞ¯ʘƭðħ¼Jh¿ŦęΌƇ¥²Q]Č¥nuÂÏri¸¬ƪÛ^Ó¦d¥[Wà x\\ZjÒ¨GtpþYŊĕ´zUOëPîMĄÁxH´áiÜUàîÜŐĂÛSuŎrJð̬EFÁú×uÃÎkrĒ{V}İ«O_ÌËĬ©ÓŧSRѱ§Ģ£^ÂyèçěM³Ƃę{[¸¿u ºµ[gt£¸OƤĿéYõ·kĀq]juw¥DĩƍõÇPéĽG©ã¤G uȧþRcÕĕNyyûtøï»a½ē¿BMoį£Íj}éZËqbʍƬh¹ìÿÓAçãnIáI`ks£CGěUy×Cy @¶ʡÊBnāzGơMē¼±O÷õJËĚăVĪũƆ£¯{ËL½ÌzżVR|ĠTbuvJvµhĻĖHAëáa OÇðñęNw œľ·LmI±íĠĩPÉ×®ÿscB³±JKßĊ«` ađ»·QAmOVţéÿ¤¹SQt]]Çx±¯A@ĉij¢Óļ©l¶ÅÛrŕspãRk~¦ª]Į´FRådČsCqđéFn¿ÅƃmÉx{W©ºƝºįkÕƂƑ¸wWūЩÈF£\\tÈ¥ÄRÈýÌJ lGr^×äùyÞ³fjc¨£ÂZ|ǓMĝÏ@ëÜőRĝ÷¡{aïȷPu°ËXÙ{©TmĠ}Y³ÞIňµç½©C¡į÷¯B»|St»]vųs»}MÓ ÿʪƟǭA¡fs»PY¼c¡»¦cċ¥£~msĉPSi^o©AecPeǵkgyUi¿h}aHĉ^|á´¡HØûÅ«ĉ®]m¡qĉ¶³ÈyôōLÁstB®wn±ă¥HSòė£Së@לÊăxÇN©©T±ª£IJ¡fb®Þbb_Ą¥xu¥B{łĝ³«`dƐt¤ťiñÍUuºí`£^tƃIJc·ÛLO½sç¥Ts{ă\\_»kϱq©čiìĉ|ÍI¥ć¥]ª§D{ŝŖÉR_sÿc³ĪōƿΧp[ĉc¯bKmR¥{³Ze^wx¹dƽŽôIg §Mĕ ƹĴ¿ǣÜÍ]Ý]snåA{eƭ`ǻŊĿ\\ijŬűYÂÿ¬jĖqßb¸L«¸©@ěĀ©ê¶ìÀEH|´bRľÓ¶rÀQþvl®ÕETzÜdb hw¤{LRdcb¯ÙVgƜßzÃôì®^jUèXÎ|UäÌ»rK\\ªN¼pZCüVY¤ɃRi^rPŇTÖ}|br°qňb̰ªiƶGQ¾²x¦PmlŜ[Ĥ¡ΞsĦÔÏâ\\ªÚŒU\\f ¢N²§x|¤§xĔsZPòʛ²SÐqF`ªVÞŜĶƨVZÌL`¢dŐIqr\\oäõF礻Ŷ×h¹]ClÙ\\¦ďÌį¬řtTӺƙgQÇÓHţĒ´ÃbEÄlbʔC|CŮkƮ[ʼ¬ň´KŮÈΰÌζƶlðļATUvdTGº̼ÔsÊDÔveOg" + ] + ], + "encodeOffsets": [[[105308, 37219]], [[95370, 40081]]] + } + }, + { + "type": "Feature", + "id": "640000", + "properties": { "id": "640000", "cp": [106.278179, 37.26637], "name": "宁夏", "childNum": 2 }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + "@@KëÀęĞ«Oęȿȕı]ʼn¡åįÕÔ«ǴõƪĚQÐZhv K°öqÀÑS[ÃÖHƖčËnL]ûc Ùß@ĝ¾}w»»oģF¹»kÌÏ·{zP§B¢íyÅt@@á]Yv_ssģ¼ißĻL¾ġsKD£¡N_ X¸}B~HaiÅf{«x»ge_bsKF¯¡IxmELcÿZ¤ĢÝsuBLùtYdmVtNmtOPhRw~bd ¾qÐ\\âÙH\\bImlNZ»loqlVmGā§~QCw¤{A\\PKNY¯bFkC¥sks_Ã\\ă«¢ħkJi¯rrAhĹûç£CUĕĊ_ÔBixÅÙĄnªÑaM~ħpOu¥sîeQ¥¤^dkKwlL~{L~hw^ófćKyEKzuÔ¡qQ¤xZÑ¢^ļöܾEp±âbÊÑÆ^fk¬ NC¾YpxbK~¥eÖäBlt¿Đx½I[ĒǙWf»Ĭ}d§dµùEuj¨IÆ¢¥dXªƅx¿]mtÏwßRĶX¢͎vÆzƂZò®ǢÌʆCrâºMÞzÆMÒÊÓŊZľr°Î®Ȉmª²ĈUªĚîøºĮ¦ÌĘk^FłĬhĚiĀ˾iİbjÕ" + ], + ["@@mfwěwMrŢªv@G"] + ], + "encodeOffsets": [[[109366, 40242]], [[108600, 36303]]] + } + }, + { + "type": "Feature", + "id": "650000", + "properties": { "id": "650000", "cp": [85.617733, 40.792818], "name": "新疆", "childNum": 1 }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@QØĔ²X¨~ǘBºjʐߨvKƔX¨vĊO÷¢i@~cĝe_«E}QxgɪëÏÃ@sÅyXoŖ{ô«ŸuX êÎf`C¹ÂÿÐGĮÕĞXŪōŸMźÈƺQèĽôe|¿ƸJR¤ĘEjcUóº¯Ĩ_ŘÁMª÷Ð¥OéÈ¿ÖğǤǷÂFÒzÉx[]Ĥĝœ¦EP}ûƥé¿İƷTėƫœŕƅƱB»Đ±ēO ¦E}`cȺrĦáŖuÒª«IJπdƺÏØZƴwʄ¤ĖGĐǂZĶèH¶}ÚZצʥĪï|ÇĦMŔ»İĝLjì¥Βba¯¥ǕǚkĆŵĦɑĺƯxūД̵nơʃĽá½M»òmqóŘĝč˾ăC ćāƿÝɽ©DZҹđ¥³ðLrÁ®ɱĕģʼnǻ̋ȥơŻǛȡVï¹Ň۩ûkɗġƁ§ʇė̕ĩũƽō^ƕUv£ƁQïƵkŏ½ΉÃŭdzLŇʻ«ƭ\\lŭD{ʓDkaFÃÄa³ŤđÔGRÈƚhSӹŚsİ«ĐË[¥ÚDkº^Øg¼ŵ¸£EÍöůʼnT¡c_ËKYƧUśĵÝU_©rETÏʜ±OñtYwē¨{£¨uM³x½şL©Ùá[ÓÐĥ Νtģ¢\\śnkOw¥±T»ƷFɯàĩÞáB¹Æ ÑUwŕĽw[mG½Èå~Æ÷QyěCFmĭZīŵVÁƿQƛûXS²b½KϽĉS©ŷXĕ{ĕK·¥Ɨcqq©f¿]ßDõU³hgËÇïģÉɋwk¯í}I·œbmÉřīJɥĻˁ×xoɹīlc ¤³Xù]DžA¿w͉ì¥wÇN·ÂËnƾƍdǧđ®ƝvUm©³G\\}µĿQyŹlăµEwLJQ½yƋBe¶ŋÀůo¥AÉw@{Gpm¿AijŽKLh³`ñcËtW±»ÕSëüÿďDu\\wwwù³VLŕOMËGh£õP¡erÏd{ġWÁ č|yšg^ğyÁzÙs`s|ÉåªÇ}m¢Ń¨`x¥ù^}Ì¥H«YªƅAйn~ź¯f¤áÀzgÇDIÔ´AňĀÒ¶ûEYospõD[{ù°]uJqU|Soċxţ[õÔĥkŋÞŭZ˺óYËüċrw ÞkrťË¿XGÉbřaDü·Ē÷Aê[ÄäI®BÕĐÞ_¢āĠpÛÄȉĖġDKwbmÄNôfƫVÉvidzHQµâFùœ³¦{YGd¢ĚÜO {Ö¦ÞÍÀP^bƾl[vt×ĈÍE˨¡Đ~´î¸ùÎhuè`¸HÕŔVºwĠââWò@{ÙNÝ´ə²ȕn{¿¥{l÷eé^eďXj©î\\ªÑòÜìc\\üqÕ[Č¡xoÂċªbØø|¶ȴZdÆÂońéG\\¼C°ÌÆn´nxÊOĨŪƴĸ¢¸òTxÊǪMīĞÖŲÃɎOvʦƢ~FRěò¿ġ~åŊúN¸qĘ[Ĕ¶ÂćnÒPĒÜvúĀÊbÖ{Äî¸~Ŕünp¤ÂH¾ĄYÒ©ÊfºmÔĘcDoĬMŬS¤s²ʘÚžȂVŦ èW°ªB|IJXŔþÈJĦÆæFĚêYĂªĂ]øªŖNÞüAfɨJ¯ÎrDDĤ`mz\\§~D¬{vJ«lµĂb¤pŌŰNĄ¨ĊXW|ų ¿¾ɄĦƐMTòP÷fØĶK¢ȝ˔Sô¹òEð`Ɩ½ǒÂň×äı§ĤƝ§C~¡hlåǺŦŞkâ~}FøàIJaĞfƠ¥Ŕd®U¸źXv¢aƆúŪtŠųƠjdƺƺÅìnrh\\ĺ¯äɝĦ]èpĄ¦´LƞĬ´ƤǬ˼Ēɸ¤rºǼ²¨zÌPðŀbþ¹ļD¢¹\\ĜÑŚ¶ZƄ³àjĨoâȴLÊȮĐĚăÀêZǚŐ¤qȂ\\L¢ŌİfÆs|zºeªÙæ§{Ā´ƐÚ¬¨Ĵà²łhʺKÞºÖTiƢ¾ªì°`öøu®Ê¾ãØ" + ], + "encodeOffsets": [[88824, 50096]] + } + }, + { + "type": "Feature", + "id": "110000", + "properties": { + "id": "110000", + "cp": [116.405285, 39.904989], + "name": "北京", + "childNum": 1 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@ĽOÁûtŷmiÍt_H»Ĩ±d`¹{bw Yr³S]§§o¹qGtm_SŧoaFLgQN_dV@Zom_ć\\ßc±x¯oœRcfe £o§ËgToÛJíĔóu |wP¤XnO¢ÉŦ¯rNÄā¤zâŖÈRpŢZÚ{GrFt¦Òx§ø¹RóäV¤XdżâºWbwڍUd®bêņ¾jnŎGŃŶnzÚSeîĜZczî¾i]ÍQaúÍÔiþĩȨWĢü|Ėu[qb[swP@ÅğP¿{\\¥A¨ÏѨj¯X\\¯MKpA³[H īu}}" + ], + "encodeOffsets": [[120023, 41045]] + } + }, + { + "type": "Feature", + "id": "120000", + "properties": { + "id": "120000", + "cp": [117.190182, 39.125596], + "name": "天津", + "childNum": 1 + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + "@@ŬgX§Ü«E ¶F̬O_ïlÁgz±AXeµÄĵ{¶]gitgIj·¥îakS¨ÐƎk}ĕ{gBqGf{¿aU^fIư³õ{YıëNĿk©ïËZŏR§òoY×Ógc ĥs¡bġ«@dekąI[nlPqCnp{ō³°`{PNdƗqSÄĻNNâyj]äÒD ĬH°Æ]~¡HO¾X}ÐxgpgWrDGpù^LrzWxZ^¨´T\\|~@IzbĤjeĊªz£®ĔvěLmV¾Ô_ÈNW~zbĬvG²ZmDM~~" + ], + "encodeOffsets": [[120237, 41215]] + } + }, + { + "type": "Feature", + "id": "310000", + "properties": { + "id": "310000", + "cp": [121.472644, 31.231706], + "name": "上海", + "childNum": 6 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@ɧư¬EpƸÁxc"], + ["@@©ª"], + ["@@MA"], + ["@@QpİE§ÉC¾"], + ["@@bŝÕÕEȣÚƥêImɇǦèÜĠÚÃƌÃ͎ó"], + ["@@ǜûȬɋŭ×^sYɍDŋŽąñCG²«ªč@h_p¯A{oloY¬j@IJ`gQÚhr|ǀ^MIJvtbe´R¯Ô¬¨Yô¤r]ìƬį"] + ], + "encodeOffsets": [ + [[124702, 32062]], + [[124547, 32200]], + [[124808, 31991]], + [[124726, 32110]], + [[124903, 32376]], + [[124438, 32149]] + ] + } + }, + { + "type": "Feature", + "id": "500000", + "properties": { + "id": "500000", + "cp": [107.304962, 29.533155], + "name": "重庆", + "childNum": 2 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + "@@vjG~nGŘŬĶȂƀƾ¹¸ØÎezĆT¸}êÐqHðqĖä¥^CÆIj²p \\_ æüY|[YxƊæu°xb® Űb@~¢NQt°¶Sæ Ê~rljĔëĚ¢~uf`faĔJåĊnÖ]jƎćÊ@£¾a®£Ű{ŶĕFègLk{Y|¡ĜWƔtƬJÑxq±ĢN´òKLÈüD|s`ŋć]Ã`đMûƱ½~Y°ħ`ƏíW½eI½{aOIrÏ¡ĕŇapµÜƅġ^ÖÛbÙŽŏml½SêqDu[RãË»ÿw`»y¸_ĺę}÷`M¯ċfCVµqʼn÷Zgg`d½pDOÎCn^uf²ènh¼WtƏxRGg¦ pVFI±G^Ic´ecGĹÞ½sëĬhxW}KÓeXsbkF¦LØgTkïƵNï¶}Gyw\\oñ¡nmĈzj@Óc£»Wă¹Ój_m»¹·~MvÛaq»ê\\ÂoVnÓØÍ²«bq¿efE Ĝ^Q~ Évýş¤²ĮpEİ}zcĺL½¿gÅ¡ýE¡ya£³t\\¨\\vú»¼§·Ñr_oÒý¥u_n»_At©Þűā§IVeëY}{VPÀFA¨ąB}q@|Ou\\FmQFÝ Mwå}]|FmÏCawu_p¯sfÙgY DHl`{QEfNysB¦zG¸rHeN\\CvEsÐùÜ_·ÖĉsaQ¯}_UxÃđqNH¬Äd^ÝŰR¬ã°wećJE·vÝ·HgéFXjÉê`|ypxkAwWĐpb¥eOsmzwqChóUQl¥F^lafanòsrEvfQdÁUVfÎvÜ^eftET¬ôA\\¢sJnQTjPØxøK|nBzĞ»LY FDxÓvr[ehľvN¢o¾NiÂxGpâ¬zbfZo~hGi]öF||NbtOMn eA±tPTLjpYQ|SHYĀxinzDJÌg¢và¥Pg_ÇzIIII£®S¬Øsμ£N" + ], + ["@@ifjN@s"] + ], + "encodeOffsets": [[[109628, 30765]], [[111725, 31320]]] + } + }, + { + "type": "Feature", + "id": "810000", + "properties": { + "id": "810000", + "cp": [114.173355, 22.320048], + "name": "香港", + "childNum": 5 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + ["@@AlBk"], + ["@@mn"], + ["@@EpFo"], + ["@@ea¢pl¸Eõ¹hj[]ÔCÎ@lj¡uBX ´AI¹ [yDU]W`çwZkmc MpÅv}IoJlcafŃK°ä¬XJmÐ đhI®æÔtSHnEÒrÈc"], + ["@@rMUwAS®e"] + ], + "encodeOffsets": [ + [[117111, 23002]], + [[117072, 22876]], + [[117045, 22887]], + [[116975, 23082]], + [[116882, 22747]] + ] + } + }, + { + "type": "Feature", + "id": "820000", + "properties": { "id": "820000", "cp": [113.54909, 22.198951], "name": "澳门", "childNum": 1 }, + "geometry": { + "type": "Polygon", + "coordinates": ["@@kÊd°å§s"], + "encodeOffsets": [[116279, 22639]] + } + } + ], + "UTF8Encoding": true +} diff --git a/src/assets/svgs/403.svg b/src/assets/svgs/403.svg new file mode 100644 index 0000000..4500596 --- /dev/null +++ b/src/assets/svgs/403.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" enable-background="new 0 0 800 800"><style>.st26{fill:#fff}</style><g id="图层_11"><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="401.773" y1="162.104" x2="401.773" y2="717.596"><stop offset="0" stop-color="#F4F2FB"/><stop offset="1" stop-color="#E1EEF5"/></linearGradient><path d="M485.03 203.46c-38.37 30.29-120.74 33.81-181.17-2.22s-172-31.38-202.22 34.87 37.19 131.33 12.78 178.98S8.66 530.13 64.45 611.49s126.6 60.62 169.22 52.45c84.17-16.13 189.79 115.67 308.62 16.13 68.47-57.35 170.44 42.09 210.17-81.36 32.78-101.86-85.67-139.5-49.97-208.03 37.96-72.88 30.67-159.24-10.46-201.06-38.31-38.96-140.75-38.46-207 13.84z" style="fill:url(#SVGID_1_)"/><linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="494.782" y1="599.604" x2="494.782" y2="428.659"><stop offset=".34" stop-color="#B0B9E1"/><stop offset=".866" stop-color="#EAF0F8"/></linearGradient><path d="M406.65 428.66h216.44l-22.53 49.03s59.19 57.87-14.13 121.91c-134.28-44.17-221.74-37.1-219.98-38.87 1.77-1.76 40.2-132.07 40.2-132.07z" style="fill:url(#SVGID_2_)"/><linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="116.855" y1="542.49" x2="116.855" y2="405.316"><stop offset=".227" stop-color="#B7ACE0"/><stop offset=".789" stop-color="#E8E7FA"/></linearGradient><path d="M117.64 405.56s-.22-.57-.52.04c-2.7 5.49-27.15 64.96-29.09 110.86 0 0-4.08 26.37 30.11 26.02 28.54-.29 27.78-24.6 27.68-32.79-.39-33.22-28.18-104.13-28.18-104.13z" style="fill:url(#SVGID_3_)"/><linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="116.857" y1="420.547" x2="116.857" y2="571.681"><stop offset="0" stop-color="#ECF1FB"/><stop offset=".818" stop-color="#AFB0E7"/></linearGradient><path d="M116.86 571.68c-.55 0-1-.45-1-1V421.55c0-.55.45-1 1-1s1 .45 1 1v149.13c0 .55-.45 1-1 1z" style="fill:url(#SVGID_4_)"/><linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="617.984" y1="450.968" x2="617.984" y2="362.644"><stop offset=".227" stop-color="#CCD4F4"/><stop offset=".789" stop-color="#ECF1FB"/></linearGradient><path d="M618.49 362.8s-.14-.37-.33.03c-1.74 3.53-17.48 41.83-18.73 71.38 0 0-2.63 16.98 19.39 16.76 18.38-.18 17.89-15.84 17.82-21.11-.25-21.4-18.15-67.06-18.15-67.06z" style="fill:url(#SVGID_5_)"/><linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="617.985" y1="372.451" x2="617.985" y2="469.764"><stop offset="0" stop-color="#ECF1FB"/><stop offset="1" stop-color="#A6A8E2"/></linearGradient><path d="M617.99 469.76c-.36 0-.64-.29-.64-.64V373.1c0-.36.29-.64.64-.64s.64.29.64.64v96.02c0 .36-.29.64-.64.64z" style="fill:url(#SVGID_6_)"/><linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="463.902" y1="88.362" x2="429.148" y2="148.558"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><circle cx="446.52" cy="118.46" r="34.75" style="fill:url(#SVGID_7_)"/><linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="421.565" y1="118.828" x2="421.565" y2="176.282"><stop offset="0" stop-color="#F9FAFE"/><stop offset="1" stop-color="#E5EDF7"/></linearGradient><path d="M466.3 137.41h-34.57c-2.23-10.61-11.65-18.58-22.93-18.58s-20.69 7.97-22.93 18.58h-9.05c-10.73 0-19.44 8.7-19.44 19.44 0 10.73 8.7 19.44 19.44 19.44h89.47c10.73 0 19.44-8.7 19.44-19.44.01-10.74-8.69-19.44-19.43-19.44z" style="fill:url(#SVGID_8_)"/><g><linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="688.586" y1="540.208" x2="688.586" y2="512.38"><stop offset=".227" stop-color="#AFB0E7"/><stop offset="1" stop-color="#ECF1FB"/></linearGradient><circle cx="688.59" cy="526.29" r="13.91" style="fill:url(#SVGID_9_)"/><linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="688.635" y1="515.894" x2="688.635" y2="560.69"><stop offset="0" stop-color="#DDE1F6"/><stop offset=".818" stop-color="#A6A8E2"/></linearGradient><path d="M688.64 560.69c-.24 0-.43-.19-.43-.43v-43.94c0-.24.19-.43.43-.43s.43.19.43.43v43.94a.44.44 0 01-.43.43z" style="fill:url(#SVGID_10_)"/></g><g><linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="2622.045" y1="266.481" x2="2451.058" y2="562.64" gradientTransform="matrix(-1 0 0 1 2941.346 0)"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M248.82 393.99c0-24.52-.03-49.03.01-73.54.02-14.37 4.24-18.36 17.97-20.53 41.87-6.61 82.03-18.72 117.91-42.29 10.38-6.82 18.3-7.59 29.06-.47 34.85 23.06 73.26 37.11 114.55 42.8 13.12 1.81 16.84 5.88 16.85 19.25.04 45.72-.4 91.44.18 137.15.34 26.77-8.17 49.99-24.02 70.73-31.46 41.17-74.88 63.76-122.21 80.03-2.5.86-5.83.67-8.36-.23-38.47-13.74-74.58-31.84-104.15-61.09-22.97-22.73-37.84-49.56-37.79-83.22.03-22.87.01-45.73 0-68.59z" style="fill:url(#SVGID_11_)"/><linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="2625.25" y1="279.944" x2="2462.749" y2="561.403" gradientTransform="matrix(-1 0 0 1 2941.346 0)"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M247.94 401.44c0-23.21-.03-46.42.01-69.63.02-13.61 4.06-17.38 17.23-19.43 40.15-6.26 78.67-17.72 113.07-40.04 9.95-6.46 17.55-7.18 27.86-.44 33.42 21.83 70.25 35.14 109.84 40.52 12.58 1.71 16.14 5.56 16.15 18.22.03 43.28-.38 86.57.18 129.84.33 25.34-7.83 47.33-23.03 66.96-30.17 38.98-71.81 60.36-117.19 75.77-2.4.81-5.59.64-8.01-.22-36.89-13.01-71.52-30.14-99.87-57.84-22.03-21.52-36.28-46.91-36.23-78.78.02-21.65-.01-43.29-.01-64.93z" style="fill:url(#SVGID_12_)"/><linearGradient id="SVGID_13_" gradientUnits="userSpaceOnUse" x1="361.421" y1="346.477" x2="449.513" y2="499.057"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M411.59 435.75c23.18-5.61 40.41-26.11 40.41-50.49 0-28.68-23.85-52.01-53.17-52.01s-53.17 23.33-53.17 52.01c0 24.38 17.24 44.88 40.41 50.49v85.2h25.52v-36.38h32.67v-24.96h-32.67v-23.86zm-40.41-50.49c0-14.91 12.41-27.05 27.65-27.05s27.65 12.14 27.65 27.05-12.41 27.05-27.65 27.05-27.65-12.14-27.65-27.05z" style="fill:url(#SVGID_13_)"/><path class="st26" d="M407.67 439.03c21.8-5.39 38.01-25.1 38.01-48.54 0-27.58-22.43-50.01-50.01-50.01s-50.01 22.43-50.01 50.01c0 23.44 16.21 43.15 38.01 48.54v81.92h24v-34.98h30.73v-24h-30.73v-22.94zm-38.01-48.55c0-14.34 11.67-26.01 26.01-26.01s26.01 11.67 26.01 26.01-11.67 26.01-26.01 26.01-26.01-11.67-26.01-26.01z"/><linearGradient id="SVGID_14_" gradientUnits="userSpaceOnUse" x1="484.836" y1="475.674" x2="565.754" y2="615.828"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><circle cx="525.3" cy="545.75" r="80.9" style="fill:url(#SVGID_14_)"/><linearGradient id="SVGID_15_" gradientUnits="userSpaceOnUse" x1="482.787" y1="483.323" x2="559.605" y2="616.376"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#C6D5F4"/></linearGradient><circle cx="521.2" cy="549.85" r="76.81" style="fill:url(#SVGID_15_)"/><path class="st26" d="M538.5 547.62l23.01-23.01c4.44-4.44 4.44-11.63 0-16.06-4.44-4.44-11.63-4.44-16.06 0l-23.01 23.01-23.01-23.01c-4.44-4.44-11.63-4.44-16.06 0-4.44 4.44-4.44 11.63 0 16.06l23.01 23.01-23.01 23.01c-4.44 4.44-4.44 11.63 0 16.06 2.22 2.22 5.13 3.33 8.03 3.33 2.91 0 5.81-1.11 8.03-3.33l23.01-23.01 23.01 23.01c2.22 2.22 5.13 3.33 8.03 3.33s5.81-1.11 8.03-3.33c4.44-4.44 4.44-11.63 0-16.06l-23.01-23.01z"/></g><g><linearGradient id="SVGID_16_" gradientUnits="userSpaceOnUse" x1="232.569" y1="558.709" x2="232.569" y2="484.191"><stop offset="0" stop-color="#C3D5FD"/><stop offset="1" stop-color="#1A90FC"/></linearGradient><path d="M224.88 484.54s-18.08-2.5-23.95 5.81-8.02 29.58-8.02 29.58l13.61-.72-1.15 24.78 25.11 14.72 35.77-19.24-5.44-22.45 11.43-2.98s-3.4-32.58-19.31-27.77c-8.17.87-10.74.73-10.74.73s-2.15 6.85-9.53 6.27c-7.38-.59-7.78-8.73-7.78-8.73z" style="fill:url(#SVGID_16_)"/><linearGradient id="SVGID_17_" gradientUnits="userSpaceOnUse" x1="233.602" y1="471.483" x2="233.602" y2="495.089"><stop offset="0" stop-color="#F4AE98"/><stop offset="1" stop-color="#FAD1BB"/></linearGradient><path d="M226.69 474.3l-3.76 16.76c-.18.79.23 1.59.98 1.89 1.94.79 5.83 2.13 9.82 2.13 4.15 0 8.06-2.27 9.86-3.48.62-.42.88-1.19.64-1.9l-5.75-17.09a1.643 1.643 0 00-1.86-1.1l-8.61 1.53c-.65.11-1.18.61-1.32 1.26z" style="fill:url(#SVGID_17_)"/><linearGradient id="SVGID_18_" gradientUnits="userSpaceOnUse" x1="-816.068" y1="920.854" x2="-804.529" y2="839.612" gradientTransform="rotate(-8.082 -2795.015 -6505.71)"><stop offset="0" stop-color="#C3D5FD"/><stop offset="1" stop-color="#1A90FC"/></linearGradient><path d="M204.24 487.44c5.26-1.75 12.4-.58 12.69 11.22s-11.28 30.62-7.13 37.16c4.2 6.63 13.17 16.05 18.89 21.41-1.33 6.3-4.91 11.61-4.91 11.61s-21.05-9.71-30.21-19.44c-9.17-9.73-4.54-32.03-.3-47.9 3.19-11.95 10.97-14.06 10.97-14.06z" style="fill:url(#SVGID_18_)"/><linearGradient id="SVGID_19_" gradientUnits="userSpaceOnUse" x1="-6575.898" y1="102.823" x2="-6564.359" y2="21.581" gradientTransform="scale(-1 1) rotate(-8.082 -118.103 -44396.273)"><stop offset="0" stop-color="#C3D5FD"/><stop offset="1" stop-color="#1A90FC"/></linearGradient><path d="M259.39 487.44c-5.26-1.75-12.4-.58-12.69 11.22s11.28 30.62 7.13 37.16c-4.2 6.63-13.17 16.05-18.89 21.41 1.33 6.3 4.91 11.61 4.91 11.61s21.05-9.71 30.21-19.44c9.17-9.73 4.54-32.03.3-47.9-3.19-11.95-10.97-14.06-10.97-14.06z" style="fill:url(#SVGID_19_)"/><linearGradient id="SVGID_20_" gradientUnits="userSpaceOnUse" x1="232.569" y1="531.798" x2="232.569" y2="579.152"><stop offset="0" stop-color="#275C89"/><stop offset="1" stop-color="#013F7C"/></linearGradient><path d="M206.79 579.15h51.1c2.31 0 4.38-1.75 5.19-4.4l10.3-33.89c1.34-4.4-1.33-9.07-5.19-9.07h-71.23c-3.82 0-6.48 4.6-5.21 8.98l9.84 33.89c.77 2.69 2.86 4.49 5.2 4.49z" style="fill:url(#SVGID_20_)"/><path class="st26" d="M204.75 594.74s-.79-1.74-1.4-1.93c-.61-.19-9.35-.54-12.53-1.36-3.19-.83-12.38-2.14-16.32 1.59-3.43 3.25-4.56 10.84.66 15.2 1.96 1.7 3.89 2.2 11.14 1.86 7.26-.34 17.78-.26 20.09-3.63-.07-5.55-1.64-11.73-1.64-11.73z"/><linearGradient id="SVGID_21_" gradientUnits="userSpaceOnUse" x1="-5720.751" y1="599.589" x2="-5703.986" y2="599.589" gradientTransform="matrix(-1 0 0 1 -5504.059 0)"><stop offset="0" stop-color="#F4B9A4"/><stop offset=".652" stop-color="#FAD1BB"/></linearGradient><path d="M212.86 592.81s-8.44 1.9-11.45 1.62-.49 11.87-.49 11.87 8.05.56 15.18-1.51c2.4-9.3-3.24-11.98-3.24-11.98z" style="fill:url(#SVGID_21_)"/><linearGradient id="SVGID_22_" gradientUnits="userSpaceOnUse" x1="209.839" y1="581.112" x2="296.322" y2="581.112"><stop offset="0" stop-color="#18264B"/><stop offset=".652" stop-color="#2D3C65"/></linearGradient><path d="M209.84 592.37l4.39 13.64s94.25-12.41 80.78-43c-11.27-25.57-85.17 29.36-85.17 29.36z" style="fill:url(#SVGID_22_)"/><linearGradient id="SVGID_23_" gradientUnits="userSpaceOnUse" x1="190.339" y1="591.445" x2="190.339" y2="609.24"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><path d="M203.66 593.42s3.45 1.35 3.89 6.17c.44 4.82-.99 8.05-8.33 8.94s-9.21.56-13.81.67-11.29.56-12.27-8.2c-.99-8.75 7.96-10.98 17.24-8.75 2.92.56 13.28 1.17 13.28 1.17z" style="fill:url(#SVGID_23_)"/><g><path class="st26" d="M263.56 594.74s.79-1.74 1.4-1.93c.61-.19 9.35-.54 12.53-1.36 3.19-.83 11.75-2.2 16.08 1.49 4.01 3.42 4.27 11-.29 15.18-1.96 1.7-4.02 2.32-11.28 1.98-7.26-.34-17.78-.26-20.09-3.63.09-5.55 1.65-11.73 1.65-11.73z"/><linearGradient id="SVGID_24_" gradientUnits="userSpaceOnUse" x1="251.623" y1="599.589" x2="268.387" y2="599.589"><stop offset="0" stop-color="#F4B9A4"/><stop offset=".652" stop-color="#FAD1BB"/></linearGradient><path d="M255.45 592.81s8.44 1.9 11.45 1.62.49 11.87.49 11.87-8.05.56-15.18-1.51c-2.4-9.3 3.24-11.98 3.24-11.98z" style="fill:url(#SVGID_24_)"/><linearGradient id="SVGID_25_" gradientUnits="userSpaceOnUse" x1="171.993" y1="581.112" x2="258.476" y2="581.112"><stop offset="0" stop-color="#445677"/><stop offset="1" stop-color="#293861"/></linearGradient><path d="M258.48 592.37L254.09 606s-94.25-12.41-80.78-43c11.26-25.56 85.17 29.37 85.17 29.37z" style="fill:url(#SVGID_25_)"/><linearGradient id="SVGID_26_" gradientUnits="userSpaceOnUse" x1="277.976" y1="591.445" x2="277.976" y2="609.24"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><path d="M264.66 593.42s-3.45 1.35-3.89 6.17.99 8.05 8.33 8.94c7.34.89 9.21.56 13.81.67s11.29.56 12.27-8.2c.99-8.75-7.96-10.98-17.24-8.75-2.92.56-13.28 1.17-13.28 1.17z" style="fill:url(#SVGID_26_)"/></g><linearGradient id="SVGID_27_" gradientUnits="userSpaceOnUse" x1="249.053" y1="466.067" x2="218.202" y2="466.067"><stop offset="0" stop-color="#F4B9A4"/><stop offset=".652" stop-color="#FAD1BB"/></linearGradient><path d="M248.39 467.6c.56-.8.91-2.84.46-3.44-.83-.67-1.61-.28-2.21.3.14-4.88-.31-8.94-.41-9.97-.3-2.99-3.35-8.48-13.3-8.48-9.95 0-11.88 7.18-11.88 7.18s-.65 5.08-.46 11.24c-.59-.57-1.37-.93-2.18-.27-.46.6-.1 2.64.46 3.44.56.8.91 2.69 1.02 3.74.1.99-.62 3.65 2 3.31 1.56 6.25 7.89 11.47 11.82 11.47 4.3 0 10.01-5.26 11.63-11.48 2.68.37 1.95-2.31 2.04-3.31.09-1.04.45-2.93 1.01-3.73z" style="fill:url(#SVGID_27_)"/><linearGradient id="SVGID_28_" gradientUnits="userSpaceOnUse" x1="213.957" y1="454.142" x2="249.774" y2="454.142"><stop offset="0" stop-color="#4F5C7C"/><stop offset="1" stop-color="#274168"/></linearGradient><path d="M240.1 443.88s-1.94-6.12-9.39-4.65c-7.44 1.46-7.95 4.98-10.87 5.12-4.99.23-8.97 6.45-2.58 13.03 2.85 2.93.44 4.19 1.79 6.78s1.34 5.12 1.34 5.12 2.38-7.6.81-10.84c-.81-1.67 2.77-2.13 7.24-1.73s11.51-1.08 12.06-4.12c1.32 6.23 2.64 6.88 4.31 7.83 1.68.95 1.78 8.48 1.78 8.48s.3-5.53 1.47-6.78c.96-2.04 2.85-10.07.72-12.02s-.32-8.19-8.68-6.22z" style="fill:url(#SVGID_28_)"/></g></g></svg> \ No newline at end of file diff --git a/src/assets/svgs/404.svg b/src/assets/svgs/404.svg new file mode 100644 index 0000000..5244d8d --- /dev/null +++ b/src/assets/svgs/404.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" enable-background="new 0 0 800 800"><style>.st49{fill:#d4e4fe}</style><g id="图层_5"><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="401.193" y1="159.763" x2="401.193" y2="715.254"><stop offset="0" stop-color="#F4F2FB"/><stop offset="1" stop-color="#E1EEF5"/></linearGradient><path d="M484.45 201.12c-38.37 30.29-120.74 33.81-181.17-2.22s-172-31.38-202.22 34.87 37.19 131.33 12.78 178.98S8.08 527.79 63.87 609.15s126.6 60.62 169.22 52.45c84.17-16.13 189.79 115.67 308.62 16.13 68.47-57.35 170.44 42.09 210.17-81.36 32.78-101.86-85.67-139.5-49.97-208.03 37.96-72.88 30.67-159.24-10.46-201.06-38.31-38.96-140.75-38.46-207 13.84z" style="fill:url(#SVGID_1_)"/><linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="484.537" y1="604.68" x2="484.537" y2="493.367"><stop offset=".34" stop-color="#B0B9E1"/><stop offset=".866" stop-color="#EAF0F8"/></linearGradient><path d="M285.1 583.44c1.77-1.63 77.74-90.07 77.74-90.07h321.13l-99.5 111.31-299.37-21.24z" style="fill:url(#SVGID_2_)"/><linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="616.023" y1="627.266" x2="657.332" y2="555.716"><stop offset="0" stop-color="#B0B9E1"/><stop offset=".866" stop-color="#EAF0F8"/></linearGradient><path d="M604.49 620.61L659.43 556.93 633.22 624.12z" style="fill:url(#SVGID_3_)"/><linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="116.275" y1="540.149" x2="116.275" y2="402.974"><stop offset=".003" stop-color="#9A9ADB"/><stop offset=".789" stop-color="#CECDF1"/></linearGradient><path d="M117.06 403.22s-.22-.57-.52.04c-2.7 5.49-27.15 64.96-29.09 110.86 0 0-4.08 26.37 30.11 26.02 28.54-.29 27.78-24.6 27.68-32.79-.39-33.22-28.18-104.13-28.18-104.13z" style="fill:url(#SVGID_4_)"/><linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="116.277" y1="418.206" x2="116.277" y2="569.34"><stop offset="0" stop-color="#ECF1FB"/><stop offset=".818" stop-color="#AFB0E7"/></linearGradient><path d="M116.28 569.34c-.55 0-1-.45-1-1V419.21c0-.55.45-1 1-1s1 .45 1 1v149.13c0 .55-.45 1-1 1z" style="fill:url(#SVGID_5_)"/><linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="617.404" y1="448.627" x2="617.404" y2="360.303"><stop offset=".227" stop-color="#CCD4F4"/><stop offset=".789" stop-color="#ECF1FB"/></linearGradient><path d="M617.91 360.46s-.14-.37-.33.03c-1.74 3.53-17.48 41.83-18.73 71.38 0 0-2.63 16.98 19.39 16.76 18.38-.18 17.89-15.84 17.82-21.11-.25-21.4-18.15-67.06-18.15-67.06z" style="fill:url(#SVGID_6_)"/><linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="617.405" y1="370.11" x2="617.405" y2="467.422"><stop offset="0" stop-color="#ECF1FB"/><stop offset="1" stop-color="#A6A8E2"/></linearGradient><path d="M617.41 467.42c-.36 0-.64-.29-.64-.64v-96.02c0-.36.29-.64.64-.64.36 0 .64.29.64.64v96.02c0 .35-.29.64-.64.64z" style="fill:url(#SVGID_7_)"/><linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="463.322" y1="86.02" x2="428.568" y2="146.217"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><circle cx="445.95" cy="116.12" r="34.75" style="fill:url(#SVGID_8_)"/><linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="420.985" y1="116.487" x2="420.985" y2="173.941"><stop offset="0" stop-color="#F9FAFE"/><stop offset="1" stop-color="#E5EDF7"/></linearGradient><path d="M465.72 135.07h-34.57c-2.23-10.61-11.65-18.58-22.93-18.58s-20.69 7.97-22.93 18.58h-9.05c-10.73 0-19.44 8.7-19.44 19.44 0 10.73 8.7 19.44 19.44 19.44h89.47c10.73 0 19.44-8.7 19.44-19.44.01-10.74-8.69-19.44-19.43-19.44z" style="fill:url(#SVGID_9_)"/><g><linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="688.006" y1="537.867" x2="688.006" y2="510.039"><stop offset=".227" stop-color="#AFB0E7"/><stop offset="1" stop-color="#ECF1FB"/></linearGradient><circle cx="688.01" cy="523.95" r="13.91" style="fill:url(#SVGID_10_)"/><linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="688.056" y1="513.553" x2="688.056" y2="558.349"><stop offset="0" stop-color="#DDE1F6"/><stop offset=".818" stop-color="#A6A8E2"/></linearGradient><path d="M688.06 558.35c-.24 0-.43-.19-.43-.43v-43.94c0-.24.19-.43.43-.43s.43.19.43.43v43.94a.44.44 0 01-.43.43z" style="fill:url(#SVGID_11_)"/></g><g><linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="2879.853" y1="308.382" x2="2737.462" y2="450.774" gradientTransform="matrix(-1 0 0 1 3207.18 0)"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M270.73 392.79l91.4-73.3c7.43 11.92 20.65 19.87 35.7 19.87 16.43 0 30.69-9.48 37.6-23.26l92.11 76.85 10.83-12.98-98.5-82.19c0-.16.01-.31.01-.47 0-23.18-18.86-42.04-42.05-42.04-23.18 0-42.04 18.86-42.04 42.04 0 1.8.13 3.58.35 5.32l-95.98 76.97 10.57 13.19zm101.96-95.48c0-13.86 11.28-25.14 25.14-25.14s25.14 11.28 25.14 25.14-11.28 25.14-25.14 25.14-25.14-11.27-25.14-25.14z" style="fill:url(#SVGID_12_)"/><linearGradient id="SVGID_13_" gradientUnits="userSpaceOnUse" x1="2814.247" y1="259.815" x2="2814.247" y2="392.836" gradientTransform="matrix(-1 0 0 1 3207.18 0)"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#C6D5F4"/></linearGradient><path d="M268.75 392.68l88.31-70.82c7.18 11.51 19.95 19.2 34.49 19.2 15.88 0 29.65-9.16 36.33-22.47l88.99 74.25 10.46-12.54-95.17-79.41c0-.15.01-.3.01-.46 0-22.4-18.22-40.62-40.62-40.62s-40.62 18.22-40.62 40.62c0 1.74.12 3.46.34 5.14l-92.73 74.37 10.21 12.74zm98.51-92.24c0-13.4 10.9-24.29 24.29-24.29 13.4 0 24.29 10.9 24.29 24.29 0 13.4-10.9 24.29-24.29 24.29-13.4 0-24.29-10.9-24.29-24.29z" style="fill:url(#SVGID_13_)"/><linearGradient id="SVGID_14_" gradientUnits="userSpaceOnUse" x1="2966.463" y1="329.794" x2="2654.707" y2="641.55" gradientTransform="matrix(-1 0 0 1 3203.43 0)"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M230.6 619.91h326.35c17.89 0 32.39-14.5 32.39-32.39V388.31c0-21.39-17.34-38.72-38.72-38.72H230.6c-17.89 0-32.39 14.5-32.39 32.39v205.54c-.01 17.88 14.5 32.39 32.39 32.39z" style="fill:url(#SVGID_14_)"/><linearGradient id="SVGID_15_" gradientUnits="userSpaceOnUse" x1="2716.773" y1="319.563" x2="2914.293" y2="661.678" gradientTransform="matrix(-1 0 0 1 3203.43 0)"><stop offset="0" stop-color="#EBF2FA"/><stop offset=".525" stop-color="#FDFEFF"/></linearGradient><path d="M223.6 619.91h328.59c14.03 0 25.4-11.37 25.4-25.4V386.73c0-14.03-11.37-25.4-25.4-25.4H223.6c-14.03 0-25.4 11.37-25.4 25.4v207.78c0 14.03 11.38 25.4 25.4 25.4z" style="fill:url(#SVGID_15_)"/><linearGradient id="SVGID_16_" gradientUnits="userSpaceOnUse" x1="2815.495" y1="361.334" x2="2815.495" y2="425.526" gradientTransform="matrix(-1 0 0 1 3203.43 0)"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M198.24 425.53h379.39v-38.79c0-14.03-11.37-25.4-25.4-25.4H223.64c-14.03 0-25.4 11.37-25.4 25.4v38.79z" style="fill:url(#SVGID_16_)"/><linearGradient id="SVGID_17_" gradientUnits="userSpaceOnUse" x1="276.445" y1="488.742" x2="350.685" y2="531.604"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M328.82 457.46H307.7c-1.27 0-2.46.59-3.24 1.59L261.91 514c-.56.72-.86 1.6-.86 2.51v23.15c0 2.26 1.83 4.09 4.09 4.09h41.34c2.26 0 4.09 1.83 4.09 4.09v13.46c0 2.26 1.83 4.09 4.09 4.09h14.14c2.26 0 4.09-1.83 4.09-4.09v-13.46c0-2.26 1.83-4.09 4.09-4.09s4.09-1.83 4.09-4.09V525.5c0-2.26-1.83-4.09-4.09-4.09s-4.09-1.83-4.09-4.09v-55.77a4.059 4.059 0 00-4.07-4.09zm-39.3 57.35l13.74-17.74c2.39-3.08 7.33-1.4 7.33 2.51v17.74c0 2.26-1.83 4.09-4.09 4.09h-13.74c-3.41 0-5.33-3.91-3.24-6.6z" style="fill:url(#SVGID_17_)"/><linearGradient id="SVGID_18_" gradientUnits="userSpaceOnUse" x1="455.095" y1="488.742" x2="529.335" y2="531.604"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M511.56 517.32v-55.77c0-2.26-1.83-4.09-4.09-4.09h-21.12c-1.27 0-2.46.59-3.24 1.59L440.56 514c-.56.72-.86 1.6-.86 2.51v23.15c0 2.26 1.83 4.09 4.09 4.09h41.34c2.26 0 4.09 1.83 4.09 4.09v13.46c0 2.26 1.83 4.09 4.09 4.09h14.14c2.26 0 4.09-1.83 4.09-4.09v-13.46c0-2.26 1.83-4.09 4.09-4.09s4.09-1.83 4.09-4.09V525.5c0-2.26-1.83-4.09-4.09-4.09-2.24 0-4.07-1.83-4.07-4.09zm-43.39-2.51l13.74-17.74c2.39-3.08 7.33-1.4 7.33 2.51v17.74c0 2.26-1.83 4.09-4.09 4.09H471.4c-3.4 0-5.32-3.91-3.23-6.6z" style="fill:url(#SVGID_18_)"/><linearGradient id="SVGID_19_" gradientUnits="userSpaceOnUse" x1="339.488" y1="482.174" x2="441.31" y2="540.961"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M356.4 566.16h68c2.26 0 4.09-1.83 4.09-4.09v-101c0-2.26-1.83-4.09-4.09-4.09h-68c-2.26 0-4.09 1.83-4.09 4.09v101c0 2.26 1.83 4.09 4.09 4.09zm49.76-82.76v56.34c0 2.26-1.83 4.09-4.09 4.09h-23.34c-2.26 0-4.09-1.83-4.09-4.09V483.4c0-2.26 1.83-4.09 4.09-4.09h23.34c2.26 0 4.09 1.83 4.09 4.09z" style="fill:url(#SVGID_19_)"/></g><g><linearGradient id="SVGID_20_" gradientUnits="userSpaceOnUse" x1="871.514" y1="4485.232" x2="872.065" y2="4498.77" gradientTransform="rotate(2.333 95904.663 -3670.234)"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><path d="M605.95 610.6s3.25 4.88 10.55 1.06c3.91 2.72 8.92 4.97 12.39 5.88 3.47.91 3.68 5.4 3.12 6.61-4.66-.47-18.14.64-27.3-2.94.72-7.53 1.24-10.61 1.24-10.61z" style="fill:url(#SVGID_20_)"/><path class="st49" d="M604.06 623.84l.43-3.23s10.54 2.63 28.38 1.03c.17 1.66.35 2.48.35 2.48s-13.56 2.02-29.16-.28z"/><linearGradient id="SVGID_21_" gradientUnits="userSpaceOnUse" x1="-1427.263" y1="-235.579" x2="-1409.896" y2="-215.318" gradientTransform="rotate(40.6 -1575.457 2818.52)"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><path d="M520.47 596.12s-.05 5.81 7.27 7.94c1.95 5-3.73 11.79 5.37 12.42 3.34.23 1.75 5.12.73 5.63-10.95 4.01-14.63-10.12-19.62-18.98 4.32-5.09 6.25-7.01 6.25-7.01z" style="fill:url(#SVGID_21_)"/><linearGradient id="SVGID_22_" gradientUnits="userSpaceOnUse" x1="-3772.01" y1="604.486" x2="-3772.01" y2="502.198" gradientTransform="matrix(-1 0 0 1 -3222.68 0)"><stop offset="0" stop-color="#445677"/><stop offset="1" stop-color="#293861"/></linearGradient><path d="M569.3 502.2s-14.44-.26-17.67 18.85c-3.23 19.11 1.57 23.66-5.38 37.29-3.62 7.1-27.15 41.12-27.15 41.12l6.83 5.03s37.94-34.72 43.52-48.71 9.83-28.83 10.13-41.46c.28-12.62-10.28-12.12-10.28-12.12z" style="fill:url(#SVGID_22_)"/><linearGradient id="SVGID_23_" gradientUnits="userSpaceOnUse" x1="-3839.642" y1="559.801" x2="-3786.238" y2="559.801" gradientTransform="matrix(-1 0 0 1 -3222.68 0)"><stop offset="0" stop-color="#445677"/><stop offset="1" stop-color="#293861"/></linearGradient><path d="M572.72 506.19s14.87 3.53 15.75 3.98c.44.23 2.89 7.07 5.24 13.95 5.04 6.87 23.02 32.28 23.21 45.51.29 20.13-.96 43.67-.96 43.67l-9.24.11s-3.5-38.9-5.85-42.31c-.42-.61-1.29-1.95-2.42-3.74-5.14-6.22-16.5-16.65-28.16-27.07-16.45-14.66 2.43-34.1 2.43-34.1z" style="fill:url(#SVGID_23_)"/><linearGradient id="SVGID_24_" gradientUnits="userSpaceOnUse" x1="5317.908" y1="132.095" x2="5317.908" y2="56.817" gradientTransform="rotate(26.086 2112.504 -9908.036)"><stop offset="0" stop-color="#C3D5FD"/><stop offset="1" stop-color="#1A90FC"/></linearGradient><path d="M603.14 448.91s-10.69-8.37-16.99-4.36c-6.3 4-14.27 18.91-14.27 18.91l8.85 4.38-23.8 39.67 40.69 21.83 14.6-42.28 11.79.69s7.96-25.24-3.62-27.43c-5.45-2.3-7.04-3.34-7.04-3.34s-3.49 4.27-7.99 1.18-2.22-9.25-2.22-9.25z" style="fill:url(#SVGID_24_)"/><linearGradient id="SVGID_25_" gradientUnits="userSpaceOnUse" x1="5161.945" y1="1134.369" x2="5171.26" y2="1068.78" gradientTransform="rotate(18.006 4848.87 -13687.47)"><stop offset="0" stop-color="#C3D5FD"/><stop offset="1" stop-color="#1A90FC"/></linearGradient><path d="M589.15 443.6c3.88.61 8.04 4.05 4.56 12.85-3.48 8.8-16.66 18.5-16.06 24.82.6 6.4 3.37 16.58 5.33 22.6-2.8 4.17-6.72 6.78-6.72 6.78s-10.33-14.75-13.12-25.23 7.07-25.25 14.69-35.41c5.73-7.67 11.32-6.41 11.32-6.41z" style="fill:url(#SVGID_25_)"/><linearGradient id="SVGID_26_" gradientUnits="userSpaceOnUse" x1="-8924.659" y1="-865.525" x2="-8915.544" y2="-929.706" gradientTransform="scale(-1 1) rotate(-34.172 -2504.53 -13720.806)"><stop offset="0" stop-color="#C3D5FD"/><stop offset="1" stop-color="#1A90FC"/></linearGradient><path d="M624.12 463.5c-2.79-3.19-7.68-4.9-11.53 3.69s-2.35 26.64-7.02 29.97c-4.72 3.37-13.34 7.07-18.62 8.96-1.12 5.12-.49 10.33-.49 10.33s16.36.44 25.19-3.42c8.83-3.86 12.82-21.97 15.06-35.2 1.69-9.97-2.59-14.33-2.59-14.33z" style="fill:url(#SVGID_26_)"/><linearGradient id="SVGID_27_" gradientUnits="userSpaceOnUse" x1="-3813.896" y1="480.898" x2="-3841.811" y2="423.883" gradientTransform="matrix(-1 0 0 1 -3222.68 0)"><stop offset="0" stop-color="#4F5C7C"/><stop offset="1" stop-color="#274168"/></linearGradient><path d="M590.9 439.68c.43-4.69 4.5-7.9 9.3-7.17.4-1.31 4.44-2.98 5.38-4.6 3.5-6.03 9.26-7 14-3.56 9.79 2.79 8.01 12.2 4.75 21.55 2.8 5.61 1.52 12.41-.06 15.18 4.75 5.07 2.09 11.58-1.39 16.52-.4.56-.82 1.06-1.25 1.52-.21 5.85-8.34 7.86-11.32 4.89-3.17-3.16-3.57-4.49-9.32-1.76-5.75 2.73-11.24-1.54-11.3-7.34-.06-5.8-4.28-4.1-6.12-5.63-3.33-2.77-1.15-5.93-1.15-5.93s-4.85-.26-6.01-7.38c-1.33-16.99 11.95-17.08 14.49-16.29z" style="fill:url(#SVGID_27_)"/><path class="st49" d="M515.38 601.24s4.92 12.03 5.91 13.61 5.9 9.27 14.26 5.05c-.04 1.49-.11 2.43-.11 2.43s-9.42 6.26-15.33-4.62c-5.91-10.88-6.75-14.63-6.75-14.63l2.02-1.84z"/></g></g></svg> \ No newline at end of file diff --git a/src/assets/svgs/500.svg b/src/assets/svgs/500.svg new file mode 100644 index 0000000..9c02092 --- /dev/null +++ b/src/assets/svgs/500.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" enable-background="new 0 0 800 800"><style>.st26{fill:#fff}</style><g id="图层_16"><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="402.832" y1="159.843" x2="402.832" y2="715.335"><stop offset="0" stop-color="#F4F2FB"/><stop offset="1" stop-color="#E1EEF5"/></linearGradient><path d="M486.09 201.2c-38.37 30.29-120.74 33.81-181.17-2.22s-172-31.38-202.22 34.87 37.19 131.33 12.78 178.98S9.72 527.87 65.5 609.23s126.6 60.62 169.22 52.45c84.17-16.13 189.79 115.67 308.62 16.13 68.47-57.35 170.44 42.09 210.17-81.36 32.78-101.86-85.67-139.5-49.97-208.03 37.96-72.88 30.67-159.24-10.46-201.06-38.3-38.96-140.75-38.46-206.99 13.84z" style="fill:url(#SVGID_1_)"/><linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="117.913" y1="540.229" x2="117.913" y2="403.055"><stop offset=".227" stop-color="#B7ACE0"/><stop offset=".789" stop-color="#E8E7FA"/></linearGradient><path d="M118.7 403.3s-.22-.57-.52.04c-2.7 5.49-27.15 64.96-29.09 110.86 0 0-4.08 26.37 30.11 26.02 28.54-.29 27.78-24.6 27.68-32.79-.39-33.22-28.18-104.13-28.18-104.13z" style="fill:url(#SVGID_2_)"/><linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="117.915" y1="418.287" x2="117.915" y2="569.42"><stop offset="0" stop-color="#ECF1FB"/><stop offset=".818" stop-color="#AFB0E7"/></linearGradient><path d="M117.92 569.42c-.55 0-1-.45-1-1V419.29c0-.55.45-1 1-1s1 .45 1 1v149.13c0 .55-.45 1-1 1z" style="fill:url(#SVGID_3_)"/><linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="619.042" y1="448.707" x2="619.042" y2="360.383"><stop offset=".227" stop-color="#CCD4F4"/><stop offset=".789" stop-color="#ECF1FB"/></linearGradient><path d="M619.55 360.54s-.14-.37-.33.03c-1.74 3.53-17.48 41.83-18.73 71.38 0 0-2.63 16.98 19.39 16.76 18.38-.18 17.89-15.84 17.82-21.11-.26-21.4-18.15-67.06-18.15-67.06z" style="fill:url(#SVGID_4_)"/><linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="619.043" y1="370.19" x2="619.043" y2="467.503"><stop offset="0" stop-color="#ECF1FB"/><stop offset="1" stop-color="#A6A8E2"/></linearGradient><path d="M619.04 467.5c-.36 0-.64-.29-.64-.64v-96.02c0-.36.29-.64.64-.64s.64.29.64.64v96.02c.01.35-.28.64-.64.64z" style="fill:url(#SVGID_5_)"/><linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="464.96" y1="86.101" x2="430.206" y2="146.297"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><circle cx="447.58" cy="116.2" r="34.75" style="fill:url(#SVGID_6_)"/><linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="422.623" y1="116.567" x2="422.623" y2="174.021"><stop offset="0" stop-color="#F9FAFE"/><stop offset="1" stop-color="#E5EDF7"/></linearGradient><path d="M467.36 135.15h-34.57c-2.23-10.61-11.65-18.58-22.93-18.58s-20.69 7.97-22.93 18.58h-9.05c-10.73 0-19.44 8.7-19.44 19.44 0 10.73 8.7 19.44 19.44 19.44h89.47c10.73 0 19.44-8.7 19.44-19.44.01-10.74-8.7-19.44-19.43-19.44z" style="fill:url(#SVGID_7_)"/><linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="689.644" y1="537.948" x2="689.644" y2="510.119"><stop offset=".227" stop-color="#AFB0E7"/><stop offset="1" stop-color="#ECF1FB"/></linearGradient><circle cx="689.64" cy="524.03" r="13.91" style="fill:url(#SVGID_8_)"/><linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="689.694" y1="513.633" x2="689.694" y2="558.429"><stop offset="0" stop-color="#DDE1F6"/><stop offset=".818" stop-color="#A6A8E2"/></linearGradient><path d="M689.69 558.43c-.24 0-.43-.19-.43-.43v-43.94c0-.24.19-.43.43-.43s.43.19.43.43V558c0 .24-.19.43-.43.43z" style="fill:url(#SVGID_9_)"/><linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="289.384" y1="477.19" x2="289.384" y2="411.226"><stop offset="0" stop-color="#B0B9E1"/><stop offset="1" stop-color="#E7EFF7"/></linearGradient><path d="M202.07 451.28L270.1 411.23 376.7 411.23 315.15 477.19 237.41 476.01z" style="fill:url(#SVGID_10_)"/><linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="454.145" y1="502.809" x2="454.145" y2="420.65"><stop offset="0" stop-color="#B0B9E1"/><stop offset="1" stop-color="#E7EFF7"/></linearGradient><path d="M386.71 479.55L431.76 420.65 521.58 420.65 423.81 502.81 394.37 495.15z" style="fill:url(#SVGID_11_)"/><linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="589.016" y1="472.132" x2="589.016" y2="397.68"><stop offset="0" stop-color="#B0B9E1"/><stop offset="1" stop-color="#E7EFF7"/></linearGradient><path d="M501.26 458.64l64.79-60.96h110.72l-48.99 66.61a19.243 19.243 0 01-17.85 7.7l-108.67-13.35z" style="fill:url(#SVGID_12_)"/><linearGradient id="SVGID_13_" gradientUnits="userSpaceOnUse" x1="314.267" y1="607.349" x2="314.267" y2="497.361"><stop offset="0" stop-color="#B0B9E1"/><stop offset="1" stop-color="#E7EFF7"/></linearGradient><path d="M212.23 592.77L303.67 497.36 416.3 497.36 297.04 607.35 247.57 604.7z" style="fill:url(#SVGID_13_)"/><g><linearGradient id="SVGID_14_" gradientUnits="userSpaceOnUse" x1="515.604" y1="312.867" x2="613.092" y2="481.721"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M564.35 296.53c-41.79 0-75.67 33.6-75.67 75.05v51.43c0 41.45 33.88 75.05 75.67 75.05s75.67-33.6 75.67-75.05v-51.43c-.01-41.45-33.88-75.05-75.67-75.05zm23.82 137.83c0 13.05-10.67 23.63-23.82 23.63-13.16 0-23.82-10.58-23.82-23.63v-74.13c0-13.05 10.67-23.63 23.82-23.63 13.16 0 23.82 10.58 23.82 23.63v74.13z" style="fill:url(#SVGID_14_)"/><linearGradient id="SVGID_15_" gradientUnits="userSpaceOnUse" x1="513.839" y1="321.619" x2="606.64" y2="482.355"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M560.24 305.91c-39.52 0-71.56 32.04-71.56 71.56v49.03c0 39.52 32.04 71.56 71.56 71.56s71.56-32.04 71.56-71.56v-49.03c0-39.52-32.04-71.56-71.56-71.56zm22.53 131.41c0 12.44-10.09 22.53-22.53 22.53-12.44 0-22.53-10.09-22.53-22.53v-70.67c0-12.44 10.09-22.53 22.53-22.53 12.44 0 22.53 10.09 22.53 22.53v70.67z" style="fill:url(#SVGID_15_)"/><linearGradient id="SVGID_16_" gradientUnits="userSpaceOnUse" x1="217.031" y1="307.363" x2="316.583" y2="479.793"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M333.72 412.6c-5.55-58.15-65.99-54.01-90.14-49.98l2.26-15.28 71.49 5.88 8.98-5.88V307.2h-109l-9.09 7.47-14.81 92.41h43.6c22.73-19.99 38.77-11.37 45.38 0 6.34 10.92 7.27 43.26-19.71 43.87-23.34.53-23.13-19.92-23.13-19.92l-41.55.58-8.06 7.52s6.18 59.41 69.73 59.41 77.3-50.09 74.05-85.94z" style="fill:url(#SVGID_16_)"/><linearGradient id="SVGID_17_" gradientUnits="userSpaceOnUse" x1="212.735" y1="311.982" x2="309.699" y2="479.928"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M324.26 415.94c-5.19-55.89-61.65-51.92-84.21-48.04l2.11-14.69h75.17v-38.58H208.14l-14.95 96h40.73c21.23-19.21 36.22-10.93 42.39 0 5.92 10.49 6.79 46.38-18.41 46.97-21.8.51-24.41-19.14-24.41-19.14l-43.54.66s5.78 59.41 65.14 59.41 72.2-48.14 69.17-82.59z" style="fill:url(#SVGID_17_)"/><linearGradient id="SVGID_18_" gradientUnits="userSpaceOnUse" x1="368.459" y1="304.731" x2="452.448" y2="450.205"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M387.26 461.26s-54.09-36.72-56.49-83.83c-2.29-45.03 25.47-81.27 76.27-81.27 55.29 0 78.12 47.95 78.12 73.99 0 26.04-10.63 63.25-55.73 93.35-23.53 0-42.17-2.24-42.17-2.24z" style="fill:url(#SVGID_18_)"/><linearGradient id="SVGID_19_" gradientUnits="userSpaceOnUse" x1="366.623" y1="312.428" x2="445.175" y2="448.483"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M384.76 461.29s-51.7-34.94-53.99-79.77c-2.19-42.85 24.35-77.34 72.9-77.34 52.85 0 73.47 45.54 73.47 70.32 0 24.78-12.03 58.72-55.14 87.36-22.49.01-37.24-.57-37.24-.57z" style="fill:url(#SVGID_19_)"/><linearGradient id="SVGID_20_" gradientUnits="userSpaceOnUse" x1="400.418" y1="454.748" x2="417.994" y2="485.191"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M414.59 486.78h-16.64c-.85 0-1.64-.44-2.08-1.17l-11.39-18.8c-.7-1.15-.33-2.64.82-3.34 1.15-.69 2.64-.33 3.34.82l10.68 17.62h13.84l10.6-19.05c.65-1.17 2.13-1.6 3.31-.94 1.17.65 1.6 2.13.94 3.31l-11.29 20.3c-.44.77-1.25 1.25-2.13 1.25z" style="fill:url(#SVGID_20_)"/><linearGradient id="SVGID_21_" gradientUnits="userSpaceOnUse" x1="397.841" y1="454.748" x2="415.417" y2="485.191"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M412.01 486.78h-16.64c-.85 0-1.64-.44-2.08-1.17l-11.39-18.8c-.7-1.15-.33-2.64.82-3.34 1.15-.69 2.64-.33 3.34.82l10.68 17.62h13.84l10.6-19.05c.65-1.17 2.13-1.6 3.31-.94 1.17.65 1.6 2.13.94 3.31l-11.29 20.3c-.43.77-1.25 1.25-2.13 1.25z" style="fill:url(#SVGID_21_)"/><linearGradient id="SVGID_22_" gradientUnits="userSpaceOnUse" x1="395.626" y1="441.888" x2="415.816" y2="476.856"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M429.22 468.35h-47.66c-2.76 0-5-2.24-5-5V452.9h57.65v10.45c0 2.76-2.23 5-4.99 5z" style="fill:url(#SVGID_22_)"/><linearGradient id="SVGID_23_" gradientUnits="userSpaceOnUse" x1="395.022" y1="445.756" x2="412.776" y2="476.507"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M425.57 468.35h-44.01c-2.76 0-5-2.24-5-5v-6.93h54.01v6.93c0 2.76-2.24 5-5 5z" style="fill:url(#SVGID_23_)"/><linearGradient id="SVGID_24_" gradientUnits="userSpaceOnUse" x1="396.171" y1="472.261" x2="416.697" y2="507.813"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M418.79 505.46h-25.7c-4.09 0-7.4-3.31-7.4-7.4v-19.75h40.5v19.75c0 4.09-3.31 7.4-7.4 7.4z" style="fill:url(#SVGID_24_)"/><linearGradient id="SVGID_25_" gradientUnits="userSpaceOnUse" x1="395.099" y1="476.159" x2="413.018" y2="507.195"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><path d="M414.04 505.46h-20.95c-4.09 0-7.4-3.31-7.4-7.4v-16.47h35.75v16.47c0 4.09-3.31 7.4-7.4 7.4z" style="fill:url(#SVGID_25_)"/><linearGradient id="SVGID_26_" gradientUnits="userSpaceOnUse" x1="370.752" y1="345.042" x2="439.366" y2="413.656"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M404.4 311.4s-17.23 79.51 1.33 135.9c47.84-62.43-1.33-135.9-1.33-135.9z" style="fill:url(#SVGID_26_)"/><linearGradient id="SVGID_27_" gradientUnits="userSpaceOnUse" x1="352.936" y1="350.49" x2="415.513" y2="413.067"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M386.43 316.99s-15.24 26.94-16.34 62.72c-.75 24.43 11.93 66.85 11.93 66.85s-20.76-36.07-20.76-70.23 25.17-59.34 25.17-59.34z" style="fill:url(#SVGID_27_)"/><linearGradient id="SVGID_28_" gradientUnits="userSpaceOnUse" x1="389.798" y1="347.846" x2="456.792" y2="414.84"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><path d="M420.65 316.99s34.1 22.12 34.1 60.99-29.68 68.58-29.68 68.58 23.5-42.18 23.5-70.9c0-14.24-13.98-48.76-27.92-58.67z" style="fill:url(#SVGID_28_)"/><path class="st26" d="M386.43 316.99s-62.13 47.12-4.42 129.57c-7.06-15.6-36.21-73.62 4.42-129.57zM420.65 316.99s62.13 47.12 4.42 129.57c7.07-15.6 36.22-73.62-4.42-129.57zM404.4 311.4s-35.48 79.66 1.33 135.9c32.24-57.5-1.33-135.9-1.33-135.9z"/></g><g><linearGradient id="SVGID_29_" gradientUnits="userSpaceOnUse" x1="234.692" y1="561.708" x2="234.692" y2="486.088"><stop offset="0" stop-color="#C3D5FD"/><stop offset="1" stop-color="#1A90FC"/></linearGradient><path d="M226.89 486.45s-18.35-2.54-24.31 5.89c-5.96 8.43-8.14 30.01-8.14 30.01l13.81-.73-1.16 25.14 25.48 14.94 36.3-19.52-5.52-22.78 11.6-3.03s-3.46-33.06-19.59-28.18c-8.29.89-10.9.74-10.9.74s-2.18 6.95-9.67 6.36c-7.49-.58-7.9-8.84-7.9-8.84z" style="fill:url(#SVGID_29_)"/><linearGradient id="SVGID_30_" gradientUnits="userSpaceOnUse" x1="235.741" y1="473.191" x2="235.741" y2="497.147"><stop offset="0" stop-color="#F4AE98"/><stop offset="1" stop-color="#FAD1BB"/></linearGradient><path d="M228.72 476.05l-3.81 17.01c-.18.8.24 1.61 1 1.92 1.97.8 5.91 2.17 9.97 2.17 4.21 0 8.18-2.3 10-3.53.63-.42.89-1.21.65-1.93l-5.83-17.35a1.681 1.681 0 00-1.89-1.12l-8.74 1.55c-.67.11-1.2.62-1.35 1.28z" style="fill:url(#SVGID_30_)"/><linearGradient id="SVGID_31_" gradientUnits="userSpaceOnUse" x1="-1535.437" y1="750.954" x2="-1523.728" y2="668.51" gradientTransform="rotate(-8.082 -1929.216 -11692.611)"><stop offset="0" stop-color="#C3D5FD"/><stop offset="1" stop-color="#1A90FC"/></linearGradient><path d="M205.94 489.39c5.34-1.77 12.58-.59 12.88 11.39.29 11.98-11.45 31.07-7.24 37.71 4.26 6.73 13.37 16.29 19.17 21.73-1.35 6.4-4.99 11.78-4.99 11.78s-21.36-9.86-30.66-19.73c-9.3-9.87-4.61-32.5-.3-48.61 3.24-12.13 11.14-14.27 11.14-14.27z" style="fill:url(#SVGID_31_)"/><linearGradient id="SVGID_32_" gradientUnits="userSpaceOnUse" x1="-5585.118" y1="175.804" x2="-5573.409" y2="93.36" gradientTransform="scale(-1 1) rotate(-8.082 -118.041 -37329.02)"><stop offset="0" stop-color="#C3D5FD"/><stop offset="1" stop-color="#1A90FC"/></linearGradient><path d="M261.91 489.39c-5.34-1.77-12.58-.59-12.88 11.39-.29 11.98 11.45 31.07 7.24 37.71-4.26 6.73-13.37 16.29-19.17 21.73 1.35 6.4 4.99 11.78 4.99 11.78s21.36-9.86 30.66-19.73c9.3-9.87 4.61-32.5.3-48.61-3.24-12.13-11.14-14.27-11.14-14.27z" style="fill:url(#SVGID_32_)"/><linearGradient id="SVGID_33_" gradientUnits="userSpaceOnUse" x1="234.692" y1="534.399" x2="234.692" y2="582.454"><stop offset="0" stop-color="#275C89"/><stop offset="1" stop-color="#013F7C"/></linearGradient><path d="M208.53 582.45h51.85c2.35 0 4.45-1.78 5.26-4.46l10.45-34.39c1.36-4.46-1.35-9.21-5.26-9.21h-72.29c-3.87 0-6.58 4.67-5.29 9.11l9.98 34.39c.8 2.74 2.92 4.56 5.3 4.56z" style="fill:url(#SVGID_33_)"/><path class="st26" d="M206.46 598.27s-.8-1.76-1.42-1.95c-.62-.19-9.49-.54-12.72-1.38s-12.56-2.17-16.56 1.61c-3.48 3.3-4.63 11 .67 15.43 1.99 1.73 3.94 2.23 11.31 1.89s18.04-.27 20.38-3.68c-.07-5.65-1.66-11.92-1.66-11.92z"/><linearGradient id="SVGID_34_" gradientUnits="userSpaceOnUse" x1="-3991.106" y1="603.193" x2="-3974.093" y2="603.193" gradientTransform="matrix(-1 0 0 1 -3772.525 0)"><stop offset="0" stop-color="#F4B9A4"/><stop offset=".652" stop-color="#FAD1BB"/></linearGradient><path d="M214.69 596.31s-8.56 1.92-11.62 1.64c-3.06-.28-.5 12.05-.5 12.05s8.17.57 15.4-1.53c2.45-9.44-3.28-12.16-3.28-12.16z" style="fill:url(#SVGID_34_)"/><linearGradient id="SVGID_35_" gradientUnits="userSpaceOnUse" x1="211.625" y1="584.443" x2="299.388" y2="584.443"><stop offset="0" stop-color="#18264B"/><stop offset=".652" stop-color="#2D3C65"/></linearGradient><path d="M211.63 595.87l4.45 13.84s95.64-12.6 81.97-43.63c-11.43-25.96-86.42 29.79-86.42 29.79z" style="fill:url(#SVGID_35_)"/><linearGradient id="SVGID_36_" gradientUnits="userSpaceOnUse" x1="191.837" y1="594.929" x2="191.837" y2="612.987"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><path d="M205.35 596.94s3.5 1.37 3.95 6.26c.44 4.89-1 8.17-8.45 9.07-7.45.91-9.34.57-14.01.68-4.67.11-11.45.57-12.46-8.32-1-8.88 8.08-11.15 17.5-8.88 2.96.56 13.47 1.19 13.47 1.19z" style="fill:url(#SVGID_36_)"/><g><path class="st26" d="M266.14 598.27s.8-1.76 1.42-1.95c.62-.19 9.49-.54 12.72-1.38 3.23-.84 11.93-2.24 16.32 1.51 4.07 3.48 4.34 11.16-.3 15.4-1.99 1.73-4.08 2.35-11.44 2.01s-18.04-.27-20.38-3.68c.08-5.64 1.66-11.91 1.66-11.91z"/><linearGradient id="SVGID_37_" gradientUnits="userSpaceOnUse" x1="254.028" y1="603.193" x2="271.04" y2="603.193"><stop offset="0" stop-color="#F4B9A4"/><stop offset=".652" stop-color="#FAD1BB"/></linearGradient><path d="M257.92 596.31s8.56 1.92 11.62 1.64c3.06-.28.5 12.05.5 12.05s-8.17.57-15.4-1.53c-2.45-9.44 3.28-12.16 3.28-12.16z" style="fill:url(#SVGID_37_)"/><linearGradient id="SVGID_38_" gradientUnits="userSpaceOnUse" x1="173.22" y1="584.443" x2="260.983" y2="584.443"><stop offset="0" stop-color="#445677"/><stop offset="1" stop-color="#293861"/></linearGradient><path d="M260.98 595.87l-4.45 13.84s-95.64-12.6-81.97-43.63c11.43-25.96 86.42 29.79 86.42 29.79z" style="fill:url(#SVGID_38_)"/><linearGradient id="SVGID_39_" gradientUnits="userSpaceOnUse" x1="280.771" y1="594.929" x2="280.771" y2="612.987"><stop offset="0" stop-color="#FFDB80"/><stop offset="1" stop-color="#FFBB24"/></linearGradient><path d="M267.26 596.94s-3.5 1.37-3.95 6.26 1 8.17 8.45 9.07 9.34.57 14.01.68 11.45.57 12.46-8.32c1-8.88-8.08-11.15-17.5-8.88-2.96.56-13.47 1.19-13.47 1.19z" style="fill:url(#SVGID_39_)"/></g><linearGradient id="SVGID_40_" gradientUnits="userSpaceOnUse" x1="251.42" y1="467.696" x2="220.113" y2="467.696"><stop offset="0" stop-color="#F4B9A4"/><stop offset=".652" stop-color="#FAD1BB"/></linearGradient><path d="M250.74 469.25c.57-.81.93-2.88.46-3.49-.84-.68-1.63-.29-2.24.3.14-4.96-.31-9.07-.42-10.12-.31-3.04-3.4-8.6-13.5-8.6s-12.05 7.29-12.05 7.29-.66 5.15-.46 11.41c-.6-.58-1.39-.95-2.22-.28-.46.61-.1 2.68.46 3.49.57.81.93 2.73 1.03 3.79.1 1.01-.63 3.7 2.03 3.36 1.59 6.35 8.01 11.64 11.99 11.64 4.36 0 10.16-5.33 11.8-11.65 2.71.37 1.98-2.34 2.07-3.35.13-1.06.49-2.98 1.05-3.79z" style="fill:url(#SVGID_40_)"/><linearGradient id="SVGID_41_" gradientUnits="userSpaceOnUse" x1="215.804" y1="455.594" x2="252.152" y2="455.594"><stop offset="0" stop-color="#4F5C7C"/><stop offset="1" stop-color="#274168"/></linearGradient><path d="M242.34 445.19s-1.97-6.21-9.53-4.72c-7.55 1.48-8.06 5.06-11.03 5.19-5.06.24-9.11 6.54-2.61 13.22 2.89 2.97.45 4.25 1.82 6.88s1.36 5.19 1.36 5.19 2.41-7.71.82-11c-.82-1.7 2.82-2.16 7.35-1.75s11.68-1.1 12.24-4.18c1.34 6.32 2.68 6.98 4.38 7.94 1.7.96 1.8 8.6 1.8 8.6s.3-5.62 1.49-6.88c.98-2.07 2.89-10.22.73-12.19s-.34-8.31-8.82-6.3z" style="fill:url(#SVGID_41_)"/></g><linearGradient id="SVGID_42_" gradientUnits="userSpaceOnUse" x1="509.948" y1="612.061" x2="509.948" y2="547.57"><stop offset="0" stop-color="#B0B9E1"/><stop offset="1" stop-color="#E7EFF7"/></linearGradient><path d="M452.67 596.16L498.32 547.57 567.22 547.57 506.27 612.06z" style="fill:url(#SVGID_42_)"/><linearGradient id="SVGID_43_" gradientUnits="userSpaceOnUse" x1="461.835" y1="563.724" x2="495.632" y2="622.263"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><circle cx="478.73" cy="592.99" r="33.79" style="fill:url(#SVGID_43_)"/><linearGradient id="SVGID_44_" gradientUnits="userSpaceOnUse" x1="455.798" y1="564.313" x2="489.595" y2="622.851"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><circle cx="472.7" cy="593.58" r="33.79" style="fill:url(#SVGID_44_)"/><linearGradient id="SVGID_45_" gradientUnits="userSpaceOnUse" x1="479.001" y1="231.35" x2="503.267" y2="273.38"><stop offset="0" stop-color="#C8CBF2"/><stop offset="1" stop-color="#AFB0E7"/></linearGradient><circle cx="491.13" cy="252.36" r="24.26" style="fill:url(#SVGID_45_)"/><linearGradient id="SVGID_46_" gradientUnits="userSpaceOnUse" x1="474.666" y1="231.772" x2="498.933" y2="273.803"><stop offset=".116" stop-color="#DEE4FF"/><stop offset=".847" stop-color="#BACBEE"/></linearGradient><circle cx="486.8" cy="252.79" r="24.26" style="fill:url(#SVGID_46_)"/></g></svg> \ No newline at end of file diff --git a/src/assets/svgs/icon.svg b/src/assets/svgs/icon.svg new file mode 100644 index 0000000..7024bec --- /dev/null +++ b/src/assets/svgs/icon.svg @@ -0,0 +1 @@ +<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M115.147.062a13 13 0 014.94.945c1.55.63 2.907 1.526 4.069 2.688a13.148 13.148 0 012.761 4.069c.678 1.55 1.017 3.245 1.017 5.086v102.3c0 3.681-1.187 6.733-3.56 9.155-2.373 2.422-5.352 3.633-8.937 3.633H12.992c-3.875 0-7-1.26-9.373-3.779-2.373-2.518-3.56-5.667-3.56-9.445V12.704c0-3.39 1.163-6.345 3.488-8.863C5.872 1.32 8.972.062 12.847.062h102.3zM81.434 109.047c1.744 0 3.003-.412 3.778-1.235.775-.824 1.163-1.914 1.163-3.27 0-1.26-.388-2.325-1.163-3.197-.775-.872-2.034-1.307-3.778-1.307H72.57c.097-.194.145-.485.145-.872V27.09h9.01c1.743 0 2.954-.436 3.633-1.308.678-.872 1.017-1.938 1.017-3.197 0-1.26-.34-2.325-1.017-3.197-.679-.872-1.89-1.308-3.633-1.308H46.268c-1.743 0-2.954.436-3.632 1.308-.678.872-1.018 1.938-1.018 3.197 0 1.26.34 2.325 1.018 3.197.678.872 1.889 1.308 3.632 1.308h8.138v72.075c0 .193.024.339.073.436.048.096.072.242.072.436H46.56c-1.744 0-3.003.435-3.778 1.307-.775.872-1.163 1.938-1.163 3.197 0 1.356.388 2.446 1.163 3.27.775.823 2.034 1.235 3.778 1.235h34.875z"/></svg> \ No newline at end of file diff --git a/src/assets/svgs/login-bg.svg b/src/assets/svgs/login-bg.svg new file mode 100644 index 0000000..bbe06c1 --- /dev/null +++ b/src/assets/svgs/login-bg.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="5760" height="3040"><image width="5760" height="3040" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAFoAAAAvgAQMAAAC1QKagAAAABGdBTUEAALGPC/xhBQAAACBjSFJN AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABlBMVEUsNEr///91v/yPAAAA AWJLR0QB/wIt3gAAAAd0SU1FB+YBBQYyN1c3BnEAAAhjSURBVHja7cExAQAAAMKg9U9tDB+gAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAACAtwFzzwABY3VrRQAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMi0wMS0wNVQwNjo1 MDo1MyswMDowMCfNlVoAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjItMDEtMDVUMDY6NTA6NTQrMDA6 MDCTNxNoAAAAAElFTkSuQmCC"/></svg> \ No newline at end of file diff --git a/src/assets/svgs/login-box-bg.svg b/src/assets/svgs/login-box-bg.svg new file mode 100644 index 0000000..ab10040 --- /dev/null +++ b/src/assets/svgs/login-box-bg.svg @@ -0,0 +1 @@ +<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 700 700" xml:space="preserve" enable-background="new 0 0 700 700"><style>.st0{fill:#e5e6eb}.st1{fill:#fff}.st2{fill:#84a9ff}.st3{fill:#050f64}.st4{fill:#155bcd}.st5{fill:#ffbd00}.st6{fill:#ff654f}.st9{fill:#f5bdc8}.st10{fill:#ea8096}.st11{opacity:0}.st13{fill:#dca000}</style><path class="st0" d="M101.8 176.7c21.4-19.8 48.8-33.2 77.8-37.2 92.4-12.6 158.2 78.1 240.3 104.9 40.8 13.3 85.4 12.6 125.4 28 68.5 26.2 131.4 117.8 101 191.6-23.7 57.5-79.6 71.8-134.6 54-33.5-10.9-64.1-29.4-97.6-40.5-38.1-12.6-78.7-15.1-118.9-16.7s-80.6-2.4-119.6-12-77-28.9-101.2-60.9C40.8 343.4 48 260.8 73.1 213.7c7.4-13.9 17.2-26.3 28.7-37z"/><path class="st1" d="M82 257.1c5.7-23.2 18.9-44.7 37.3-60.4l1.7-1.5 1.8-1.4 1.8-1.4 1.8-1.3c.6-.4 1.2-.9 1.8-1.3l1.9-1.3c.6-.4 1.2-.9 1.9-1.3l1.9-1.2c5.1-3.2 10.5-6 16.1-8.4 11.1-4.7 23-7.8 35.1-9 12.1-1.1 24.3-.5 36.1 1.5 5.9 1 11.8 2.4 17.6 4 .7.2 1.5.4 2.2.6l2.2.7 2.2.7 2.1.7 2.1.7 2.1.8 2.1.8 2.1.8c5.6 2.2 11.1 4.6 16.5 7.2 5.4 2.6 10.7 5.4 15.9 8.3 10.4 5.9 20.6 12.2 30.5 18.8-10.4-5.9-20.7-11.8-31.4-17.2-5.3-2.7-10.7-5.3-16.1-7.7-5.4-2.4-10.9-4.7-16.5-6.7l-2.1-.8-2.1-.7-2.1-.7-2.1-.7-2.1-.7-2.1-.6-2.1-.6-2.1-.6c-5.7-1.5-11.5-2.8-17.3-3.7-11.6-1.9-23.5-2.5-35.2-1.3-11.7 1.1-23.2 4-34.1 8.5-5.4 2.2-10.7 4.9-15.8 7.9l-1.9 1.1c-.6.4-1.2.8-1.9 1.2l-1.8 1.2c-.6.4-1.2.8-1.8 1.3l-1.8 1.3-1.8 1.3-1.8 1.3-1.7 1.4c-18.2 15.2-32 35.7-39.1 58.4z"/><path class="st2" d="M183.1 543.2c-.3 1.2-.5 1.8-.5 1.8-.7-.5-1.4-.9-2.1-1.4-120.8-82.8-72.6-232.2-72.6-232.2 115.7 67.3 80.1 213.8 75.2 231.8z"/><path class="st3" d="M183.1 543.2c-.3 1.2-.5 1.8-.5 1.8-.7-.5-1.4-.9-2.1-1.4-10.1-29.9-20.1-59.8-29.8-89.8-5-15.5-10-31.1-14.8-46.7l-3.6-11.7-3.5-11.7c-1.2-3.9-2.2-7.8-3.4-11.8-.6-2-1.1-3.9-1.6-5.9l-1.6-5.9 1.6 5.9c.5 2 1.1 3.9 1.7 5.9 1.2 3.9 2.3 7.8 3.5 11.7l3.6 11.7 3.7 11.7c5 15.5 10.2 31 15.4 46.5 10.4 30 20.8 59.9 31.4 89.7zM137.9 384.9c-.1 0-.2 0-.4-.1-.3-.1-.4-.5-.2-.8 3.7-7.2 6-15.3 6.7-23.4 0-.3.3-.5.6-.5s.5.3.5.6c-.7 8.2-3.1 16.5-6.9 23.8 0 .3-.2.4-.3.4zM154 430.5h-.3c-.3-.1-.4-.5-.3-.7 3.4-8.3 7.6-16.4 12.3-24.1.2-.3.5-.3.8-.2.3.2.3.5.2.8-4.7 7.6-8.8 15.6-12.2 23.9-.1.1-.3.3-.5.3zM137.4 440.3h-.3c-9.5-3.9-18.3-9.3-26.1-16.1-.2-.2-.3-.6-.1-.8.2-.2.6-.3.8-.1 7.7 6.7 16.3 12 25.7 15.9.3.1.4.5.3.7 0 .2-.1.3-.3.4zM125.9 390.5c-.2.1-.4.1-.6-.1l-19.2-15c-.2-.2-.3-.6-.1-.8.2-.2.6-.3.8-.1l19.2 15c.2.2.3.6.1.8 0 .1-.1.2-.2.2zM170.7 478.4h-.3c-.3-.1-.4-.5-.3-.7l10.1-23.5c.1-.3.5-.4.7-.3.3.1.4.5.3.7l-10.1 23.5c0 .1-.2.3-.4.3zM151.6 481.6h-.3l-24.3-10c-.3-.1-.4-.5-.3-.7.1-.3.5-.4.7-.3l24.3 10c.3.1.4.5.3.7-.1.1-.3.3-.4.3z"/><path class="st4" d="M182.3 543.2c.3 1.2.4 1.9.4 1.9-.8-.1-1.7-.2-2.5-.3C35 525 11 369.8 11 369.8c133.5 8.2 167.5 155.1 171.3 173.4z"/><path class="st1" d="M182.3 543.2c.3 1.2.4 1.9.4 1.9-.8-.1-1.7-.2-2.5-.3-22.5-22.1-44.8-44.4-66.9-66.8-11.5-11.6-22.9-23.3-34.2-35.1l-8.5-8.8-8.4-8.9c-2.8-3-5.5-6-8.3-9-1.4-1.5-2.7-3-4.1-4.6l-4-4.6 4.1 4.5c1.4 1.5 2.7 3 4.1 4.5 2.8 3 5.6 6 8.4 8.9l8.5 8.8 8.6 8.7c11.5 11.6 23 23.1 34.7 34.6 22.5 22.2 45.2 44.3 68.1 66.2zM70.7 422.1c-.1.1-.2.1-.3.1-.3 0-.6-.3-.6-.6.1-8.1-1.5-16.4-4.5-23.9-.1-.3 0-.6.3-.7.3-.1.6 0 .7.3 3 7.7 4.6 16.1 4.6 24.4 0 .1-.1.3-.2.4zM105.6 455.5c-.1.1-.2.1-.3.1-.3 0-.6-.2-.6-.5-.7-9-.6-18.1.2-27 0-.3.3-.5.6-.5s.5.3.5.6c-.8 8.9-.9 17.9-.2 26.8.1.2 0 .4-.2.5zM95.2 471.7c-.1.1-.2.1-.3.1-10.3.8-20.5-.1-30.5-2.7-.3-.1-.5-.4-.4-.7.1-.3.4-.5.7-.4 9.9 2.5 20 3.4 30.1 2.6.3 0 .6.2.6.5 0 .4-.1.5-.2.6zM62.6 432.4c-.1.1-.3.2-.5.2l-23.9-4.8c-.3-.1-.5-.4-.4-.7.1-.3.4-.5.7-.4l23.9 4.8c.3.1.5.4.4.7-.1.1-.1.2-.2.2zM142.1 490.8c-.1.1-.2.1-.3.1-.3 0-.6-.2-.6-.5l-1.5-25.5c0-.3.2-.6.5-.6s.6.2.6.5l1.5 25.5c0 .2-.1.4-.2.5zM126.4 502.3c-.1.1-.2.1-.3.1l-26.2 2c-.3 0-.6-.2-.6-.5s.2-.6.5-.6l26.2-2c.3 0 .6.2.6.5 0 .2-.1.4-.2.5z"/><g><path class="st5" d="M259.6 503.3c1.2.5 1.8.7 1.8.7-.5.7-1.1 1.3-1.7 1.9C164 616.8 20.9 552.3 20.9 552.3c79.7-107.4 221.4-55.9 238.7-49z"/><path class="st1" d="M259.6 503.3c1.2.5 1.8.7 1.8.7-.5.7-1.1 1.3-1.7 1.9-30.8 6.8-61.6 13.3-92.5 19.7-16 3.3-32 6.5-48 9.6l-12 2.3-12 2.2c-4 .7-8 1.4-12.1 2-2 .4-4 .6-6 .9l-6.1.9 6-1c2-.3 4-.6 6-1 4-.7 8-1.4 12-2.2l12-2.3 12-2.4c16-3.3 31.9-6.7 47.9-10.2 31-6.9 61.9-13.9 92.7-21.1zM97.3 530.8c0 .1 0 .2-.1.3-.2.3-.5.3-.8.2-6.8-4.5-14.6-7.7-22.5-9.3-.3-.1-.5-.4-.4-.7.1-.3.4-.5.7-.4 8.1 1.6 16 4.9 22.9 9.5.1 0 .1.2.2.4zM144.3 519.7c0 .1 0 .2-.1.3-.2.3-.5.4-.8.2-7.9-4.3-15.5-9.4-22.5-14.9-.2-.2-.3-.6-.1-.8.2-.2.6-.3.8-.1 7 5.5 14.6 10.5 22.4 14.8.2.1.3.3.3.5zM152.2 537.3c0 .1 0 .2-.1.3-4.9 9-11.3 17.2-18.8 24.1-.2.2-.6.2-.8 0-.2-.2-.2-.6 0-.8 7.5-6.9 13.7-14.9 18.6-23.8.2-.3.5-.4.8-.2.2.1.3.3.3.4zM101.5 543.2c.1.2 0 .4-.1.6l-17 17.5c-.2.2-.6.2-.8 0-.2-.2-.2-.6 0-.8l17-17.5c.2-.2.6-.2.8 0 .1 0 .1.1.1.2zM193.8 508.4c0 .1 0 .2-.1.3-.2.3-.5.4-.8.2l-22.2-12.7c-.3-.2-.4-.5-.2-.8.2-.3.5-.4.8-.2l22.2 12.7c.2.2.3.3.3.5zM194.9 527.8c0 .1 0 .2-.1.3l-12.7 23.1c-.2.3-.5.4-.8.2-.3-.2-.4-.5-.2-.8l12.7-23.1c.2-.3.5-.4.8-.2.1.2.3.3.3.5z"/></g><g><path class="st2" d="M608.8 430.3c-1 .2-2.4-.3-4.4-1.4-3.2-1.9-8.3-4.9-10.2-6.1 3 6.3 5.8 12.7 8.3 19.2 4.5-1 7.9-.1 10.1 1.4 2.2 1.5 3.3 3.6 3.3 4.6-.1 2-1.8 2.4-4.9.3-1.6-1.1-3.7-2.6-5.5-3.9-1.3-.9-2.3-1.7-2.8-2 .8 2 1.5 4 2.2 6h.2c1.3.2 3.1 3.1 3.9 4.1 1.7 2.3 3 4.9 3.2 7.8.1 1.2-.1 2.6-1.2 3.2-1.2.6-2.6-.3-3.5-1.3-2.5-2.8-4-6.5-4.1-10.2 0-1-.1-3.3 1.2-3.5-.8-2-1.5-3.9-2.3-5.9-.1.6-.4 1.9-.7 3.4-.5 2.1-1.1 4.7-1.7 6.4-1.1 3.5-2.7 4.1-4 2.8-.7-.7-1.1-2.7-.3-5.2.8-2.4 2.6-5.3 6.6-7.7-2.7-6.4-5.6-12.7-8.8-18.9-.1.8-.3 2.2-.5 3.7-.3 2.6-.9 5.7-1.4 7.8-.5 2.1-1.2 3.4-2 4-.8.6-1.7.4-2.5-.3-.9-.7-1.6-3.1-.9-6.2.6-2.9 2.6-6.5 7-9.6-3.5-6.6-7.2-13.1-11.2-19.4v.3c0 1 0 2.5-.1 4.1-.1 1.6-.2 3.4-.3 5-.1 1.7-.4 3.3-.5 4.6-.8 5.3-3 6.6-5.2 5-1.2-.8-2.1-3.7-1.7-7.4.2-1.9.9-4 2.2-6.2 1.1-2 2.8-4.2 5.2-6.3-3.8-5.8-7.8-11.5-12-17 .1 1.2.2 2.8.2 4.6.1 1.8.1 3.9.1 5.8v2.8c0 .9-.1 1.8-.1 2.5-.4 6.1-2.8 7.8-5.5 6.2-.7-.4-1.4-1.4-1.9-2.8s-.8-3.3-.7-5.4c.1-2.2.7-4.6 1.9-7.3 1.1-2.4 2.8-5 5.2-7.6-4.2-5.4-8.5-10.5-13.1-15.5l2-1.8c4.5 5.2 8.8 10.5 12.9 16 3.1-1.6 6.1-2.5 8.8-2.7 3-.3 5.6.1 7.8.9s4 1.9 5.3 3.1c1.2 1.2 2 2.4 2.2 3.3.7 3.5-2 4.7-8 2.5-3.1-1.2-7.3-2.8-10.7-4.2-1.7-.6-3.3-1.2-4.4-1.6 4.1 5.6 8 11.5 11.6 17.4 2.9-1.2 5.6-1.7 8-1.8 2.6 0 4.8.5 6.7 1.4 3.8 1.7 5.8 4.5 6 6 .3 3.1-2 4-7.1 1.6-2.6-1.3-6.1-3-9-4.4-1.4-.7-2.8-1.3-3.7-1.8-.1 0-.1-.1-.2-.1 3.9 6.4 7.5 13 10.8 19.8 5.1-1.6 9.2-.9 12 .7 2.8 1.6 4.3 4 4.4 5.2-.7 1.1-1.2 1.8-2.2 2z"/></g><g><path class="st2" d="M552.1 373.7c-.5 1.1-.8 1.7-.8 1.7l-1.8-1.8c-105.3-101.8-32.8-241.1-32.8-241.1 102.7 85.7 43.2 224.2 35.4 241.2z"/><path class="st1" d="M552.1 373.7c-.5 1.1-.8 1.7-.8 1.7l-1.8-1.8c-5-31.1-9.8-62.3-14.4-93.5-2.4-16.1-4.7-32.3-6.8-48.5l-1.6-12.1-1.5-12.2c-.5-4.1-.9-8.1-1.4-12.2-.2-2-.4-4.1-.6-6.1l-.5-6.1.6 6.1c.2 2 .4 4.1.7 6.1.5 4 1 8.1 1.5 12.1l1.6 12.1 1.7 12.1c2.4 16.1 4.9 32.3 7.5 48.4 5.1 31.4 10.3 62.7 15.8 93.9zM533.9 210c-.1 0-.2 0-.3-.1-.3-.2-.3-.5-.1-.8 4.9-6.5 8.5-14.1 10.6-21.9.1-.3.4-.5.7-.4.3.1.5.4.4.7-2.1 8-5.8 15.7-10.7 22.3-.3.1-.5.2-.6.2zM542.2 257.6c-.1 0-.2 0-.3-.1-.3-.2-.3-.5-.2-.8 4.8-7.6 10.2-14.9 16.2-21.7.2-.2.6-.3.8-.1.2.2.3.6.1.8-5.9 6.7-11.3 13.9-16.1 21.5-.1.3-.3.4-.5.4zM524.2 264.5c-.1 0-.2 0-.3-.1-8.7-5.4-16.5-12.2-23-20.2-.2-.2-.2-.6.1-.8.2-.2.6-.2.8.1 6.4 7.9 14.1 14.6 22.7 19.9.3.2.3.5.2.8-.2.2-.4.3-.5.3zM521.2 213.5c-.2 0-.4 0-.5-.2l-16.5-18c-.2-.2-.2-.6 0-.8.2-.2.6-.2.8 0l16.5 18c.2.2.2.6 0 .8-.1.2-.2.2-.3.2zM550.7 307.7c-.1 0-.2 0-.3-.1-.3-.2-.3-.5-.2-.8l13.9-21.5c.2-.3.5-.3.8-.2.3.2.3.5.2.8l-13.9 21.5c-.1.2-.3.3-.5.3zM531.2 307.6c-.1 0-.2 0-.3-.1l-22.3-13.9c-.3-.2-.3-.5-.2-.8.2-.3.5-.3.8-.2l22.3 13.9c.3.2.3.5.2.8-.1.2-.3.3-.5.3z"/><g><path class="st4" d="M526.6 382.8c-1 .7-1.6 1-1.6 1-.2-.8-.4-1.6-.6-2.5-35-142.2 100.5-221.5 100.5-221.5 41.5 127.2-82.7 212.8-98.3 223z"/><path class="st3" d="M526.6 382.8c-1 .7-1.6 1-1.6 1-.2-.8-.4-1.6-.6-2.5 12.3-29 24.8-58 37.5-86.8 6.6-14.9 13.3-29.8 20-44.7l5.1-11.1 5.2-11.1c1.7-3.7 3.6-7.3 5.3-11 .9-1.8 1.8-3.6 2.7-5.5l2.8-5.4-2.7 5.5c-.9 1.8-1.8 3.6-2.7 5.5-1.7 3.7-3.5 7.4-5.2 11.1l-5.1 11.1-5 11.2c-6.6 14.9-13 29.9-19.4 44.9-12.2 29.2-24.3 58.5-36.3 87.8zM598.2 234.5c-.1-.1-.2-.2-.2-.3-.1-.3 0-.6.3-.7 7.6-2.9 14.7-7.4 20.6-13 .2-.2.6-.2.8 0 .2.2.2.6 0 .8-6 5.7-13.3 10.2-21 13.2-.1.1-.3.1-.5 0zM580 279.3c-.1-.1-.2-.1-.2-.2-.1-.3 0-.6.3-.8 8.1-3.9 16.6-7.2 25.2-9.7.3-.1.6.1.7.4.1.3-.1.6-.4.7-8.6 2.5-17 5.8-25 9.7-.3 0-.5 0-.6-.1zM561 275.5c-.1-.1-.2-.1-.2-.3-4.5-9.3-7.4-19.1-8.7-29.3 0-.3.2-.6.5-.6s.6.2.6.5c1.3 10.1 4.2 19.8 8.6 29 .1.3 0 .6-.3.8-.1 0-.3 0-.5-.1zM585.6 230.8c-.2-.1-.3-.2-.4-.4l-4.4-24c-.1-.3.1-.6.5-.7.3-.1.6.1.7.5l4.4 24c.1.3-.1.6-.5.7-.1-.1-.2-.1-.3-.1zM560.5 326.2c-.1-.1-.2-.1-.2-.2-.1-.3 0-.6.3-.8l23.2-10.8c.3-.1.6 0 .8.3.1.3 0 .6-.3.8l-23.2 10.8c-.2 0-.4 0-.6-.1zM544.1 315.8c-.1-.1-.2-.1-.2-.2l-11.5-23.7c-.1-.3 0-.6.3-.8.3-.1.6 0 .8.3l11.5 23.7c.1.3 0 .6-.3.8-.2 0-.5 0-.6-.1z"/></g><g><path class="st5" d="M482.2 415.1c-1.2 0-1.9-.1-1.9-.1l.9-2.4C532.4 275.4 689 286.2 689 286.2c-37.4 128.5-188.2 129.3-206.8 128.9z"/><path class="st1" d="M482.2 415.1c-1.2 0-1.9-.1-1.9-.1l.9-2.4c26.5-17 53.2-33.9 79.9-50.6 13.9-8.6 27.8-17.2 41.7-25.6l10.5-6.3 10.5-6.2c3.5-2.1 7.1-4.1 10.6-6.1 1.8-1 3.6-2 5.3-3l5.4-2.9-5.3 3c-1.8 1-3.6 2-5.3 3-3.5 2.1-7 4.1-10.5 6.2l-10.5 6.4-10.4 6.4c-13.8 8.6-27.6 17.4-41.4 26.2-26.6 17.2-53.1 34.5-79.5 52zM624.9 333c0-.1-.1-.2 0-.4.1-.3.4-.5.7-.4 7.9 1.8 16.3 2.2 24.3.9.3 0 .6.2.7.5 0 .3-.2.6-.5.7-8.2 1.3-16.7 1-24.8-.9l-.4-.4zM584.6 359.7c0-.1-.1-.2 0-.3 0-.3.3-.5.6-.5 8.9 1.3 17.8 3.4 26.3 6.2.3.1.5.4.4.7-.1.3-.4.5-.7.4-8.5-2.7-17.3-4.8-26.1-6.1-.3-.1-.4-.3-.5-.4zM571.1 345.9c-.1-.1-.1-.2-.1-.3 1.5-10.2 4.6-20 9.3-29.2.1-.3.5-.4.8-.2.3.1.4.5.2.8-4.6 9.1-7.7 18.7-9.2 28.8 0 .3-.3.5-.6.5-.2-.2-.4-.3-.4-.4zM616.6 322.8c-.1-.2-.1-.4-.1-.6l9.9-22.3c.1-.3.5-.4.8-.3.3.1.4.5.3.8l-9.9 22.3c-.1.3-.5.4-.8.3l-.2-.2zM542.1 387.4c0-.1-.1-.2 0-.3.1-.3.3-.5.7-.5l25.2 4.2c.3.1.5.3.5.7-.1.3-.3.5-.7.5l-25.2-4.2c-.3-.1-.4-.2-.5-.4zM534.4 369.6c0-.1-.1-.2 0-.3l3.9-26c0-.3.3-.5.6-.5s.5.3.5.6l-3.9 26c0 .3-.3.5-.6.5s-.4-.2-.5-.3z"/></g></g><g><path class="st2" d="M445 229c-.1 1 .4 2.4 1.6 4.3 2.1 3.1 5.4 8 6.6 9.9-6.4-2.7-13-5.1-19.6-7.3.8-4.5-.3-7.9-1.9-10-1.6-2.2-3.7-3.1-4.8-3-2 .2-2.3 1.9 0 4.9 1.1 1.5 2.8 3.5 4.2 5.3 1 1.2 1.8 2.2 2.2 2.7-2-.7-4.1-1.3-6.1-1.9v-.2c-.3-1.3-3.3-3-4.3-3.7-2.4-1.6-5.1-2.7-7.9-2.8-1.2 0-2.6.3-3.1 1.4-.5 1.2.5 2.6 1.5 3.4 2.9 2.4 6.7 3.6 10.4 3.5 1 0 3.3 0 3.5-1.4 2 .7 4 1.3 6 2-.6.2-1.9.5-3.4.9-2.1.6-4.6 1.4-6.3 2-3.4 1.3-4 2.9-2.6 4.2.7.6 2.8 1 5.2 0 2.4-.9 5.2-2.9 7.3-7 6.6 2.3 13 4.9 19.3 7.7-.8.1-2.2.4-3.7.7-2.5.5-5.6 1.2-7.7 1.8-2.1.6-3.3 1.4-3.9 2.2-.5.8-.4 1.7.4 2.5s3.2 1.4 6.2.5c2.9-.8 6.3-2.9 9.3-7.5 6.8 3.1 13.5 6.5 20 10.2h-.3c-1 .1-2.5.2-4.1.4-1.6.2-3.3.4-5 .6-1.7.2-3.3.5-4.6.8-5.2 1.1-6.4 3.3-4.7 5.5.9 1.1 3.8 1.9 7.5 1.3 1.9-.3 4-1.1 6.1-2.5 2-1.3 4-3 6-5.5 6 3.4 11.9 7.1 17.7 11.1h-4.6c-1.8 0-3.9.1-5.8.2-1 .1-1.9.1-2.8.2-.9.1-1.7.2-2.5.3-6.1.8-7.6 3.2-5.9 5.8.4.7 1.5 1.3 2.9 1.7 1.5.4 3.3.7 5.5.4 2.2-.3 4.6-.9 7.2-2.3 2.3-1.2 4.8-3 7.3-5.6 5.6 3.9 11 8 16.2 12.3l1.7-2.1c-5.4-4.2-11-8.2-16.7-12 1.4-3.2 2.1-6.2 2.2-8.9.1-3-.4-5.6-1.3-7.7-.9-2.1-2.1-3.9-3.4-5.1-1.3-1.2-2.5-1.9-3.4-2-3.5-.5-4.6 2.2-2 8.1 1.4 3 3.2 7.1 4.8 10.5.7 1.7 1.4 3.2 1.9 4.3-5.9-3.8-11.9-7.4-18-10.7 1-3 1.4-5.7 1.3-8.1-.1-2.6-.8-4.8-1.7-6.6-1.9-3.7-4.8-5.5-6.3-5.6-3.1-.2-3.9 2.3-1.2 7.2 1.4 2.5 3.3 5.9 4.9 8.8.8 1.4 1.5 2.7 2 3.6 0 .1.1.1.1.2-6.6-3.5-13.4-6.8-20.3-9.7 1.3-5.2.4-9.2-1.3-11.9-1.8-2.8-4.2-4.1-5.4-4.1-1.6.3-2.3.8-2.4 1.8z"/></g><g><path class="st2" d="M100.2 255.8c1-.1 2.4.5 4.3 1.8 3 2.2 7.8 5.7 9.6 7-2.4-6.5-4.6-13.2-6.4-19.9-4.6.6-7.9-.6-9.9-2.3-2.1-1.7-3-3.9-2.8-4.9.3-2 2-2.2 4.9.2 1.5 1.2 3.4 3 5.1 4.4 1.2 1 2.2 1.9 2.6 2.3-.6-2.1-1.1-4.1-1.6-6.2h-.2c-1.3-.3-2.8-3.4-3.5-4.5-1.5-2.4-2.5-5.2-2.5-8 0-1.2.4-2.6 1.5-3 1.3-.5 2.6.6 3.4 1.6 2.3 3 3.3 6.8 3.1 10.5-.1 1-.2 3.3-1.5 3.4.6 2 1.2 4.1 1.8 6.1.2-.6.6-1.9 1-3.4.7-2.1 1.6-4.5 2.3-6.2 1.4-3.4 3.1-3.9 4.3-2.4.6.7.8 2.8-.2 5.2-1 2.4-3.1 5-7.3 7 2 6.6 4.4 13.2 6.9 19.6.2-.8.5-2.2.8-3.7.6-2.5 1.4-5.6 2.1-7.6.7-2.1 1.5-3.3 2.4-3.8.9-.5 1.7-.3 2.5.5s1.2 3.3.3 6.2c-.9 2.8-3.2 6.2-7.8 8.9 2.8 6.9 5.9 13.8 9.3 20.4v-.3c.1-1 .3-2.4.5-4s.5-3.3.8-5c.3-1.7.7-3.3 1-4.6 1.3-5.2 3.6-6.3 5.7-4.5 1.1.9 1.8 3.9 1 7.5-.4 1.8-1.3 3.9-2.8 6-1.3 1.9-3.2 3.9-5.8 5.8 3.2 6.2 6.6 12.2 10.3 18.1 0-1.2.1-2.8.2-4.6.1-1.8.3-3.9.5-5.8.1-1 .2-1.9.3-2.8.1-.9.3-1.7.4-2.5 1-6 3.5-7.5 6.1-5.6.6.5 1.2 1.5 1.6 3 .3 1.5.5 3.3.2 5.5s-1.1 4.5-2.6 7.1c-1.3 2.3-3.2 4.7-5.9 7 3.6 5.7 7.5 11.3 11.6 16.7l-2.2 1.6c-4-5.6-7.8-11.3-11.3-17.2-3.3 1.3-6.3 1.9-9 1.9-3 0-5.6-.6-7.7-1.6-2.1-1-3.8-2.3-4.9-3.6-1.1-1.3-1.8-2.6-1.8-3.4-.3-3.5 2.4-4.5 8.2-1.7 2.9 1.5 7 3.5 10.3 5.2 1.7.8 3.1 1.5 4.2 2-3.6-6-6.9-12.2-9.9-18.5-3 .9-5.7 1.2-8.1 1-2.6-.2-4.7-1-6.5-2-3.6-2-5.3-5-5.4-6.6 0-3.1 2.4-3.8 7.2-.9 2.4 1.5 5.8 3.5 8.6 5.2 1.4.9 2.6 1.6 3.5 2.1.1 0 .1.1.2.1-3.3-6.8-6.2-13.7-8.8-20.7-5.3 1.1-9.2 0-11.9-1.8-2.7-1.9-3.9-4.4-3.8-5.6-.1-.9.5-1.6 1.5-1.7z"/></g><g><path class="st4" d="M106.8 558.3c0 13.1 8.1 23.7 18.2 23.7h455c10.1 0 18.2-10.6 18.2-23.7H106.8z"/><path class="st2" d="M155.4 290.9H549.6V538.5H155.4z"/><path class="st3" d="M556.6 264.8h-408c-7.6 0-13.8 6.2-13.8 13.8V540c0 7.6 6.2 13.8 13.8 13.8h408c7.6 0 13.8-6.2 13.8-13.8V278.6c0-7.7-6.2-13.8-13.8-13.8z"/><path class="st1" d="M155.4 285.5H549.6V533.1H155.4z"/><path class="st0" d="M295.7 558.3L196.6 558.3 196.9 553.9 197.3 548.4 294.9 548.4zM508.6 558.3L409.4 558.3 409.8 553.9 410.2 548.4 507.8 548.4z"/></g><g><path class="st0" d="M188 451.7H222.4V455.59999999999997H188zM235 451.7H269.4V455.59999999999997H235zM328.6 451.7H353.5V455.59999999999997H328.6zM374.8 451.7H413.5V455.59999999999997H374.8zM342.3 465.1H359.90000000000003V469H342.3zM342.3 475.3H359.90000000000003V479.2H342.3zM342.3 485.6H359.90000000000003V489.5H342.3zM342.3 495.8H359.90000000000003V499.7H342.3z"/><path class="st6" d="M209.7 465.1H222.39999999999998V469H209.7z"/><path class="st2" d="M209.7 475.3H222.39999999999998V479.2H209.7z"/><path class="st4" d="M209.7 485.6H222.39999999999998V489.5H209.7z"/><path class="st5" d="M209.7 495.8H222.39999999999998V499.7H209.7z"/><path class="st0" d="M399.7 465.1H417.3V469H399.7zM399.7 475.3H417.3V479.2H399.7zM399.7 485.6H417.3V489.5H399.7zM399.7 495.8H417.3V499.7H399.7zM234.6 465.1H252.2V469H234.6zM234.6 475.3H260.7V479.2H234.6zM234.6 485.6H267.5V489.5H234.6zM234.6 495.8H249.7V499.7H234.6zM180.4 314.6H306V321.5H180.4z"/><path class="st4" d="M180.4 340.4H198.20000000000002V347.9H180.4zM216.1 340.4H233.9V347.9H216.1zM251.8 340.4H269.6V347.9H251.8zM287.5 340.4H305.3V347.9H287.5zM323.3 340.4H341.1V347.9H323.3zM359 340.4H376.8V347.9H359zM394.7 340.4H412.5V347.9H394.7z"/><g><path class="st0" d="M180.4 355.7H430.20000000000005V358H180.4z"/></g><g><path class="st0" d="M427.7 446.2H181v-90.4h-2v92.5h250.7v-92.5h-2v90.4z"/><path class="st0" d="M405.1 355.7H407.1V447.2H405.1zM382.4 355.7H384.5V447.2H382.4zM359.8 355.7H361.8V447.2H359.8zM337.2 355.7H339.2V447.2H337.2zM314.6 355.7H316.6V447.2H314.6zM292 355.7H294V447.2H292zM269.4 355.7H271.4V447.2H269.4zM246.8 355.7H248.8V447.2H246.8zM224.2 355.7H226.2V447.2H224.2zM201.6 355.7H203.7V447.2H201.6z"/><path class="st0" d="M179 355.7H429.7V357.7H179zM180 378.4H428.7V380.4H180zM180 401H428.7V403H180zM180 423.6H428.7V425.6H180z"/><g><path class="st2" d="M203.6 396.2H219.79999999999998V446.3H203.6zM248.8 385.8H265V446.3H248.8zM294.1 410.5H310.3V446.3H294.1zM339.3 373.7H355.5V446.29999999999995H339.3zM384.5 393.3H400.7V446.3H384.5z"/></g><g><path class="st6" d="M201.6 396.2H217.79999999999998V446.3H201.6zM246.8 385.8H263V446.3H246.8zM292 410.5H308.2V446.3H292zM337.2 373.7H353.4V446.29999999999995H337.2zM382.5 393.3H398.7V446.3H382.5z"/></g></g><g><path class="st0" d="M179 471.1H429.7V473.20000000000005H179z"/></g><g><path class="st0" d="M179 481.3H429.7V483.40000000000003H179z"/></g><g><path class="st0" d="M179 491.6H429.7V493.70000000000005H179z"/></g><g><path class="st0" d="M179 501.8H429.7V503.90000000000003H179z"/></g><g><path class="st6" d="M473.5 352.4c.9-5.5 5.4-9.8 10.9-10.6l-.2-5.1-.5-12.6c-14.7 1.2-26.4 12.7-27.9 27.2l12.6.8 5.1.3z"/><path class="st5" d="M491.1 366.7c-1.5.6-3.1.9-4.8.9-2.9 0-5.6-.9-7.7-2.5l-3.5 3.8-8.5 9.2c5.3 4.5 12.2 7.2 19.7 7.2 4.7 0 9.1-1 13-2.9l-5.9-11.1-2.3-4.6zM516.3 361.3l-12.4-2.1c-1.2 4.6-4 8.4-7.9 10.9l5.9 11.1 2.7 5.2c8.8-5.1 15.3-13.8 17.5-24.1l-5.8-1z"/><path class="st6" d="M468.2 354.9l-12.6-.8-5.9-.4v.9c0 10.1 4.1 19.3 10.7 25.9l4-4.3 8.5-9.2c-2.8-3.2-4.6-7.4-4.7-12.1z"/><path class="st4" d="M495.9 339.3l-2.4 4.6c3.5 2.3 5.7 6.3 5.7 10.8v.8l5.1.9 12.4 2.1c.2-1.3.2-2.5.2-3.8 0-11.3-6.1-21.2-15.2-26.5l-5.8 11.1zM487 336.6c2.3.1 4.4.6 6.4 1.4l5.8-11.2 2.7-5.2c-4.7-2.3-10-3.5-15.7-3.5l.2 5.9.6 12.6z"/></g><g><path class="st0" d="M446.7 407.2H525.1V411.09999999999997H446.7zM446.7 441.5H450.59999999999997V445.4H446.7zM454.4 441.5H525.1V445.4H454.4z"/><path class="st4" d="M446.7 456.1H450.59999999999997V460H446.7z"/><path class="st0" d="M454.4 456.1H525.1V460H454.4z"/><path d="M446.7 470.8H450.59999999999997V474.7H446.7z" style="fill:#6292ff"/><path class="st0" d="M454.4 470.8H525.1V474.7H454.4z"/><path d="M446.7 485.4H450.59999999999997V489.29999999999995H446.7z" style="fill:#da5544"/><path class="st0" d="M454.4 485.4H525.1V489.29999999999995H454.4z"/><path class="st5" d="M446.7 500H450.59999999999997V503.9H446.7z"/><path class="st0" d="M454.4 500H525.1V503.9H454.4zM446.7 417.7H525.1V430.7H446.7z"/></g></g><g><path class="st3" d="M522.8 556.7c.3-.3.7-.5 1.1-.6.4-.1.8-.1 1.3-.1 1-.1 2-.3 2.9-.8.5-.3.9-.6 1.4-.8l2.9.1c.4.4.7 1 .8 1.6.1.5.1 1.1.1 1.6v.6h-10.8v-.6c0-.4 0-.8.3-1z"/><path class="st9" d="M532.7 551.2L532.4 554.5 529.4 554.4 529.2 551.4z"/><path class="st3" d="M494 555.5c.3-.3.7-.4 1.1-.5.4 0 .8 0 1.3.1 1 .1 2.1-.1 3-.5.5-.2 1-.5 1.5-.6l2.9.4c.4.5.5 1.1.6 1.7.1.5 0 1.1-.1 1.6l-.1.6-10.7-1.2.1-.6c0-.4.1-.8.4-1z"/><path class="st4" d="M535.3 503.7c.6-11.4.5-27.5-2.6-36.6 0-.2-23.9 2-23.9 2l-5.6 22.9c-2 8.1-2.9 16.3-2.8 24.6l.3 34.4 4 .3 7.5-45.5c2.8-5.4 5.8-11.6 8.1-17.7l8.7 63.4 4-.2c0-.1 2.3-47.6 2.3-47.6z"/><path class="st9" d="M504.5 551.2L503.8 554.4 500.9 554 501 551z"/><path class="st10" d="M481.6 394.3c.7-.3 1.6 0 1.9.7 2 4 4.2 7.8 6.6 11.5 2.4 3.7 5 7.2 7.8 10.5s5.8 6.4 9.1 9.1c1.6 1.4 3.3 2.7 5 3.9.4.3.9.6 1.3.9l1.3.9c.9.6 1.8 1.1 2.7 1.7.3.2.5.4.8.6.2.2.4.5.6.7.3.5.6 1.1.7 1.8.3 1.3.1 2.7-.7 4-.8 1.3-2 2.1-3.3 2.3-.7.1-1.4.1-2.1 0-.3-.1-.7-.2-1-.3l-.9-.6c-.9-.7-1.8-1.5-2.7-2.3l-1.3-1.2c-.4-.4-.9-.8-1.3-1.2-1.7-1.6-3.4-3.4-4.9-5.1-3.1-3.5-6-7.3-8.5-11.2-2.5-3.9-4.7-8-6.6-12.2-1.9-4.2-3.6-8.4-5.1-12.7-.5-.7-.1-1.5.6-1.8z"/><path class="st2" d="M500.2 434.6l9.4 7.3c2.8 2.2 6.8 1.9 9-.9s1.8-7.2-1.1-9.4l-9.4-7.3-7.9 10.3z"/><path class="st2" d="M521.8 428.5c-9-.1-16 7.9-14.8 16.8l1.8 23.7c10 3.6 17.5 1.6 23.9-2l1.1-25.2c.7-7.1-4.9-13.2-12-13.3z"/><path class="st1" d="M531.8 433.5l-.2.2c1 1.4 1.7 3 2 4.7h.3c-.3-1.7-1-3.4-2.1-4.9zm-9.9 37.3v.3c2.2-.2 4.4-.8 6.6-1.7l-.1-.2c-2.1.8-4.2 1.3-6.5 1.6zm5.1-41.3c-1.6-.8-3.4-1.2-5.2-1.2h-.2c-4.3 0-8.5 1.9-11.3 5.2-1.7 1.9-2.8 4.2-3.3 6.6l.3.1c1.5-6.6 7.4-11.6 14.5-11.5 1.9 0 3.6.5 5.2 1.2v-.4zM508.6 466c-.1 0-.2.1-.3.1l.2 3 .2.1c2.2.8 4.5 1.4 6.6 1.7v-.3c-2.1-.3-4.2-.8-6.5-1.7l-.2-2.9zm-1.8-20.6l.9 12h.3l-.9-12h-.3z"/><path class="st3" d="M524 412.1s6.2 1.5 4.7 8.4c-1 4.6-4.4 7-9.2 7.8l4.5-16.2z"/><path class="st9" d="M517.5 423.7l.5 7.1c2 1.2 4 1.1 5.9-.3l-.5-7.1-5.9.3z"/><path class="st10" d="M517.6 424.6l.1 2.2c.9.5 1.9.7 3 .7h.2c1-.1 2-.5 2.7-1.2l-.1-2.1-5.9.4z"/><path class="st9" d="M514.6 415.4l.4 5.3.1 1.2c.3 2.9 2.7 5.1 5.6 5.1.3 0 .6 0 .9-.1.1 0 .2-.1.3-.1h.1c.4-.2.8-.4 1.1-.8.7-.8 1.1-1.6 1.5-2.5.3-.7.6-1.5.8-2.2.2-.9.4-1.8.2-2.8l-.4-4.6-9-.7-1.6 2.2z"/><path class="st3" d="M523.9 414s-10.3.6-8.2 9.7c0 0-3.2-6.5.1-10.9 3.6-4.8 8.5-3.2 10.2-.9 1.7 2.3 3 6.1-1.8 8.9-.1-.1 1.5-3.5-.3-6.8z"/><path class="st9" d="M523.7 419.5c.1 1.2 1.1 2.1 2.3 2 1.2-.1 2.1-1.1 2-2.3-.1-1.2-1.1-2.1-2.3-2-1.2.1-2.1 1.1-2 2.3z"/><g><path class="st3" d="M503.8 450.8l-7.4-8c4.5-4.2 6.9-9.8 6.9-15.9h10.9c0 9.1-3.8 17.8-10.4 23.9z"/><path class="st4" d="M514.2 427h-10.9c0-12-9.7-21.7-21.7-21.7-2.6 0-5.1.4-7.5 1.3l-3.8-10.2c3.6-1.3 7.4-2 11.3-2 18-.1 32.6 14.6 32.6 32.6z"/><path class="st2" d="M481.6 459.6c-18 0-32.6-14.6-32.6-32.6 0-13.6 8.6-25.9 21.4-30.6l3.8 10.2c-8.5 3.1-14.2 11.3-14.2 20.4 0 12 9.7 21.7 21.7 21.7 5.5 0 10.8-2.1 14.8-5.8l7.4 8c-6.1 5.6-14 8.7-22.3 8.7z"/></g><g><path class="st9" d="M471.1 455.3c0-.8.5-1.5 1.3-1.5 4.4-.5 8.8-1.1 13.1-2.1 4.3-.9 8.5-2.1 12.6-3.5 4.1-1.5 8-3.2 11.8-5.2 1.9-1 3.7-2.1 5.5-3.3.4-.3.9-.6 1.3-.9l1.3-.9c.8-.6 1.7-1.2 2.5-1.9.3-.2.6-.4.8-.5l.9-.3c.6-.1 1.3-.1 1.9 0 1.3.2 2.6.9 3.5 2.1.9 1.2 1.2 2.6 1 3.9-.1.7-.4 1.3-.8 1.9-.2.3-.4.6-.7.8-.3.3-.6.5-.9.7-1 .6-2.1 1.2-3.1 1.7l-1.6.8c-.5.3-1.1.5-1.6.8-2.1 1-4.3 2-6.5 2.8-4.4 1.7-9 3-13.5 3.9-4.6.9-9.2 1.5-13.7 1.9-4.6.3-9.1.4-13.7.3-.8 0-1.4-.7-1.4-1.5z"/><path class="st2" d="M515.5 452.5l10.1-6.2c3.1-1.9 4.3-5.7 2.4-8.8-1.9-3.1-6.1-4.2-9.1-2.3l-10.1 6.2 6.7 11.1z"/><path class="st1" d="M529.1 439.4c-.1-.7-.4-1.4-.8-2-.9-1.5-2.5-2.7-4.3-3.1-.3-.1-.6-.1-.9-.2v.3c2 .3 3.9 1.4 4.9 3.2 1.4 2.3 1.1 5-.5 7l.2.1c1.3-1.5 1.8-3.4 1.4-5.3zm-3.3 7.1s.1 0 .1-.1l-.3-.1-3 1.8.2.2 3-1.8zm-4.2 2.6l-.2-.2-2.9 1.7.2.2 2.9-1.7zm-4.4 2.6l-.2-.2-1.5.9-5.2-8.5-.2.1 5.3 8.8 1.8-1.1zm.2-15.9l-7.4 4.5.1.2 7.4-4.5-.1-.2z"/></g></g><g><path class="st10" d="M234.4 464c0-.8-.5-1.5-1.3-1.6-2.3-.3-4.6-.6-6.9-1-2.3-.4-4.5-.8-6.7-1.3s-4.3-1.2-6.2-2c-1.9-.8-3.7-1.9-5.3-3.1-3.2-2.5-5.7-6-8-9.7-.3-.5-.6-.9-.9-1.4l-.8-1.4c-.6-1-1.1-2-1.7-3-1.1-2-2.2-4-3.2-6.1-1.4-2.6-4.7-3.5-7.2-2s-3.3 4.8-1.7 7.3c1.4 1.9 2.7 3.9 4.1 5.8.7 1 1.4 1.9 2.2 2.9l1.1 1.4c.4.5.8.9 1.1 1.4 1.6 1.9 3.2 3.7 5 5.5 1.8 1.8 3.9 3.4 6.1 4.8 2.3 1.3 4.7 2.3 7.2 3 2.5.7 4.9 1.1 7.3 1.3 2.4.2 4.8.4 7.1.4 2.4.1 4.7.1 7 .1 1 0 1.7-.6 1.7-1.3z"/><path class="st3" d="M190.5 450.4l-6.3-10c-1.9-3-1.3-7 1.8-8.9 3-1.9 7.3-1.1 9.2 2l6.3 10-11 6.9z"/><path class="st9" d="M181.4 505.2L189.7 554.4 192.6 553.9 193.4 504.8z"/><path class="st4" d="M194.2 504.7l-13.6.5c-3.7-9-6.9-28.9-3.1-38.1l15.2 3.4 1.5 34.2z"/><circle transform="rotate(-16.739 184.847 470.406)" class="st4" cx="184.8" cy="470.4" r="7.9"/><g class="st11"><path class="st4" d="M184.8 470.4L184.8 470.4 184.8 470.4 184.8 470.3z"/></g><path class="st9" d="M165.9 503.2L161.1 553.4 164.1 553.6 177.6 505.8z"/><path class="st4" d="M180.4 462.7c-3.2-1-6.5.2-8.5 2.7-.1.2-.3.4-.4.6-5.7 8.3-7.5 27.6-6.3 37l13.2 3 7.3-33.4c1.3-4.2-1.1-8.6-5.3-9.9z"/><path class="st2" d="M180.4 497.1l-1.9 8.9-2.2-.5v.3l2.4.5 2-9.1-.3-.1zm-11.9-25.8c-1.3 3.5-2.4 7.8-3.1 12.8v.3h.3c.6-4.6 1.7-9.1 3.1-12.9l-.3-.2zm-3.9 23.7h.3c0-2.2.2-4.5.4-6.8h-.3c-.3 2.3-.4 4.6-.4 6.8zm.6 8c-.2-1.3-.3-2.8-.3-4.4h-.3c.1 1.6.2 3.1.3 4.4v.2l8 1.8.1-.2-7.8-1.8zm18.6-21.8l-1.7 7.9h.3l1.7-7.9h-.3z"/><path class="st3" d="M170.4 556.6c-.2-.4-.6-.6-1-.7-.4-.1-.8-.1-1.2-.2-1-.2-2-.6-2.8-1.2-.4-.3-.8-.7-1.3-.9l-2.9-.3c-.4.4-.8.9-1 1.5-.2.5-.2 1.1-.3 1.6l-.1.6 10.7 1.2.1-.6c.1-.3 0-.7-.2-1zM199.5 555.1c-.3-.3-.7-.4-1.2-.4-.4 0-.8.1-1.3.1-1 .1-2.1 0-3-.4l-1.5-.6-2.9.5c-.3.5-.5 1.1-.5 1.7 0 .5 0 1.1.1 1.6l.1.6 10.7-1.6-.1-.6c0-.3-.1-.6-.4-.9zM182 428.8c9 .1 15.8 8.2 14.4 17.1l-3.6 24c-6.5 2.3-15.6 1.5-23.1-.7v-27.4c-.5-7.1 5.2-13.1 12.3-13z"/><path class="st1" d="M169.4 457.4v10.4h.3v-10.4h-.3zm12.6-28.8h-.1c-.4 0-.8 0-1.2.1v.3c.4 0 .8-.1 1.3-.1 2.1 0 4 .5 5.8 1.2l.1-.2c-1.8-.9-3.8-1.3-5.9-1.3zm11.3 5.3c-.8-.9-1.7-1.8-2.7-2.5l-.2.2c3.7 2.7 6.1 7.1 6.1 11.9 0 .8-.1 1.6-.2 2.4l-.8 5.3.3.1.8-5.3c.7-4.4-.5-8.8-3.3-12.1zm-.6 36c-5.9 2.1-13.9 1.6-20.9-.1v.3c4 1 8.1 1.5 11.8 1.5 3.5 0 6.6-.5 9.2-1.4l.1-.1 2.1-13.8h-.3l-2 13.6zm-16.9-39.5l-.1-.3c-1.1.6-2.1 1.4-2.9 2.3-2.4 2.5-3.6 5.9-3.3 9.3v6.4h.3v-6.4c-.4-4.7 2.1-9 6-11.3z"/><g><path class="st9" d="M186.2 424.7l-.4 7.3c-2.1 1.1-4 1-5.9-.4l.4-7.3 5.9.4z"/><path class="st10" d="M186.1 426.9v.8c-.9.5-2 .7-3.1.7h-.2c-1-.1-1.9-.5-2.6-1.2l.1-2.1 5.8 1.8z"/><path class="st9" d="M189.3 416.4l-.5 5.2-.1 1.2c-.3 2.9-2.8 5.1-5.7 5-.3 0-.6-.1-.9-.1-.1 0-.2-.1-.3-.1h-.1c-.4-.2-.8-.5-1.1-.8-.6-.8-1-1.6-1.4-2.5-.3-.8-.6-1.5-.8-2.3-.2-.9-.3-1.8-.2-2.8l.2-3.6 9.3-1.5 1.6 2.3z"/><path class="st3" d="M189 424.6s0-3.1-.1-4.6c-.1-1.4-.4-2.8-1.5-2.6-2.1.4-2.9-1.4-2.9-1.4-.6 0-1.2.1-1.9.3-3.1.8-3.6 0-4-.5-.8 2.4-.5 5.5-.5 5.8 0 .1.1.3.1.4.2.8.5 1.5.8 2.3.3.7.6 1.4 1 2v.5c-2.2-.4-4.9-2.8-5.6-4.7-2.3-7.2 1.6-11.5 7.1-12.6 4.8-.9 7.4 3.5 8.4 7.5.8 2.3-.3 7-.9 7.6z"/><path class="st9" d="M180.2 420.3c-.1 1.2-1.1 2.1-2.3 2-1.2-.1-2.1-1.1-2-2.3.1-1.2 1.1-2.1 2.3-2 1.2.1 2 1.1 2 2.3z"/></g><g><path transform="rotate(-180 274.437 454.01)" class="st2" d="M269 446.1H279.8V462H269z"/><path transform="rotate(-180 260.511 447.387)" class="st2" d="M255.1 432.8H265.9V461.90000000000003H255.1z"/><path transform="rotate(-180 246.585 443.424)" class="st4" d="M241.2 424.9H252V461.9H241.2z"/><path transform="rotate(-180 232.659 439.712)" class="st4" d="M227.2 417.5H238V461.9H227.2z"/><path transform="rotate(-180 218.732 441.217)" class="st4" d="M213.3 420.5H224.10000000000002V461.9H213.3z"/><path transform="rotate(-180 204.806 443.424)" class="st2" d="M199.4 424.9H210.20000000000002V461.9H199.4z"/><path transform="rotate(-180 190.88 447.387)" class="st4" d="M185.5 432.8H196.3V461.90000000000003H185.5z"/><g><path transform="rotate(-180 232.659 462.663)" class="st3" d="M183.1 461.9H282.3V463.4H183.1z"/></g></g><g><path class="st9" d="M227.5 461.9c-.1-.8-.7-1.4-1.5-1.4h-6.9c-2.3-.1-4.6-.2-6.8-.4s-4.4-.6-6.4-1.1c-2-.6-3.9-1.3-5.7-2.4-3.5-2.1-6.5-5.1-9.3-8.5-.4-.4-.7-.8-1.1-1.3l-1-1.3c-.7-.9-1.4-1.8-2-2.7-1.4-1.8-2.7-3.7-4-5.6-1.7-2.3-5.1-2.8-7.4-.9-2.3 1.8-2.6 5.3-.6 7.5 1.6 1.7 3.2 3.4 4.9 5.2.8.9 1.7 1.7 2.5 2.6l1.3 1.3c.4.4.9.8 1.3 1.2 1.8 1.7 3.7 3.3 5.8 4.8 2.1 1.5 4.3 2.8 6.7 3.9 2.4 1 5 1.7 7.5 2 2.5.3 5 .4 7.4.3 2.4-.1 4.8-.3 7.1-.6s4.7-.6 7-.9c.7-.3 1.2-1 1.2-1.7z"/><path class="st3" d="M181.9 454.2l-7.7-9c-2.3-2.7-2.2-6.7.5-9.1 2.7-2.3 7.1-2.1 9.4.7l7.7 9-9.9 8.4z"/><path class="st1" d="M179.6 434.3c-1.2-.1-2.3.1-3.4.6l.1.2c2.6-1.2 5.9-.6 7.8 1.7l.7.8.2-.2-.7-.8c-1.1-1.3-2.8-2.2-4.7-2.3zm12.2 11.6l-8.1 6.8.2.2 8.3-7-4.4-5.2-.2.2 4.2 5zm-18-9.1c-1.8 2.1-1.9 5.2-.4 7.7l.2-.1c-1.4-2.3-1.4-5.3.3-7.4l-.1-.2zm7.4 17l.2-.2-3.7-4.4-.2.2 3.7 4.4z"/></g></g><g><path class="st3" d="M630.9 587.7H74.2c-1.6 0-2.9-1.3-2.9-2.9 0-1.6 1.3-2.9 2.9-2.9H631c1.6 0 2.9 1.3 2.9 2.9-.1 1.6-1.4 2.9-3 2.9z"/></g><g><path transform="rotate(-40.957 194.403 297.627)" class="st2" d="M179.5 288.7H209.2V306.4H179.5z"/><path transform="rotate(-40.957 148.955 337.083)" class="st4" d="M103.6 323.8H194.2V350.40000000000003H103.6z"/><path class="st4" d="M294.2 300.4c28.1-24.4 31.2-67.2 6.7-95.3-24.4-28.1-67.2-31.2-95.3-6.7-25.9 22.5-30.5 60.4-12.1 88.2 1.6 2.4 3.4 4.8 5.4 7.1 2 2.3 4.1 4.4 6.2 6.3 25 22.1 63.3 22.9 89.1.4zm-76.9-88.6c20.7-18 52.3-15.8 70.3 5s15.8 52.3-5 70.3-52.3 15.8-70.3-5-15.8-52.3 5-70.3z"/><g style="opacity:.5"><path class="st2" d="M212.3 282.1c-18-20.8-15.8-52.3 5-70.3 20.7-18 52.3-15.8 70.3 5s15.8 52.3-5 70.3c-20.7 17.9-52.3 15.7-70.3-5z"/></g><g><path class="st1" d="M263.6 217c.2-.4.4-.7.8-1 1-.8 2.5-.5 3.2.5l20.8 28.3c.8 1 .5 2.5-.5 3.2-1 .8-2.5.5-3.2-.5l-20.8-28.3c-.5-.6-.6-1.5-.3-2.2zM252.5 225.2c.2-.4.4-.7.8-1 1-.8 2.5-.5 3.2.5l20.8 28.3c.8 1 .5 2.5-.5 3.2-1 .8-2.5.5-3.2-.5l-20.8-28.3c-.5-.6-.6-1.5-.3-2.2z"/></g></g><g><path class="st3" d="M410 551.8l-12.9 6.5c-.2-.4-.3-.9-.2-1.4.1-.6.5-1 .9-1.3.5-.3 1-.6 1.5-.8 1.2-.7 2.2-1.6 3-2.8.4-.6.7-1.2 1.2-1.8l3.6-1.7c.7.3 1.4.8 1.9 1.4.4.5.7 1.2 1 1.9zM422.6 556.4l-14.4 1.9c-.1-.5 0-1 .2-1.4.3-.5.8-.8 1.3-1 .5-.2 1.1-.2 1.7-.3 1.4-.2 2.6-.8 3.7-1.6.6-.4 1.1-.9 1.7-1.3l3.9-.4c.6.5 1.1 1.2 1.3 2 .4.7.5 1.4.6 2.1zM416.5 508.6L416.5 508.6 416.5 508.6z"/><g class="st11"><path class="st3" d="M414.6 478.3L414.6 478.3 414.6 478.3 414.6 478.3z"/></g><path class="st3" d="M416.5 508.6L416.5 508.6 416.5 508.6zM416.5 508.6L416.5 508.6 416.5 508.6z"/><path class="st2" d="M384.1 510.1l18.8 40.3 4.7-1.9-12-37.6 9.7-15.3 11 57.2 5.1-.3.1-73.2c.1-.7-30.9-2.6-30.9-2.6l-6.8 30.4c-.1 1.1 0 2 .3 3zm32.4-1.5z"/><g class="st11"><path class="st3" d="M416.5 508.6L416.5 508.6 416.5 508.6z"/></g><path class="st10" d="M352.8 484.7c1.5-1.5 3-2.8 4.5-4.2.4-.3.7-.7 1.1-1 .4-.4.7-.7 1.1-1l1-1.1.5-.5.5-.5c2.7-2.9 5.1-6 7.3-9.3 1-1.7 2.1-3.3 3.1-5l.7-1.3c.1-.2.2-.4.4-.6l.3-.7 1.3-2.6c.1-.2.2-.4.3-.7l.3-.7.6-1.3.6-1.3.3-.7.2-.3.1-.3 1.1-2.7c.4-.9.7-1.8 1.1-2.8.4-1.2 1.4-2 2.6-2.4 1.2-.4 2.6-.4 3.9.2 1.3.6 2.3 1.6 2.8 2.7.5 1.2.5 2.5-.1 3.7-.5.9-1 1.8-1.6 2.8l-1.6 2.7-.2.3-.2.3-.4.7-.9 1.3-.9 1.3-.4.7c-.1.2-.3.4-.5.7l-1.8 2.6-.5.6c-.2.2-.3.4-.5.6l-1 1.3c-1.3 1.7-2.7 3.3-4.1 4.9-2.9 3.1-6 6.1-9.2 8.7l-.6.5-.6.5-1.3.9c-.4.3-.8.6-1.3.9-.4.3-.8.6-1.3.9-1.7 1.2-3.4 2.3-5 3.4-.5.4-1.3.3-1.9-.3-.3-.6-.3-1.4.2-1.9z"/><path class="st4" d="M383.8 465.5l5.6-12.2c1.7-3.7.4-8-3.3-9.7-3.7-1.7-8.4-.1-10 3.6l-5.6 12.2 13.3 6.1z"/><g><path class="st4" d="M389.8 435.7c-7.7 1.9-12.2 9.9-9.6 17.5l11.9 36.1c13.9 1.7 20.3-2.7 29.7-9.7l-12.4-33.5c-3-7.9-11.4-12.4-19.6-10.4z"/></g><g><path class="st1" d="M403.3 438.2c1.4 1 2.7 2.2 3.8 3.7l.3-.3c-1.1-1.4-2.4-2.7-3.8-3.7l-.3.3zm16 43.1l.2.3c.8-.6 1.5-1.1 2.3-1.7l.2-.2-3.2-8.7-.4.1 3.1 8.5c-.6.5-1.4 1.1-2.2 1.7zm-27.6 8.3h.3c2.7.3 5.1.5 7.3.4l-.1-.4c-2.2.1-4.5 0-7.2-.3l-7-21.2-.4.1 7.1 21.4zm20.6-3.7c-1 .5-1.9 1-3 1.4l.2.4c1.1-.5 2.1-1 3.1-1.5l-.3-.3zm2.1-26l.4-.2-3-8-.4.2 3 8zm-32.6-.2l.4-.1-2.1-6.5c-2.3-6.8 1.2-14.1 7.6-16.8.3-.1.6-.3 1-.4l-.2-.4-.9.3c-6.8 2.8-10.3 10.4-7.9 17.4l2.1 6.5z"/></g><g><path class="st4" d="M353.2 491.4c8.2 7.9 20.6 10.6 31.7 6l-11.3-26.9-20.4 20.9z"/><path class="st3" d="M373.6 470.4l29.2-1.4c-.2-3.3-.9-6.7-2.3-9.9-2.7-6.4-7.4-11.3-13.1-14.4l-13.8 25.7zM362.2 443.5l11.3 26.9 13.9-25.7c-7.4-4-16.7-4.8-25.2-1.2z"/><path class="st3" d="M373.6 470.4l11.3 26.9c11.6-4.9 18.4-16.4 17.8-28.3l-29.1 1.4z"/><path class="st4" d="M346.7 481.8c1.6 3.7 3.8 7 6.6 9.6l20.4-21-29-3.4c-.7 4.9-.1 9.9 2 14.8z"/></g><g><path class="st9" d="M371.3 467.3c.5-.6 1.3-.7 1.9-.3 1.8 1.2 3.7 2.4 5.6 3.4.9.5 1.9 1 2.9 1.4 1 .4 1.9.8 2.9 1.1 1 .3 1.9.5 2.8.5h1.2c.4 0 .8-.1 1.1-.2.3-.1.6-.3.9-.4.3-.2.5-.4.8-.6.2-.2.5-.5.7-.8.2-.3.4-.6.6-1 .4-.7.7-1.6 1-2.5.3-.9.5-1.8.7-2.8.4-2 .6-4 .7-6.1.1-2.1.2-4.2.2-6.4 0-2.1-.1-4.3-.2-6.5 0-1.4.5-2.6 1.5-3.6.9-.9 2.2-1.5 3.7-1.5 1.4 0 2.7.6 3.7 1.6.9 1 1.4 2.3 1.3 3.7-.2 2.3-.4 4.6-.8 6.8-.3 2.3-.7 4.6-1.1 6.9-.5 2.3-1 4.6-1.8 7-.4 1.2-.8 2.3-1.4 3.5-.6 1.2-1.2 2.3-2 3.4-.4.6-.9 1.1-1.4 1.6-.5.5-1.1 1-1.7 1.4-.6.4-1.3.8-2.1 1.1-.7.3-1.5.5-2.2.6-.7.1-1.5.1-2.2.1h-.6l-.5-.1c-.3-.1-.7-.1-1-.2-1.3-.3-2.5-.7-3.6-1.2s-2.2-1.1-3.2-1.7c-1-.6-2-1.3-2.9-2-1.9-1.4-3.6-2.8-5.3-4.3-.6-.5-.7-1.3-.2-1.9z"/><path class="st4" d="M406.8 461.8l.4-13.4c.1-4.1-2.8-7.6-6.8-7.7-4.1-.1-7.7 3.2-7.9 7.2l-.4 13.4 14.7.5z"/><path class="st1" d="M392.2 449.3h.4V448c0-.5.1-.9.2-1.4l-.4-.1c-.1.5-.2 1-.2 1.5v1.3zm11.3 12.8l3.7.1.1-4.1-.4-.1-.1 3.8-3.3-.1v.4zm3.7-11.4l-.2 4.9h.4l.2-5-.4.1zm-12.7-7.9l.4.1c1.5-1.4 3.4-2.2 5.5-2.2 1.7.1 3.3.7 4.4 1.8l.2-.3c-1.3-1.2-2.9-1.8-4.6-1.9-1.1 0-2.2.2-3.3.6-1 .5-1.9 1.1-2.6 1.9zm-2.6 15.2l.4.1.1-3.4h-.4l-.1 3.3zm4.5 3.9l3.4.1v-.4l-3.3-.1-.1.4z"/></g><g><path class="st9" d="M383 434.5l4.8 8.4c2.5.2 4.3-.9 5.5-3.1l-4.8-8.4-5.5 3.1z"/><path class="st10" d="M383 434.5l2.2 3.8c.5-.1.9-.2 1.4-.4.1-.1.3-.1.4-.2 1.5-.9 2.5-2.4 2.8-4l-1.3-2.3-5.5 3.1z"/><path class="st9" d="M377.6 430.8l2.1 3.5.9 1.4c1.2 1.9 3.7 2.7 5.7 1.7.7-.3 1.3-.8 1.7-1.3.1-.1.2-.3.3-.4.1-.1.1-.2.2-.3.5-.9.8-1.8.7-2.9-.1-.9-.2-1.8-.3-2.8-.1-.6-.2-1.1-.3-1.7-.5-2.5-2.3-4.6-4.7-5.3-2.1-.6-4-.1-5.6 1.6-1.7 1.8-2 4.4-.7 6.5z"/><path class="st3" d="M385.7 429.1h-.8s0-3.2-.8-3.9c-1.2-1-4.5.3-5.9 1.4-.4.4-.7.7-.7 1.3.1 1.2.5 3.5 1.7 5.5 0 0-5-5.2-4.5-9.4.3-2.8.8-4.8 4.3-4.8 5.5 0 9.6 2.7 11.2 8.4l-3.5.2-1 1.3z"/><g><path class="st9" d="M385.9 429.5c.6 1 2 1.3 3.1.7s1.6-2 1-3c-.6-1-2-1.3-3.1-.7-1.2.6-1.6 2-1 3z"/></g></g></g><g><path class="st5" d="M305 499.7H375.1V558.3H305z"/><path class="st13" d="M281.2 499.7H305V558.3H281.2z"/><path class="st13" d="M305 499.7L295.6 517.6 269.4 517.6 281.2 499.7zM386 519.6L316 524.1 305 499.7 375.1 504.2z"/><path class="st5" d="M386 519.6L316 519.6 305 499.7 375.1 499.7zM305 499.7L299.7 519.7 269.4 517.6 295.6 517.6z"/></g><g><path class="st0" d="M38.9 241.8c3.5-18.6 10.8-36.5 20.7-52.7 5-8.1 10.7-15.8 17.1-22.9 3.2-3.6 6.5-7 10-10.3 3.5-3.3 7.1-6.4 10.8-9.4 15-11.9 32.3-20.9 50.6-26.7 9.2-2.9 18.6-4.9 28.1-6.1 2.4-.3 4.8-.5 7.1-.8l3.6-.3c1.2-.1 2.4-.1 3.6-.2 4.8-.2 9.6-.2 14.4 0 4.8.2 9.6.7 14.3 1.3 4.8.6 9.5 1.5 14.2 2.5 2.3.5 4.7 1.1 7 1.7l3.5 1c1.2.3 2.3.7 3.4 1.1.6.2 1.1.4 1.7.5l1.7.6c1.1.4 2.3.8 3.4 1.2 1.1.4 2.2.8 3.4 1.3l3.3 1.4c.6.2 1.1.5 1.7.7l1.6.7 3.3 1.5 3.2 1.6 1.6.8 1.6.8 3.2 1.7 3.1 1.7 1.6.9 1.5.9 3.1 1.8c4.1 2.4 8.1 4.9 12.1 7.5 4 2.6 7.9 5.2 11.9 7.9 7.8 5.3 15.6 10.7 23.5 15.9 3.9 2.6 7.9 5.1 11.9 7.6 4 2.4 8.1 4.8 12.2 7.1 2 1.2 4.1 2.2 6.2 3.3 1 .6 2.1 1 3.2 1.6 1.1.5 2.1 1.1 3.2 1.5 2.1 1 4.3 2 6.5 2.8 1.1.4 2.2.9 3.3 1.3l3.3 1.2 3.3 1.2c1.1.4 2.2.8 3.4 1.1l3.4 1c.6.2 1.1.3 1.7.5l1.7.4c1.1.3 2.3.6 3.4.8l3.5.7c.6.1 1.2.2 1.7.3l1.7.3 3.5.5c-9.4-.8-18.8-2.7-27.8-5.6-9-2.9-17.8-6.7-26.3-11-4.3-2.1-8.4-4.4-12.5-6.8-4.1-2.4-8.2-4.8-12.2-7.3s-8-5.1-12-7.6l-11.9-7.7c-4-2.6-7.9-5.1-11.9-7.6s-8-4.9-12.1-7.3l-3.1-1.7-1.5-.9-1.5-.8-3.1-1.7-3.1-1.6-1.6-.8-1.6-.8-3.2-1.5-3.2-1.4-1.6-.7c-.5-.2-1.1-.4-1.6-.7-17.2-7.2-35.7-11.2-54.3-11.9-18.6-.8-37.4 1.5-55.2 6.9-4.5 1.3-8.9 2.9-13.2 4.6-4.3 1.7-8.6 3.7-12.7 5.8-8.3 4.2-16.2 9.2-23.7 14.8-7.4 5.7-14.4 11.9-20.8 18.8-6.4 6.8-12.2 14.2-17.4 22-10.6 15.9-18.3 33.3-22.9 51.7z"/></g><g><path class="st0" d="M658 370.2c6.5 13.9 10.3 29.1 11.5 44.5 1.1 15.4-.4 31.1-4.6 46.1-4.2 14.9-11.2 29.1-20.3 41.6-9.1 12.5-20.3 23.5-33.2 31.9 11.9-9.7 22.3-21 30.7-33.6 8.4-12.6 14.9-26.4 19-41 4.1-14.5 5.9-29.7 5.3-44.9-.4-15.1-3.3-30.2-8.4-44.6z"/></g><g><path class="st1" d="M639.8 422.2c.4 9.5-.9 19.2-3.6 28.3-1.4 4.6-3.1 9.1-5.2 13.4-2.1 4.3-4.6 8.5-7.3 12.4-2.8 3.9-5.9 7.6-9.2 11.1-3.4 3.4-7 6.6-10.9 9.4-7.7 5.7-16.4 10.1-25.5 12.9 8.8-3.5 17.1-8.3 24.6-14.1 3.7-2.9 7.2-6.1 10.5-9.5 3.3-3.4 6.3-7 9-10.9 2.7-3.8 5.1-7.9 7.3-12.1 2.1-4.2 3.9-8.6 5.4-13.1 2.9-8.8 4.5-18.2 4.9-27.8z"/></g></svg> \ No newline at end of file diff --git a/src/assets/svgs/member_balance.svg b/src/assets/svgs/member_balance.svg new file mode 100644 index 0000000..5395b23 --- /dev/null +++ b/src/assets/svgs/member_balance.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1693028338187" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="22985" width="128" height="128" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M983.8 312.7C958 251.7 921 197 874 150c-47-47-101.8-83.9-162.7-109.7C648.2 13.5 581.1 0 512 0S375.8 13.5 312.7 40.2C251.7 66 197 102.9 150 150c-47 47-83.9 101.8-109.7 162.7C13.5 375.8 0 442.9 0 512s13.5 136.2 40.2 199.3C66 772.3 102.9 827 150 874c47 47 101.8 83.9 162.7 109.7 63.1 26.7 130.2 40.2 199.3 40.2s136.2-13.5 199.3-40.2C772.3 958 827 921 874 874c47-47 83.9-101.8 109.7-162.7 26.7-63.1 40.2-130.2 40.2-199.3s-13.4-136.2-40.1-199.3z m-55.3 375.2c-22.8 53.8-55.4 102.2-96.9 143.7s-89.9 74.1-143.7 96.9C632.2 952.1 573 964 512 964s-120.2-11.9-175.9-35.5c-53.8-22.8-102.2-55.4-143.7-96.9s-74.1-89.9-96.9-143.7C71.9 632.2 60 573 60 512s11.9-120.2 35.5-175.9c22.8-53.8 55.4-102.2 96.9-143.7s89.9-74.1 143.7-96.9C391.8 71.9 451 60 512 60s120.2 11.9 175.9 35.5c53.8 22.8 102.2 55.4 143.7 96.9s74.1 89.9 96.9 143.7C952.1 391.8 964 451 964 512s-11.9 120.2-35.5 175.9z" fill="#000000" p-id="22986"></path><path d="M706 469.1H574.7l84.2-180.6c7-15 0.4-32.9-14.5-39.9-15-7-32.9-0.4-39.9 14.5L512 461.5l-92.5-198.3c-7-15-24.9-21.5-39.9-14.5s-21.5 24.9-14.5 39.9l84.2 180.6H318c-16.5 0-30 13.5-30 30s13.5 30 30 30h164v64h-92.5c-20.6 0-37.5 13.5-37.5 30s16.9 30 37.5 30H482v95c0 16.5 13.5 30 30 30s30-13.5 30-30v-95h92.5c20.6 0 37.5-13.5 37.5-30s-16.9-30-37.5-30H542v-64h164c16.5 0 30-13.5 30-30 0-16.6-13.5-30.1-30-30.1z" fill="#000000" p-id="22987"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/member_expenditure_balance.svg b/src/assets/svgs/member_expenditure_balance.svg new file mode 100644 index 0000000..02d498c --- /dev/null +++ b/src/assets/svgs/member_expenditure_balance.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1693028553383" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="28918" width="128" height="128" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M510.72 962.56C262.4 960 61.44 757.76 64 509.44 66.56 263.68 264.96 65.28 510.72 62.72c17.92 0 34.56 14.08 34.56 32s-14.08 34.56-32 34.56h-2.56C299.52 130.56 128 300.8 128 512s171.52 382.72 382.72 382.72S893.44 723.2 893.44 512c0-17.92 16.64-33.28 34.56-32 17.92 0 32 15.36 32 32 0 248.32-200.96 450.56-449.28 450.56z" fill="#000000" p-id="28919"></path><path d="M645.12 480H375.04c-17.92 0-34.56-14.08-34.56-32s14.08-34.56 32-34.56h272.64c17.92 0 33.28 16.64 32 34.56 0 17.92-14.08 32-32 32z m0 130.56H375.04c-17.92 0-33.28-16.64-32-34.56 0-17.92 15.36-32 32-32h270.08c17.92 0 33.28 16.64 32 34.56 0 16.64-14.08 32-32 32z" fill="#000000" p-id="28920"></path><path d="M510.72 746.24c-17.92 0-33.28-15.36-33.28-33.28V441.6c0-17.92 16.64-33.28 34.56-32 17.92 0 32 15.36 32 32v270.08c0 19.2-15.36 34.56-33.28 34.56z" fill="#000000" p-id="28921"></path><path d="M510.72 458.24c-8.96 0-17.92-3.84-24.32-10.24l-111.36-111.36c-14.08-12.8-15.36-33.28-2.56-47.36s33.28-15.36 47.36-2.56l2.56 2.56 111.36 111.36c12.8 12.8 12.8 34.56 0 47.36-6.4 6.4-15.36 10.24-23.04 10.24z" fill="#000000" p-id="28922"></path><path d="M510.72 458.24c-8.96 0-17.92-3.84-24.32-10.24-12.8-12.8-12.8-34.56 0-47.36l111.36-111.36c14.08-12.8 35.84-10.24 47.36 2.56 11.52 12.8 11.52 32 0 44.8L533.76 448c-6.4 6.4-15.36 10.24-23.04 10.24zM925.44 241.92c17.92 0 33.28-15.36 33.28-33.28 0-8.96-3.84-17.92-10.24-24.32l-111.36-111.36c-12.8-14.08-33.28-14.08-47.36-1.28s-14.08 33.28-1.28 47.36l1.28 1.28 111.36 111.36c7.68 6.4 15.36 10.24 24.32 10.24z" fill="#000000" p-id="28923"></path><path d="M815.36 353.28c8.96 0 17.92-3.84 24.32-10.24l111.36-111.36c12.8-14.08 10.24-35.84-2.56-47.36-12.8-11.52-32-11.52-44.8 0l-111.36 111.36c-12.8 12.8-12.8 34.56 0 47.36 5.12 6.4 14.08 10.24 23.04 10.24z" fill="#000000" p-id="28924"></path><path d="M920.32 241.92c17.92 0 34.56-14.08 34.56-32s-14.08-34.56-32-34.56H695.04c-17.92 0-33.28 16.64-32 34.56 0 17.92 15.36 32 32 32h225.28z" fill="#000000" p-id="28925"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/member_level.svg b/src/assets/svgs/member_level.svg new file mode 100644 index 0000000..cbcc686 --- /dev/null +++ b/src/assets/svgs/member_level.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1693027700643" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8876" width="128" height="128" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M936.96 385.877333l-203.434667-204.8-18.090667-7.68L308.565333 173.397333l-18.090667 7.68L87.04 385.877333c-9.728 9.898667-9.898667 25.941333-0.170667 35.84l406.869333 421.034667c4.778667 4.949333 11.434667 7.850667 18.432 7.850667 6.997333 0 13.653333-2.901333 18.432-7.850667l406.869333-421.034667C946.858667 411.648 946.688 395.776 936.96 385.877333zM868.522667 389.632l-141.994667 0-163.84-165.034667 141.994667 0L868.522667 389.632zM319.317333 224.768l143.018667 0-163.84 165.034667L155.477333 389.802667 319.317333 224.768zM176.469333 440.832l132.608 0 18.090667-7.509333 185.173333-186.538667 185.173333 186.538667 18.090667 7.509333 131.584 0L512 787.968 176.469333 440.832z" p-id="8877" fill="#000000"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/member_point.svg b/src/assets/svgs/member_point.svg new file mode 100644 index 0000000..b849ddb --- /dev/null +++ b/src/assets/svgs/member_point.svg @@ -0,0 +1 @@ +<svg t="1693027780777" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10083" width="128" height="128"><path d="M509.091764 501.653351c241.775532 0 424.086741-78.085426 424.086741-181.63992 0-103.543238-182.311209-181.628664-424.086741-181.628664S84.993766 216.471217 84.993766 320.014454C84.993766 423.568948 267.316232 501.653351 509.091764 501.653351zM509.091764 184.220698c222.908836 0 378.251833 71.561849 378.251833 135.793756S732.001623 455.818443 509.091764 455.818443c-222.920092 0-378.26309-71.573105-378.26309-135.803989S286.171672 184.220698 509.091764 184.220698z" fill="#000000" p-id="10084"></path><path d="M509.083577 694.061522c241.1155 0 422.937568-77.598332 422.937568-180.482561 0-27.169803-13.127995-52.453652-36.241412-75.131141-0.148379-0.153496-0.26606-0.320295-0.418532-0.468674-0.170892-0.166799-0.285502-0.345877-0.456395-0.51063l-0.11461 0.125867c-3.717671-3.40761-8.576329-5.608741-14.017248-5.608741-11.542894 0-20.898982 9.356089-20.898982 20.898982 0 6.110161 2.721994 11.481496 6.901177 15.302521l-0.082888 0.091074c13.948687 14.024411 21.809725 31.154557 21.809725 45.300742 0 64.785515-155.813718 136.966465-379.419426 136.966465-223.595474 0-379.410216-72.180949-379.410216-136.966465 0-16.139585 4.53734-29.952172 22.323425-45.670156 0.213871-0.204661 0.429789-0.381693 0.635473-0.594541 0.137123-0.118704 0.240477-0.233314 0.378623-0.354064l-0.084934-0.080841c3.416819-3.719718 5.623068-8.588609 5.623068-14.037714 0-11.542894-9.356089-20.898982-20.898982-20.898982-5.770424 0-10.993378 2.340301-14.773472 6.119371l-0.122797-0.118704c-23.408129 22.797215-36.594453 48.27754-36.594453 75.635631C86.158289 616.462167 267.979334 694.061522 509.083577 694.061522z" fill="#000000" p-id="10085"></path><path d="M895.577119 629.529787c-0.168846-0.164752-0.282433-0.342808-0.453325-0.50756l-0.11461 0.124843c-3.717671-3.40761-8.577353-5.608741-14.018272-5.608741-11.540847 0-20.897959 9.356089-20.897959 20.898982 0 6.110161 2.720971 11.482519 6.901177 15.302521l-0.083911 0.091074c13.94971 14.024411 21.810748 31.154557 21.810748 45.300742 0 64.787562-155.813718 136.966465-379.419426 136.966465-223.595474 0-379.410216-72.179926-379.410216-136.966465 0-16.139585 4.53734-29.952172 22.321378-45.670156 0.213871-0.202615 0.429789-0.381693 0.635473-0.594541 0.137123-0.118704 0.240477-0.233314 0.378623-0.354064l-0.084934-0.080841c3.416819-3.719718 5.623068-8.588609 5.623068-14.037714 0-11.542894-9.356089-20.898982-20.897959-20.898982-5.770424 0-10.993378 2.340301-14.773472 6.119371l-0.122797-0.118704c-23.410176 22.797215-36.594453 48.278563-36.594453 75.635631 0 102.884228 181.821045 180.482561 422.926312 180.482561 241.114476 0 422.935522-77.598332 422.935522-180.482561 0-27.166733-13.125949-52.452629-36.235272-75.127048C895.851365 629.847012 895.730615 629.681236 895.577119 629.529787z" fill="#000000" p-id="10086"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/member_recharge_balance.svg b/src/assets/svgs/member_recharge_balance.svg new file mode 100644 index 0000000..7519bb2 --- /dev/null +++ b/src/assets/svgs/member_recharge_balance.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1693028440322" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="25843" width="128" height="128" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M512 750.509317c-19.080745 0-31.801242-12.720497-31.801242-31.801242L480.198758 432.496894c0-19.080745 12.720497-31.801242 31.801242-31.801242s31.801242 12.720497 31.801242 31.801242l0 286.21118C537.440994 737.78882 524.720497 750.509317 512 750.509317z" fill="#000000" p-id="25844"></path><path d="M651.925466 534.26087 365.714286 534.26087c-19.080745 0-31.801242-12.720497-31.801242-31.801242 0-19.080745 12.720497-31.801242 31.801242-31.801242l286.21118 0c19.080745 0 31.801242 12.720497 31.801242 31.801242C683.726708 521.540373 671.006211 534.26087 651.925466 534.26087z" fill="#000000" p-id="25845"></path><path d="M651.925466 648.745342 365.714286 648.745342c-19.080745 0-31.801242-12.720497-31.801242-31.801242 0-19.080745 12.720497-31.801242 31.801242-31.801242l286.21118 0c19.080745 0 31.801242 12.720497 31.801242 31.801242C683.726708 636.024845 671.006211 648.745342 651.925466 648.745342z" fill="#000000" p-id="25846"></path><path d="M512 464.298137c-6.360248 0-19.080745 0-25.440994-6.360248L352.993789 324.372671c-12.720497-12.720497-12.720497-31.801242 0-44.521739 12.720497-12.720497 31.801242-12.720497 44.521739 0l133.565217 133.565217c12.720497 12.720497 12.720497 31.801242 0 44.521739C524.720497 464.298137 518.360248 464.298137 512 464.298137z" fill="#000000" p-id="25847"></path><path d="M512 464.298137c-6.360248 0-19.080745 0-25.440994-6.360248-12.720497-12.720497-12.720497-31.801242 0-44.521739l133.565217-133.565217c12.720497-12.720497 31.801242-12.720497 44.521739 0 12.720497 12.720497 12.720497 31.801242 0 44.521739L531.080745 457.937888C524.720497 464.298137 518.360248 464.298137 512 464.298137z" fill="#000000" p-id="25848"></path><path d="M512 1017.639752c-279.850932 0-508.819876-228.968944-508.819876-508.819876s228.968944-508.819876 508.819876-508.819876 508.819876 228.968944 508.819876 508.819876c0 25.440994 0 50.881988-6.360248 82.68323 0 19.080745-19.080745 31.801242-38.161491 25.440994-19.080745 0-31.801242-19.080745-25.440994-38.161491 6.360248-25.440994 6.360248-44.521739 6.360248-69.962733 0-248.049689-197.167702-445.217391-445.217391-445.217391S66.782609 267.130435 66.782609 515.180124s197.167702 445.217391 445.217391 445.217391c25.440994 0 57.242236 0 82.68323-6.360248 19.080745-6.360248 31.801242 6.360248 38.161491 25.440994 6.360248 19.080745-6.360248 31.801242-25.440994 38.161491C575.602484 1017.639752 543.801242 1017.639752 512 1017.639752z" fill="#000000" p-id="25849"></path><path d="M989.018634 864.993789l-318.012422 0c-19.080745 0-31.801242-12.720497-31.801242-31.801242s12.720497-31.801242 31.801242-31.801242l318.012422 0c19.080745 0 31.801242 12.720497 31.801242 31.801242S1001.73913 864.993789 989.018634 864.993789z" fill="#000000" p-id="25850"></path><path d="M830.012422 1024c-19.080745 0-31.801242-12.720497-31.801242-31.801242l0-318.012422c0-19.080745 12.720497-31.801242 31.801242-31.801242s31.801242 12.720497 31.801242 31.801242l0 318.012422C861.813665 1004.919255 842.732919 1024 830.012422 1024z" fill="#000000" p-id="25851"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/message.svg b/src/assets/svgs/message.svg new file mode 100644 index 0000000..14ca817 --- /dev/null +++ b/src/assets/svgs/message.svg @@ -0,0 +1 @@ +<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M0 20.967v59.59c0 11.59 8.537 20.966 19.075 20.966h28.613l1 26.477L76.8 101.523h32.125c10.538 0 19.075-9.377 19.075-20.966v-59.59C128 9.377 119.463 0 108.925 0h-89.85C8.538 0 0 9.377 0 20.967zm82.325 33.1c0-5.524 4.013-9.935 9.037-9.935 5.026 0 9.038 4.41 9.038 9.934 0 5.524-4.025 9.934-9.038 9.934-5.024 0-9.037-4.41-9.037-9.934zm-27.613 0c0-5.524 4.013-9.935 9.038-9.935s9.037 4.41 9.037 9.934c0 5.524-4.025 9.934-9.037 9.934-5.025 0-9.038-4.41-9.038-9.934zm-27.1 0c0-5.524 4.013-9.935 9.038-9.935s9.038 4.41 9.038 9.934c0 5.524-4.026 9.934-9.05 9.934-5.013 0-9.025-4.41-9.025-9.934z"/></svg> \ No newline at end of file diff --git a/src/assets/svgs/money.svg b/src/assets/svgs/money.svg new file mode 100644 index 0000000..c1580de --- /dev/null +++ b/src/assets/svgs/money.svg @@ -0,0 +1 @@ +<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M54.122 127.892v-28.68H7.513V87.274h46.609v-12.4H7.513v-12.86h38.003L.099 0h22.6l32.556 45.07c3.617 5.144 6.44 9.611 8.487 13.385 1.788-3.05 4.89-7.779 9.301-14.186L103.93 0h24.01L82.385 62.013h38.34v12.862h-46.41v12.4h46.41v11.937h-46.41v28.68H54.123z"/></svg> \ No newline at end of file diff --git a/src/assets/svgs/pay/icon/alipay_app.svg b/src/assets/svgs/pay/icon/alipay_app.svg new file mode 100644 index 0000000..ebf1188 --- /dev/null +++ b/src/assets/svgs/pay/icon/alipay_app.svg @@ -0,0 +1 @@ +<svg t="1627279997305" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11904" width="40" height="40"><path d="M938.7008 669.525333L938.7008 249.412267c0-90.555733-73.5232-164.078933-164.1472-164.078933L249.378133 85.333333c-90.555733 0-164.078933 73.48906699-164.078933 164.078933l0 525.2096c0 90.555733 73.454933 164.078933 164.07893301 164.078933l525.20959999 0c80.725333 0 147.8656-58.368 161.553067-135.099733-43.52-18.8416-232.106667-100.283733-330.376533-147.182933-74.786133 90.589867-153.088 144.930133-271.121067 144.930133s-196.81279999-72.704-187.357867-161.655467c6.2464-58.402133 46.2848-153.9072 220.296533-137.5232 91.682133 8.6016 133.666133 25.736533 208.418133 50.414933 19.3536-35.4304 35.4304-74.513067 47.616-116.0192L292.0448 436.565333l0-32.8704 164.0448 0 0-58.9824L256 344.712533l1e-8-36.181333 200.12373299 0L456.123733 223.3344c0 0 1.809067-13.312 16.520533-13.31200001l82.056533 1e-8 0 98.474667 213.333333 0 0 36.181333-213.333333 1e-8 0 58.98239999 174.045867 0c-16.00853301 65.1264-40.277333 124.962133-70.690133 177.220267C708.608 599.176533 938.7008 669.525333 938.7008 669.525333L938.7008 669.525333 938.7008 669.525333 938.7008 669.525333zM321.57013299 744.994133c-124.7232 0-144.452267-78.7456-137.83039999-111.65013299 6.5536-32.733867 42.666667-75.502933 112.0256-75.50293301 79.6672 0 151.04 20.445867 236.714667 62.088533C472.302933 698.333867 398.370133 744.994133 321.57013299 744.994133L321.57013299 744.994133 321.57013299 744.994133zM321.57013299 744.994133" fill="#1296db" p-id="11905"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/pay/icon/alipay_bar.svg b/src/assets/svgs/pay/icon/alipay_bar.svg new file mode 100644 index 0000000..eb1e1e8 --- /dev/null +++ b/src/assets/svgs/pay/icon/alipay_bar.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279586085" class="icon" viewBox="0 0 1036 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6737" xmlns:xlink="http://www.w3.org/1999/xlink" width="40.46875" height="40"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2") format("woff2"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff") format("woff"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf") format("truetype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont") format("svg"); } +</style></defs><path d="M27.587124 336.619083h69.148134a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916A13.978733 13.978733 0 0 0 96.735258 0.011183H27.587124a13.978733 13.978733 0 0 0-13.792351 13.978733v308.650434a13.978733 13.978733 0 0 0 13.792351 13.978733z m165.880969 0h27.584701a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916a13.978733 13.978733 0 0 0-13.79235-13.978733h-27.584701a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733z m138.109886 322.629167h-110.525185a27.771084 27.771084 0 0 0-27.584701 28.14385v111.829867a27.771084 27.771084 0 0 0 27.584701 28.14385h110.525185a27.957467 27.957467 0 0 0 27.584701-28.14385v-111.829867a27.957467 27.957467 0 0 0-27.584701-28.14385z m484.596091-322.629167h27.584701a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916a13.978733 13.978733 0 0 0-14.537883-13.978733h-27.5847a13.978733 13.978733 0 0 0-13.978734 13.978733v308.650434a13.978733 13.978733 0 0 0 13.978734 13.978733z m-469.871825 0H428.68358a13.978733 13.978733 0 0 0 13.792351-13.978733V13.989916A13.978733 13.978733 0 0 0 428.68358 0.011183h-83.126867a13.978733 13.978733 0 0 0-13.792351 13.978733v308.650434a13.978733 13.978733 0 0 0 13.792351 13.978733z m594.189361 0h69.148134a13.978733 13.978733 0 0 0 13.792351-13.978733V13.989916a13.978733 13.978733 0 0 0-14.537883-13.978733h-69.148135a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733z m-412.279444 126.181367H66.91396A67.470687 67.470687 0 0 0 0.002423 530.830286v425.139878a67.470687 67.470687 0 0 0 66.911537 68.029836h418.802853a67.470687 67.470687 0 0 0 66.911537-68.029836V487.775787a24.788954 24.788954 0 0 0-24.416188-24.975337z m-58.337914 433.899885a42.681733 42.681733 0 0 1-42.495349 43.054498H125.438257a42.681733 42.681733 0 0 1-42.495349-43.054498V590.100115a42.681733 42.681733 0 0 1 42.495349-43.054498h301.940642a42.681733 42.681733 0 0 1 42.495349 43.054498z m525.22761-433.899885a41.749817 41.749817 0 0 0-41.377051 42.122583v55.914934a41.377051 41.377051 0 1 0 82.940485 0v-55.914934a41.749817 41.749817 0 0 0-41.563434-42.122583z m0 223.659734a41.749817 41.749817 0 0 0-41.377051 42.122584V894.65012a45.477479 45.477479 0 0 1-45.291096 45.850246h-159.730327a43.240882 43.240882 0 0 0-43.613649 37.276622A41.9362 41.9362 0 0 0 745.534871 1024h233.538039a57.778765 57.778765 0 0 0 57.405999-58.337914V729.3283a41.749817 41.749817 0 0 0-41.377051-41.9362zM732.488053 322.64035V13.989916a13.978733 13.978733 0 0 0-13.79235-13.978733h-82.940485a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733h82.940485a13.978733 13.978733 0 0 0 13.79235-13.978733zM532.126208 0.011183c-11.36937 0-20.688525 6.337026-20.688526 13.978733v308.650434c0 7.828091 9.319156 13.978733 20.688526 13.978733s20.688525-6.337026 20.688525-13.978733V13.989916c0-7.641708-9.319156-13.978733-20.688525-13.978733z" p-id="6738" fill="#1977FD"></path><path d="M745.534871 462.80045a41.749817 41.749817 0 0 0-41.377051 42.122583v252.549117a41.377051 41.377051 0 1 0 82.940485 0V504.923033A41.749817 41.749817 0 0 0 745.534871 462.80045" p-id="6739" fill="#1977FD"></path></svg> diff --git a/src/assets/svgs/pay/icon/alipay_pc.svg b/src/assets/svgs/pay/icon/alipay_pc.svg new file mode 100644 index 0000000..2a75277 --- /dev/null +++ b/src/assets/svgs/pay/icon/alipay_pc.svg @@ -0,0 +1 @@ +<svg t="1627279878333" class="icon" viewBox="0 0 1285 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8535" width="40" height="40"><path d="M1141.76 855.04h-286.72c0 40.96 30.72 71.68 71.68 71.68h107.52c20.48 0 35.84 15.36 35.84 35.84s-15.36 35.84-35.84 35.84h-783.36c-20.48 0-35.84-15.36-35.84-35.84s15.36-35.84 35.84-35.84h107.52c40.96 0 71.68-30.72 71.68-71.68h-286.72c-76.8 0-143.36-61.44-143.36-143.36v-568.32c0-76.8 61.44-143.36 143.36-143.36h993.28c76.8 0 143.36 61.44 143.36 143.36v568.32c5.12 76.8-56.32 143.36-138.24 143.36z m71.68-711.68c0-40.96-30.72-71.68-71.68-71.68h-993.28c-40.96 0-71.68 30.72-71.68 71.68v568.32c0 40.96 30.72 71.68 71.68 71.68h993.28c40.96 0 71.68-30.72 71.68-71.68v-568.32z m-143.36 568.32h-855.04c-40.96 0-71.68-30.72-71.68-71.68v-424.96c0-40.96 30.72-71.68 71.68-71.68h855.04c40.96 0 71.68 30.72 71.68 71.68v424.96c0 40.96-30.72 71.68-71.68 71.68z" p-id="8536" fill="#1977FD"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/pay/icon/alipay_qr.svg b/src/assets/svgs/pay/icon/alipay_qr.svg new file mode 100644 index 0000000..4833750 --- /dev/null +++ b/src/assets/svgs/pay/icon/alipay_qr.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279238245" class="icon" viewBox="0 0 1115 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4112" width="43.5546875" height="40" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2") format("woff2"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff") format("woff"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf") format("truetype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont") format("svg"); } +</style></defs><path d="M751.388 68.267a34.133 34.133 0 0 1 0-68.267h227.556a91.022 91.022 0 0 1 91.022 91.022v227.556a34.133 34.133 0 1 1-68.266 0V91.022a22.756 22.756 0 0 0-22.756-22.755H751.388M1001.7 705.422a34.133 34.133 0 0 1 68.266 0v227.556A91.022 91.022 0 0 1 978.944 1024H748.885a34.133 34.133 0 0 1 0-68.267H978.49a22.756 22.756 0 0 0 22.755-22.755V705.422M364.09 955.733a34.133 34.133 0 1 1 0 68.267H136.533a91.022 91.022 0 0 1-91.022-91.022V705.422a34.133 34.133 0 0 1 68.267 0v227.556a22.756 22.756 0 0 0 22.755 22.755H364.09M113.778 318.578a34.133 34.133 0 1 1-68.267 0V91.022A91.022 91.022 0 0 1 136.533 0H364.09a34.133 34.133 0 0 1 0 68.267H136.533a22.756 22.756 0 0 0-22.755 22.755v227.556M34.133 477.867a34.133 34.133 0 0 0 0 68.266h168.619v-68.266z m1046.756 0H912.27v68.266h168.619a34.133 34.133 0 0 0 0-68.266zM202.752 157.24h709.746v320.627H202.752z m0 388.893h709.746V866.76H202.752z" fill="#1977FD" p-id="4113"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/pay/icon/alipay_wap.svg b/src/assets/svgs/pay/icon/alipay_wap.svg new file mode 100644 index 0000000..87075db --- /dev/null +++ b/src/assets/svgs/pay/icon/alipay_wap.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1645964864184" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8460" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40"><defs><style type="text/css"></style></defs><path d="M768.3 0 255.7 0c-70.8 0-128.1 57.4-128.1 128.1l0 767.8c0 70.8 57.4 128.1 128.1 128.1L512 1024l256.3 0c70.8 0 128.1-57.4 128.1-128.1L896.4 128.1C896.4 57.3 839 0 768.3 0zM383.9 96.1c0-17.7 14.3-32 32-32l192.2 0c17.7 0 32 14.3 32 32l0 0c0 17.7-14.3 32-32 32L415.9 128.1C398.2 128.1 383.9 113.8 383.9 96.1L383.9 96.1zM512 959.9 512 959.9 512 959.9c-35.4 0-64.1-28.8-64.1-64.1 0-35.4 28.7-64.1 64.1-64.1l0 0 0 0c35.4 0 64.1 28.7 64.1 64.1C576.1 931.1 547.4 959.9 512 959.9zM832.3 755.6c0 6.7-5.4 12.2-12.2 12.2L203.9 767.8c-6.7 0-12.2-5.4-12.2-12.2L191.7 204.3c0-6.7 5.4-12.2 12.2-12.2l616.3 0c6.7 0 12.2 5.4 12.2 12.2L832.4 755.6z" p-id="8461" fill="#1977FD"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/pay/icon/mock.svg b/src/assets/svgs/pay/icon/mock.svg new file mode 100644 index 0000000..27b09ea --- /dev/null +++ b/src/assets/svgs/pay/icon/mock.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1676209854312" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3033" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M173.077333 362.666667l91.114667-214.677334a65.6 65.6 0 0 1 86.016-34.773333c11.584 4.906667 24.96 10.282667 40.896 16.448 8.277333 3.2 16.789333 6.464 27.904 10.666667 28.202667 10.709333 39.296 14.933333 46.144 17.642666l51.477333-51.669333c28.181333-28.16 74.112-27.946667 102.570667 0.533333l195.925333 195.925334c16.426667 16.426667 23.445333 38.634667 21.056 59.904H896a42.666667 42.666667 0 0 1 42.666667 42.666666v490.666667a42.666667 42.666667 0 0 1-42.666667 42.666667H128a42.666667 42.666667 0 0 1-42.666667-42.666667V405.333333a42.666667 42.666667 0 0 1 42.666667-42.666666h45.077333z m48.96 0h39.104l169.194667-169.770667-27.328-10.389333c-11.2-4.245333-19.818667-7.530667-28.224-10.794667a1459.2 1459.2 0 0 1-42.197333-17.002667 20.522667 20.522667 0 0 0-26.901334 10.88L222.037333 362.666667z m108.842667 0h454.954667a23.509333 23.509333 0 0 0-5.290667-25.322667l-195.925333-195.925333a23.36 23.36 0 0 0-33.024-0.213334L330.88 362.666667zM128 405.333333v490.666667h768V405.333333H128z m597.333333 320a85.333333 85.333333 0 1 1 0-170.666666 85.333333 85.333333 0 0 1 0 170.666666z m0-42.666666a42.666667 42.666667 0 1 0 0-85.333334 42.666667 42.666667 0 0 0 0 85.333334z" fill="#4296d5" p-id="3034"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/pay/icon/wx_app.svg b/src/assets/svgs/pay/icon/wx_app.svg new file mode 100644 index 0000000..ad40b2a --- /dev/null +++ b/src/assets/svgs/pay/icon/wx_app.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279375144" class="icon" viewBox="0 0 1115 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4399" width="43.5546875" height="40" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2") format("woff2"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff") format("woff"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf") format("truetype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont") format("svg"); } +</style></defs><path d="M751.388 68.267a34.133 34.133 0 0 1 0-68.267h227.556a91.022 91.022 0 0 1 91.022 91.022v227.556a34.133 34.133 0 1 1-68.266 0V91.022a22.756 22.756 0 0 0-22.756-22.755H751.388M1001.7 705.422a34.133 34.133 0 0 1 68.266 0v227.556A91.022 91.022 0 0 1 978.944 1024H748.885a34.133 34.133 0 0 1 0-68.267H978.49a22.756 22.756 0 0 0 22.755-22.755V705.422M364.09 955.733a34.133 34.133 0 1 1 0 68.267H136.533a91.022 91.022 0 0 1-91.022-91.022V705.422a34.133 34.133 0 0 1 68.267 0v227.556a22.756 22.756 0 0 0 22.755 22.755H364.09M113.778 318.578a34.133 34.133 0 1 1-68.267 0V91.022A91.022 91.022 0 0 1 136.533 0H364.09a34.133 34.133 0 0 1 0 68.267H136.533a22.756 22.756 0 0 0-22.755 22.755v227.556M34.133 477.867a34.133 34.133 0 0 0 0 68.266h168.619v-68.266z m1046.756 0H912.27v68.266h168.619a34.133 34.133 0 0 0 0-68.266zM202.752 157.24h709.746v320.627H202.752z m0 388.893h709.746V866.76H202.752z" fill="#04C361" p-id="4400"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/pay/icon/wx_bar.svg b/src/assets/svgs/pay/icon/wx_bar.svg new file mode 100644 index 0000000..11292e6 --- /dev/null +++ b/src/assets/svgs/pay/icon/wx_bar.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279586085" class="icon" viewBox="0 0 1036 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6737" xmlns:xlink="http://www.w3.org/1999/xlink" width="40.46875" height="40"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2") format("woff2"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff") format("woff"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf") format("truetype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont") format("svg"); }</style></defs><path d="M27.587124 336.619083h69.148134a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916A13.978733 13.978733 0 0 0 96.735258 0.011183H27.587124a13.978733 13.978733 0 0 0-13.792351 13.978733v308.650434a13.978733 13.978733 0 0 0 13.792351 13.978733z m165.880969 0h27.584701a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916a13.978733 13.978733 0 0 0-13.79235-13.978733h-27.584701a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733z m138.109886 322.629167h-110.525185a27.771084 27.771084 0 0 0-27.584701 28.14385v111.829867a27.771084 27.771084 0 0 0 27.584701 28.14385h110.525185a27.957467 27.957467 0 0 0 27.584701-28.14385v-111.829867a27.957467 27.957467 0 0 0-27.584701-28.14385z m484.596091-322.629167h27.584701a13.978733 13.978733 0 0 0 13.79235-13.978733V13.989916a13.978733 13.978733 0 0 0-14.537883-13.978733h-27.5847a13.978733 13.978733 0 0 0-13.978734 13.978733v308.650434a13.978733 13.978733 0 0 0 13.978734 13.978733z m-469.871825 0H428.68358a13.978733 13.978733 0 0 0 13.792351-13.978733V13.989916A13.978733 13.978733 0 0 0 428.68358 0.011183h-83.126867a13.978733 13.978733 0 0 0-13.792351 13.978733v308.650434a13.978733 13.978733 0 0 0 13.792351 13.978733z m594.189361 0h69.148134a13.978733 13.978733 0 0 0 13.792351-13.978733V13.989916a13.978733 13.978733 0 0 0-14.537883-13.978733h-69.148135a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733z m-412.279444 126.181367H66.91396A67.470687 67.470687 0 0 0 0.002423 530.830286v425.139878a67.470687 67.470687 0 0 0 66.911537 68.029836h418.802853a67.470687 67.470687 0 0 0 66.911537-68.029836V487.775787a24.788954 24.788954 0 0 0-24.416188-24.975337z m-58.337914 433.899885a42.681733 42.681733 0 0 1-42.495349 43.054498H125.438257a42.681733 42.681733 0 0 1-42.495349-43.054498V590.100115a42.681733 42.681733 0 0 1 42.495349-43.054498h301.940642a42.681733 42.681733 0 0 1 42.495349 43.054498z m525.22761-433.899885a41.749817 41.749817 0 0 0-41.377051 42.122583v55.914934a41.377051 41.377051 0 1 0 82.940485 0v-55.914934a41.749817 41.749817 0 0 0-41.563434-42.122583z m0 223.659734a41.749817 41.749817 0 0 0-41.377051 42.122584V894.65012a45.477479 45.477479 0 0 1-45.291096 45.850246h-159.730327a43.240882 43.240882 0 0 0-43.613649 37.276622A41.9362 41.9362 0 0 0 745.534871 1024h233.538039a57.778765 57.778765 0 0 0 57.405999-58.337914V729.3283a41.749817 41.749817 0 0 0-41.377051-41.9362zM732.488053 322.64035V13.989916a13.978733 13.978733 0 0 0-13.79235-13.978733h-82.940485a13.978733 13.978733 0 0 0-13.79235 13.978733v308.650434a13.978733 13.978733 0 0 0 13.79235 13.978733h82.940485a13.978733 13.978733 0 0 0 13.79235-13.978733zM532.126208 0.011183c-11.36937 0-20.688525 6.337026-20.688526 13.978733v308.650434c0 7.828091 9.319156 13.978733 20.688526 13.978733s20.688525-6.337026 20.688525-13.978733V13.989916c0-7.641708-9.319156-13.978733-20.688525-13.978733z" p-id="6738" fill="#04C361"/><path d="M745.534871 462.80045a41.749817 41.749817 0 0 0-41.377051 42.122583v252.549117a41.377051 41.377051 0 1 0 82.940485 0V504.923033A41.749817 41.749817 0 0 0 745.534871 462.80045" p-id="6739" fill="#04C361"/></svg> \ No newline at end of file diff --git a/src/assets/svgs/pay/icon/wx_lite.svg b/src/assets/svgs/pay/icon/wx_lite.svg new file mode 100644 index 0000000..0c925cf --- /dev/null +++ b/src/assets/svgs/pay/icon/wx_lite.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1676209433089" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2990" xmlns:xlink="http://www.w3.org/1999/xlink" width="128" height="128"><path d="M608.6 290.3c67.1 0 121.7 50.5 121.7 112.9 0 19.4-5.6 38.4-15.7 55.5-15.3 25-39.8 43.5-69.4 52.3-7.9 2.3-13.9 3.2-19.4 3.2-13 0-23.1-10.2-23.1-23.1 0-13 10.2-23.1 23.1-23.1 0.9 0 2.8 0 5.1-0.9 19.9-5.6 35.6-17.1 44.4-32.4 6-9.7 8.8-20.4 8.8-31.5 0-36.6-33.8-66.6-75-66.6-14.4 0-28.2 3.7-40.7 10.6-21.8 12.5-34.7 33.3-34.7 56v193.9c0 39.3-21.8 75.4-57.9 95.8-19.4 11.1-41.2 16.7-63.4 16.7-67.1 0-121.7-50.5-121.7-112.9 0-19.4 5.6-38.4 15.7-55.5 15.3-25 39.8-43.5 69.4-52.3 8.3-2.3 13.9-3.2 19.4-3.2 13 0 23.1 10.2 23.1 23.1 0 13-10.2 23.1-23.1 23.1-0.9 0-2.8 0-5.1 0.9-19.9 6-35.6 17.6-44.4 32.4-6 9.7-8.8 20.4-8.8 31.5 0 36.6 33.8 66.6 75.4 66.6 14.4 0 28.2-3.7 40.7-10.6 21.8-12.5 34.7-33.3 34.7-56V403.3c0-39.3 21.8-75.4 57.9-95.8 19-11.6 40.7-17.2 63-17.2zM510.8 929c231.1 0 418.4-187.3 418.4-418.4S741.9 92.1 510.8 92.1 92.4 279.5 92.4 510.6 279.7 929 510.8 929z m0 22C267.5 951 70.3 753.8 70.3 510.6S267.5 70.1 510.8 70.1s440.5 197.2 440.5 440.5S754.1 951 510.8 951z" p-id="2991" fill="#58bf6b"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/pay/icon/wx_native.svg b/src/assets/svgs/pay/icon/wx_native.svg new file mode 100644 index 0000000..bf3ba2b --- /dev/null +++ b/src/assets/svgs/pay/icon/wx_native.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279375144" class="icon" viewBox="0 0 1115 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4399" width="43.5546875" height="40" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2") format("woff2"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff") format("woff"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf") format("truetype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont") format("svg"); }</style></defs><path d="M751.388 68.267a34.133 34.133 0 0 1 0-68.267h227.556a91.022 91.022 0 0 1 91.022 91.022v227.556a34.133 34.133 0 1 1-68.266 0V91.022a22.756 22.756 0 0 0-22.756-22.755H751.388M1001.7 705.422a34.133 34.133 0 0 1 68.266 0v227.556A91.022 91.022 0 0 1 978.944 1024H748.885a34.133 34.133 0 0 1 0-68.267H978.49a22.756 22.756 0 0 0 22.755-22.755V705.422M364.09 955.733a34.133 34.133 0 1 1 0 68.267H136.533a91.022 91.022 0 0 1-91.022-91.022V705.422a34.133 34.133 0 0 1 68.267 0v227.556a22.756 22.756 0 0 0 22.755 22.755H364.09M113.778 318.578a34.133 34.133 0 1 1-68.267 0V91.022A91.022 91.022 0 0 1 136.533 0H364.09a34.133 34.133 0 0 1 0 68.267H136.533a22.756 22.756 0 0 0-22.755 22.755v227.556M34.133 477.867a34.133 34.133 0 0 0 0 68.266h168.619v-68.266z m1046.756 0H912.27v68.266h168.619a34.133 34.133 0 0 0 0-68.266zM202.752 157.24h709.746v320.627H202.752z m0 388.893h709.746V866.76H202.752z" fill="#04C361" p-id="4400"/></svg> \ No newline at end of file diff --git a/src/assets/svgs/pay/icon/wx_pub.svg b/src/assets/svgs/pay/icon/wx_pub.svg new file mode 100644 index 0000000..3a6d15b --- /dev/null +++ b/src/assets/svgs/pay/icon/wx_pub.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1627279797174" class="icon" viewBox="0 0 1260 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7665" xmlns:xlink="http://www.w3.org/1999/xlink" width="49.21875" height="40"><defs><style type="text/css">@font-face { font-family: feedback-iconfont; src: url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff2") format("woff2"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.woff") format("woff"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.ttf") format("truetype"), url("//at.alicdn.com/t/font_1031158_1uhr8ri0pk5.svg#iconfont") format("svg"); } +</style></defs><path d="M797.14798 481.753a269.194 269.194 0 0 0 102.892-211.929C900.03998 120.99 779.02998 0 630.15698 0 481.28298 0 360.27398 120.99 360.27398 269.824c0 85.878 40.33 162.462 102.912 211.929A450.974 450.974 0 0 0 309.84198 582.774c-85.543 85.524-132.608 199.208-132.608 320.236 0 25.01 0 51.712 0.197 76.367a44.898 44.898 0 0 0 44.82 44.623h816.01a44.8 44.8 0 0 0 44.82-44.623V903.01c0-121.009-47.066-234.732-132.609-320.236a451.072 451.072 0 0 0-153.344-101.021z" p-id="7666" fill="#04C361"></path><path d="M1186.18898 580.391A378.644 378.644 0 0 0 1061.81198 473.03a223.783 223.783 0 0 0 64.237-157.657c0-49.742-15.872-96.67-45.746-136.074A225.34 225.34 0 0 0 964.70998 99.9a37.297 37.297 0 0 0-46.14 25.718c-5.592 19.89 5.79 40.724 25.6 46.356 63.114 18.196 107.363 77.135 107.363 143.4a148.913 148.913 0 0 1-81.23 133.06 38.065 38.065 0 0 0-20.363 36.608c1.32 15.203 11.58 28.16 25.975 32.65 125.479 39.601 209.703 155.038 209.703 287.173v63.074c0 20.638 16.62 37.534 37.16 37.711h0.196a37.396 37.396 0 0 0 37.337-37.336V805.06c-0.197-81.644-25.777-159.35-74.142-224.69z m-901.77-62.503a36.982 36.982 0 0 0 25.955-32.65 37.455 37.455 0 0 0-20.362-36.628 148.913 148.913 0 0 1-81.231-133.06c0-66.245 44.071-125.184 107.382-143.4a37.612 37.612 0 0 0 25.58-46.356 37.376 37.376 0 0 0-46.139-25.718 225.32 225.32 0 0 0-115.593 79.4 223.252 223.252 0 0 0-45.746 136.074c0 60.258 23.533 116.381 64.237 157.676A380.475 380.475 0 0 0 74.14498 580.569 373.839 373.839 0 0 0 0.00198 805.258v63.232c0 20.657 16.798 37.356 37.356 37.356h0.197a37.317 37.317 0 0 0 37.14-37.73V805.06c0-132.332 84.401-247.769 209.723-287.173z" p-id="7667" fill="#04C361"></path></svg> \ No newline at end of file diff --git a/src/assets/svgs/peoples.svg b/src/assets/svgs/peoples.svg new file mode 100644 index 0000000..aab852e --- /dev/null +++ b/src/assets/svgs/peoples.svg @@ -0,0 +1 @@ +<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M95.648 118.762c0 5.035-3.563 9.121-7.979 9.121H7.98c-4.416 0-7.979-4.086-7.979-9.121C0 100.519 15.408 83.47 31.152 76.75c-9.099-6.43-15.216-17.863-15.216-30.987v-9.128c0-20.16 14.293-36.518 31.893-36.518s31.894 16.358 31.894 36.518v9.122c0 13.137-6.123 24.556-15.216 30.993 15.738 6.726 31.141 23.769 31.141 42.012z"/><path d="M106.032 118.252h15.867c3.376 0 6.101-3.125 6.101-6.972 0-13.957-11.787-26.984-23.819-32.123 6.955-4.919 11.638-13.66 11.638-23.704v-6.985c0-15.416-10.928-27.926-24.39-27.926-1.674 0-3.306.193-4.89.561 1.936 4.713 3.018 9.974 3.018 15.526v9.121c0 13.137-3.056 23.111-11.066 30.993 14.842 4.41 27.312 23.42 27.541 41.509z"/></svg> \ No newline at end of file diff --git a/src/assets/svgs/shopping.svg b/src/assets/svgs/shopping.svg new file mode 100644 index 0000000..f395bc7 --- /dev/null +++ b/src/assets/svgs/shopping.svg @@ -0,0 +1 @@ +<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M42.913 101.36c1.642 0 3.198.332 4.667.996a12.28 12.28 0 013.89 2.772c1.123 1.184 1.987 2.582 2.592 4.193.605 1.612.908 3.318.908 5.118 0 1.8-.303 3.507-.908 5.118-.605 1.611-1.469 3.01-2.593 4.194a13.3 13.3 0 01-3.889 2.843 10.582 10.582 0 01-4.667 1.066c-1.729 0-3.306-.355-4.732-1.066a13.604 13.604 0 01-3.825-2.843c-1.123-1.185-1.988-2.583-2.593-4.194a14.437 14.437 0 01-.907-5.118c0-1.8.302-3.506.907-5.118.605-1.61 1.47-3.009 2.593-4.193a12.515 12.515 0 013.825-2.772c1.426-.664 3.003-.996 4.732-.996zm53.932.285c1.643 0 3.22.331 4.733.995a11.386 11.386 0 013.889 2.772c1.08 1.185 1.945 2.583 2.593 4.194.648 1.61.972 3.317.972 5.118 0 1.8-.324 3.506-.972 5.117-.648 1.611-1.513 3.01-2.593 4.194a12.253 12.253 0 01-3.89 2.843 11 11 0 01-4.732 1.066 10.58 10.58 0 01-4.667-1.066 12.478 12.478 0 01-3.824-2.843c-1.08-1.185-1.945-2.583-2.593-4.194a13.581 13.581 0 01-.973-5.117c0-1.801.325-3.507.973-5.118.648-1.611 1.512-3.01 2.593-4.194a11.559 11.559 0 013.824-2.772 11.212 11.212 0 014.667-.995zm21.781-80.747c2.42 0 4.3.355 5.64 1.066 1.34.71 2.29 1.587 2.852 2.63a6.427 6.427 0 01.778 3.34c-.044 1.185-.195 2.204-.454 3.057-.26.853-.8 2.606-1.62 5.26a589.268 589.268 0 01-2.788 8.743 1236.373 1236.373 0 00-3.047 9.453c-.994 3.128-1.75 5.592-2.269 7.393-1.123 3.79-2.55 6.42-4.278 7.89-1.728 1.469-3.846 2.203-6.352 2.203H39.023l1.945 12.795h65.342c4.148 0 6.223 1.943 6.223 5.828 0 1.896-.41 3.53-1.232 4.905-.821 1.374-2.442 2.061-4.862 2.061H38.505c-1.729 0-3.176-.426-4.343-1.28-1.167-.852-2.14-1.966-2.917-3.34a21.277 21.277 0 01-1.88-4.478 44.128 44.128 0 01-1.102-4.55c-.087-.568-.324-1.942-.713-4.122-.39-2.18-.865-4.904-1.426-8.174l-1.88-10.947c-.692-4.027-1.383-8.079-2.075-12.154-1.642-9.572-3.5-20.234-5.574-31.986H6.87c-1.296 0-2.377-.356-3.24-1.067a9.024 9.024 0 01-2.14-2.558 10.416 10.416 0 01-1.167-3.2C.108 8.53 0 7.488 0 6.54c0-1.896.583-3.46 1.75-4.69C2.917.615 4.494 0 6.482 0h13.095c1.728 0 3.111.284 4.148.853 1.037.569 1.858 1.28 2.463 2.132a8.548 8.548 0 011.297 2.701c.26.948.475 1.754.648 2.417.173.758.346 1.825.519 3.199.173 1.374.345 2.772.518 4.193.26 1.706.519 3.507.778 5.403h88.678z"/></svg> \ No newline at end of file diff --git a/src/components/AppLinkInput/AppLinkSelectDialog.vue b/src/components/AppLinkInput/AppLinkSelectDialog.vue new file mode 100644 index 0000000..63f1966 --- /dev/null +++ b/src/components/AppLinkInput/AppLinkSelectDialog.vue @@ -0,0 +1,207 @@ +<template> + <Dialog v-model="dialogVisible" title="选择链接" width="65%"> + <div class="h-500px flex gap-8px"> + <!-- 左侧分组列表 --> + <el-scrollbar wrap-class="h-full" ref="groupScrollbar" view-class="flex flex-col"> + <el-button + v-for="(group, groupIndex) in APP_LINK_GROUP_LIST" + :key="groupIndex" + :class="[ + 'm-r-16px m-l-0px! justify-start! w-90px', + { active: activeGroup === group.name } + ]" + ref="groupBtnRefs" + :text="activeGroup !== group.name" + :type="activeGroup === group.name ? 'primary' : 'default'" + @click="handleGroupSelected(group.name)" + > + {{ group.name }} + </el-button> + </el-scrollbar> + <!-- 右侧链接列表 --> + <el-scrollbar class="h-full flex-1" @scroll="handleScroll" ref="linkScrollbar"> + <div v-for="(group, groupIndex) in APP_LINK_GROUP_LIST" :key="groupIndex"> + <!-- 分组标题 --> + <div class="font-bold" ref="groupTitleRefs">{{ group.name }}</div> + <!-- 链接列表 --> + <el-tooltip + v-for="(appLink, appLinkIndex) in group.links" + :key="appLinkIndex" + :content="appLink.path" + placement="bottom" + :show-after="300" + > + <el-button + class="m-b-8px m-r-8px m-l-0px!" + :type="isSameLink(appLink.path, activeAppLink.path) ? 'primary' : 'default'" + @click="handleAppLinkSelected(appLink)" + > + {{ appLink.name }} + </el-button> + </el-tooltip> + </div> + </el-scrollbar> + </div> + <!-- 底部对话框操作按钮 --> + <template #footer> + <el-button type="primary" @click="handleSubmit">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <Dialog v-model="detailSelectDialog.visible" title="" width="50%"> + <el-form class="min-h-200px"> + <el-form-item + label="选择分类" + v-if="detailSelectDialog.type === APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST" + > + <ProductCategorySelect + v-model="detailSelectDialog.id" + :parent-id="0" + @update:model-value="handleProductCategorySelected" + /> + </el-form-item> + </el-form> + </Dialog> +</template> +<script lang="ts" setup> +import { APP_LINK_GROUP_LIST, APP_LINK_TYPE_ENUM, AppLink } from './data' +import { ButtonInstance, ScrollbarInstance } from 'element-plus' +import { split } from 'lodash-es' +import ProductCategorySelect from '@/views/mall/product/category/components/ProductCategorySelect.vue' +import { getUrlNumberValue } from '@/utils' + +// APP 链接选择弹框 +defineOptions({ name: 'AppLinkSelectDialog' }) +// 选中的分组,默认选中第一个 +const activeGroup = ref(APP_LINK_GROUP_LIST[0].name) +// 选中的 APP 链接 +const activeAppLink = ref({} as AppLink) + +/** 打开弹窗 */ +const dialogVisible = ref(false) +const open = (link: string) => { + activeAppLink.value.path = link + dialogVisible.value = true + + // 滚动到当前的链接 + const group = APP_LINK_GROUP_LIST.find((group) => + group.links.some((linkItem) => { + const sameLink = isSameLink(linkItem.path, link) + if (sameLink) { + activeAppLink.value = { ...linkItem, path: link } + } + return sameLink + }) + ) + if (group) { + // 使用 nextTick 的原因:可能 Dom 还没生成,导致滚动失败 + nextTick(() => handleGroupSelected(group.name)) + } +} +defineExpose({ open }) + +// 处理 APP 链接选中 +const handleAppLinkSelected = (appLink: AppLink) => { + if (!isSameLink(appLink.path, activeAppLink.value.path)) { + activeAppLink.value = appLink + } + switch (appLink.type) { + case APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST: + detailSelectDialog.value.visible = true + detailSelectDialog.value.type = appLink.type + // 返显 + detailSelectDialog.value.id = + getUrlNumberValue('id', 'http://127.0.0.1' + activeAppLink.value.path) || undefined + break + default: + break + } +} + +// 处理绑定值更新 +const emit = defineEmits<{ + change: [link: string] + appLinkChange: [appLink: AppLink] +}>() +const handleSubmit = () => { + dialogVisible.value = false + emit('change', activeAppLink.value.path) + emit('appLinkChange', activeAppLink.value) +} + +// 分组标题引用列表 +const groupTitleRefs = ref<HTMLInputElement[]>([]) +/** + * 处理右侧链接列表滚动 + * @param scrollTop 滚动条的位置 + */ +const handleScroll = ({ scrollTop }: { scrollTop: number }) => { + const titleEl = groupTitleRefs.value.find((titleEl: HTMLInputElement) => { + // 获取标题的位置信息 + const { offsetHeight, offsetTop } = titleEl + // 判断标题是否在可视范围内 + return scrollTop >= offsetTop && scrollTop < offsetTop + offsetHeight + }) + // 只需处理一次 + if (titleEl && activeGroup.value !== titleEl.textContent) { + activeGroup.value = titleEl.textContent || '' + // 同步左侧的滚动条位置 + scrollToGroupBtn(activeGroup.value) + } +} + +// 右侧滚动条 +const linkScrollbar = ref<ScrollbarInstance>() +// 处理分组选中 +const handleGroupSelected = (group: string) => { + activeGroup.value = group + const titleRef = groupTitleRefs.value.find((item: HTMLInputElement) => item.textContent === group) + if (titleRef) { + // 滚动分组标题 + linkScrollbar.value?.setScrollTop(titleRef.offsetTop) + } +} + +// 分组滚动条 +const groupScrollbar = ref<ScrollbarInstance>() +// 分组引用列表 +const groupBtnRefs = ref<ButtonInstance[]>([]) +// 自动滚动分组按钮,确保分组按钮保持在可视区域内 +const scrollToGroupBtn = (group: string) => { + const groupBtn = groupBtnRefs.value + .map((btn: ButtonInstance) => btn['ref']) + .find((ref: Node) => ref.textContent === group) + if (groupBtn) { + groupScrollbar.value?.setScrollTop(groupBtn.offsetTop) + } +} + +// 是否为相同的链接(不比较参数,只比较链接) +const isSameLink = (link1: string, link2: string) => { + return split(link1, '?', 1)[0] === split(link2, '?', 1)[0] +} + +// 详情选择对话框 +const detailSelectDialog = ref<{ + visible: boolean + id?: number + type?: APP_LINK_TYPE_ENUM +}>({ + visible: false, + id: undefined, + type: undefined +}) +// 处理详情选择 +const handleProductCategorySelected = (id: number) => { + const url = new URL(activeAppLink.value.path, 'http://127.0.0.1') + // 修改 id 参数 + url.searchParams.set('id', `${id}`) + // 排除域名 + activeAppLink.value.path = `${url.pathname}${url.search}` + // 关闭对话框 + detailSelectDialog.value.visible = false + // 重置 id + detailSelectDialog.value.id = undefined +} +</script> +<style lang="scss" scoped></style> diff --git a/src/components/AppLinkInput/data.ts b/src/components/AppLinkInput/data.ts new file mode 100644 index 0000000..1916e08 --- /dev/null +++ b/src/components/AppLinkInput/data.ts @@ -0,0 +1,228 @@ +// APP 链接分组 +export interface AppLinkGroup { + // 分组名称 + name: string + // 链接列表 + links: AppLink[] +} +// APP 链接 +export interface AppLink { + // 链接名称 + name: string + // 链接地址 + path: string + // 链接的类型 + type?: APP_LINK_TYPE_ENUM +} + +// APP 链接类型(需要特殊处理,例如商品详情) +export const enum APP_LINK_TYPE_ENUM { + // 拼团活动 + ACTIVITY_COMBINATION, + // 秒杀活动 + ACTIVITY_SECKILL, + // 文章详情 + ARTICLE_DETAIL, + // 优惠券详情 + COUPON_DETAIL, + // 自定义页面详情 + DIY_PAGE_DETAIL, + // 品类列表 + PRODUCT_CATEGORY_LIST, + // 商品列表 + PRODUCT_LIST, + // 商品详情 + PRODUCT_DETAIL_NORMAL, + // 拼团商品详情 + PRODUCT_DETAIL_COMBINATION, + // 秒杀商品详情 + PRODUCT_DETAIL_SECKILL +} + +// APP 链接列表(做一下持久化?) +export const APP_LINK_GROUP_LIST = [ + { + name: '商城', + links: [ + { + name: '首页', + path: '/pages/index/index' + }, + { + name: '商品分类', + path: '/pages/index/category', + type: APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST + }, + { + name: '购物车', + path: '/pages/index/cart' + }, + { + name: '个人中心', + path: '/pages/index/user' + }, + { + name: '商品搜索', + path: '/pages/index/search' + }, + { + name: '自定义页面', + path: '/pages/index/page', + type: APP_LINK_TYPE_ENUM.DIY_PAGE_DETAIL + }, + { + name: '客服', + path: '/pages/chat/index' + }, + { + name: '系统设置', + path: '/pages/public/setting' + }, + { + name: '常见问题', + path: '/pages/public/faq' + } + ] + }, + { + name: '商品', + links: [ + { + name: '商品列表', + path: '/pages/goods/list', + type: APP_LINK_TYPE_ENUM.PRODUCT_LIST + }, + { + name: '商品详情', + path: '/pages/goods/index', + type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_NORMAL + }, + { + name: '拼团商品详情', + path: '/pages/goods/groupon', + type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_COMBINATION + }, + { + name: '秒杀商品详情', + path: '/pages/goods/seckill', + type: APP_LINK_TYPE_ENUM.PRODUCT_DETAIL_SECKILL + } + ] + }, + { + name: '营销活动', + links: [ + { + name: '拼团订单', + path: '/pages/activity/groupon/order' + }, + { + name: '营销商品', + path: '/pages/activity/index' + }, + { + name: '拼团活动', + path: '/pages/activity/groupon/list', + type: APP_LINK_TYPE_ENUM.ACTIVITY_COMBINATION + }, + { + name: '秒杀活动', + path: '/pages/activity/seckill/list', + type: APP_LINK_TYPE_ENUM.ACTIVITY_SECKILL + }, + { + name: '签到中心', + path: '/pages/app/sign' + }, + { + name: '优惠券中心', + path: '/pages/coupon/list' + }, + { + name: '优惠券详情', + path: '/pages/coupon/detail', + type: APP_LINK_TYPE_ENUM.COUPON_DETAIL + }, + { + name: '文章详情', + path: '/pages/public/richtext', + type: APP_LINK_TYPE_ENUM.ARTICLE_DETAIL + } + ] + }, + { + name: '分销商城', + links: [ + { + name: '分销中心', + path: '/pages/commission/index' + }, + { + name: '推广商品', + path: '/pages/commission/goods' + }, + { + name: '分销订单', + path: '/pages/commission/order' + }, + { + name: '我的团队', + path: '/pages/commission/team' + } + ] + }, + { + name: '支付', + links: [ + { + name: '充值余额', + path: '/pages/pay/recharge' + }, + { + name: '充值记录', + path: '/pages/pay/recharge-log' + } + ] + }, + { + name: '用户中心', + links: [ + { + name: '用户信息', + path: '/pages/user/info' + }, + { + name: '用户订单', + path: '/pages/order/list' + }, + { + name: '售后订单', + path: '/pages/order/aftersale/list' + }, + { + name: '商品收藏', + path: '/pages/user/goods-collect' + }, + { + name: '浏览记录', + path: '/pages/user/goods-log' + }, + { + name: '地址管理', + path: '/pages/user/address/list' + }, + { + name: '用户佣金', + path: '/pages/user/wallet/commission' + }, + { + name: '用户余额', + path: '/pages/user/wallet/money' + }, + { + name: '用户积分', + path: '/pages/user/wallet/score' + } + ] + } +] as AppLinkGroup[] diff --git a/src/components/AppLinkInput/index.vue b/src/components/AppLinkInput/index.vue new file mode 100644 index 0000000..ff71382 --- /dev/null +++ b/src/components/AppLinkInput/index.vue @@ -0,0 +1,43 @@ +<template> + <el-input v-model="appLink" placeholder="输入或选择链接"> + <template #append> + <el-button @click="handleOpenDialog">选择</el-button> + </template> + </el-input> + <AppLinkSelectDialog ref="dialogRef" @change="handleLinkSelected" /> +</template> +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' + +// APP 链接输入框 +defineOptions({ name: 'AppLinkInput' }) +// 定义属性 +const props = defineProps({ + // 当前选中的链接 + modelValue: propTypes.string.def('') +}) +// 当前的链接 +const appLink = ref('') +// 选择对话框 +const dialogRef = ref() +// 处理打开对话框 +const handleOpenDialog = () => dialogRef.value?.open(appLink.value) +// 处理 APP 链接选中 +const handleLinkSelected = (link: string) => (appLink.value = link) + +// getter +watch( + () => props.modelValue, + () => (appLink.value = props.modelValue), + { immediate: true } +) + +// setter +const emit = defineEmits<{ + 'update:modelValue': [link: string] +}>() +watch( + () => appLink.value, + () => emit('update:modelValue', appLink.value) +) +</script> diff --git a/src/components/Backtop/index.ts b/src/components/Backtop/index.ts new file mode 100644 index 0000000..96de88d --- /dev/null +++ b/src/components/Backtop/index.ts @@ -0,0 +1,3 @@ +import Backtop from './src/Backtop.vue' + +export { Backtop } diff --git a/src/components/Backtop/src/Backtop.vue b/src/components/Backtop/src/Backtop.vue new file mode 100644 index 0000000..5d79f51 --- /dev/null +++ b/src/components/Backtop/src/Backtop.vue @@ -0,0 +1,17 @@ +<script lang="ts" setup> +import { ElBacktop } from 'element-plus' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'BackTop' }) + +const { getPrefixCls, variables } = useDesign() + +const prefixCls = getPrefixCls('backtop') +</script> + +<template> + <ElBacktop + :class="`${prefixCls}-backtop`" + :target="`.${variables.namespace}-layout-content-scrollbar .${variables.elNamespace}-scrollbar__wrap`" + /> +</template> diff --git a/src/components/Card/index.ts b/src/components/Card/index.ts new file mode 100644 index 0000000..f4c0d86 --- /dev/null +++ b/src/components/Card/index.ts @@ -0,0 +1,3 @@ +import CardTitle from './src/CardTitle.vue' + +export { CardTitle } diff --git a/src/components/Card/src/CardTitle.vue b/src/components/Card/src/CardTitle.vue new file mode 100644 index 0000000..76a8356 --- /dev/null +++ b/src/components/Card/src/CardTitle.vue @@ -0,0 +1,37 @@ +<script lang="ts" setup> +defineComponent({ + name: 'CardTitle' +}) + +defineProps({ + title: { + type: String, + required: true + } +}) +</script> + +<template> + <span class="card-title">{{ title }}</span> +</template> + +<style scoped lang="scss"> +.card-title { + font-size: 14px; + font-weight: 600; + + &::before { + position: relative; + top: 8px; + left: -5px; + display: inline-block; + width: 3px; + height: 14px; + //background-color: #105cfb; + background: var(--el-color-primary); + border-radius: 5px; + content: ''; + transform: translateY(-50%); + } +} +</style> diff --git a/src/components/ColorInput/index.vue b/src/components/ColorInput/index.vue new file mode 100644 index 0000000..63ff73c --- /dev/null +++ b/src/components/ColorInput/index.vue @@ -0,0 +1,34 @@ +<template> + <el-input v-model="color"> + <template #prepend> + <el-color-picker v-model="color" :predefine="PREDEFINE_COLORS" /> + </template> + </el-input> +</template> + +<script setup lang="ts"> +import { propTypes } from '@/utils/propTypes' +import { PREDEFINE_COLORS } from '@/utils/color' + +// 颜色输入框 +defineOptions({ name: 'ColorInput' }) + +const props = defineProps({ + modelValue: propTypes.string.def('') +}) +const emit = defineEmits(['update:modelValue']) +const color = computed({ + get: () => { + return props.modelValue + }, + set: (val: string) => { + emit('update:modelValue', val) + } +}) +</script> + +<style scoped lang="scss"> +:deep(.el-input-group__prepend) { + padding: 0; +} +</style> diff --git a/src/components/ConfigGlobal/index.ts b/src/components/ConfigGlobal/index.ts new file mode 100644 index 0000000..dda2462 --- /dev/null +++ b/src/components/ConfigGlobal/index.ts @@ -0,0 +1,3 @@ +import ConfigGlobal from './src/ConfigGlobal.vue' + +export { ConfigGlobal } diff --git a/src/components/ConfigGlobal/src/ConfigGlobal.vue b/src/components/ConfigGlobal/src/ConfigGlobal.vue new file mode 100644 index 0000000..af543df --- /dev/null +++ b/src/components/ConfigGlobal/src/ConfigGlobal.vue @@ -0,0 +1,62 @@ +<script setup lang="ts"> +import { provide, computed, watch, onMounted } from 'vue' +import { propTypes } from '@/utils/propTypes' +import { ComponentSize, ElConfigProvider } from 'element-plus' +import { useLocaleStore } from '@/store/modules/locale' +import { useWindowSize } from '@vueuse/core' +import { useAppStore } from '@/store/modules/app' +import { setCssVar } from '@/utils' +import { useDesign } from '@/hooks/web/useDesign' + +const { variables } = useDesign() + +const appStore = useAppStore() + +const props = defineProps({ + size: propTypes.oneOf<ComponentSize>(['default', 'small', 'large']).def('default') +}) + +provide('configGlobal', props) + +// 初始化所有主题色 +onMounted(() => { + appStore.setCssVarTheme() +}) + +const { width } = useWindowSize() + +// 监听窗口变化 +watch( + () => width.value, + (width: number) => { + if (width < 768) { + !appStore.getMobile ? appStore.setMobile(true) : undefined + setCssVar('--left-menu-min-width', '0') + appStore.setCollapse(true) + appStore.getLayout !== 'classic' ? appStore.setLayout('classic') : undefined + } else { + appStore.getMobile ? appStore.setMobile(false) : undefined + setCssVar('--left-menu-min-width', '64px') + } + }, + { + immediate: true + } +) + +// 多语言相关 +const localeStore = useLocaleStore() + +const currentLocale = computed(() => localeStore.currentLocale) +</script> + +<template> + <ElConfigProvider + :namespace="variables.elNamespace" + :locale="currentLocale.elLocale" + :message="{ max: 5 }" + :size="size" + > + <slot></slot> + </ElConfigProvider> +</template> diff --git a/src/components/ContentDetailWrap/index.ts b/src/components/ContentDetailWrap/index.ts new file mode 100644 index 0000000..1871cac --- /dev/null +++ b/src/components/ContentDetailWrap/index.ts @@ -0,0 +1,3 @@ +import ContentDetailWrap from './src/ContentDetailWrap.vue' + +export { ContentDetailWrap } diff --git a/src/components/ContentDetailWrap/src/ContentDetailWrap.vue b/src/components/ContentDetailWrap/src/ContentDetailWrap.vue new file mode 100644 index 0000000..a9eacc0 --- /dev/null +++ b/src/components/ContentDetailWrap/src/ContentDetailWrap.vue @@ -0,0 +1,58 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'ContentDetailWrap' }) + +const { t } = useI18n() + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('content-detail-wrap') + +defineProps({ + title: propTypes.string.def(''), + message: propTypes.string.def('') +}) +const emit = defineEmits(['back']) +const offset = ref(85) +const contentDetailWrap = ref() +onMounted(() => { + offset.value = contentDetailWrap.value.getBoundingClientRect().top +}) +</script> + +<template> + <div ref="contentDetailWrap" :class="[`${prefixCls}-container`]"> + <Sticky :offset="offset"> + <div + :class="[ + `${prefixCls}-header`, + 'flex b-b-1 h-50px items-center text-center bg-white pr-10px' + ]" + > + <div :class="[`${prefixCls}-header__back`, 'flex pl-10px pr-10px ']"> + <ElButton @click="emit('back')"> + <Icon class="mr-5px" icon="ep:arrow-left" /> + {{ t('common.back') }} + </ElButton> + </div> + <div :class="[`${prefixCls}-header__title`, 'flex flex-1 justify-center']"> + <slot name="title"> + <label class="text-16px font-700">{{ title }}</label> + </slot> + </div> + <div :class="[`${prefixCls}-header__right`, 'flex pl-10px pr-10px']"> + <slot name="right"></slot> + </div> + </div> + </Sticky> + <div style="padding: var(--app-content-padding)"> + <ElCard :class="[`${prefixCls}-body`, 'mb-20px']" shadow="never"> + <div> + <slot></slot> + </div> + </ElCard> + </div> + </div> +</template> diff --git a/src/components/ContentWrap/index.ts b/src/components/ContentWrap/index.ts new file mode 100644 index 0000000..8c22cc8 --- /dev/null +++ b/src/components/ContentWrap/index.ts @@ -0,0 +1,3 @@ +import ContentWrap from './src/ContentWrap.vue' + +export { ContentWrap } diff --git a/src/components/ContentWrap/src/ContentWrap.vue b/src/components/ContentWrap/src/ContentWrap.vue new file mode 100644 index 0000000..c75e4b7 --- /dev/null +++ b/src/components/ContentWrap/src/ContentWrap.vue @@ -0,0 +1,36 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'ContentWrap' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('content-wrap') + +defineProps({ + title: propTypes.string.def(''), + message: propTypes.string.def(''), + bodyStyle: propTypes.object.def({ padding: '20px' }) +}) +</script> + +<template> + <ElCard :body-style="bodyStyle" :class="[prefixCls, 'mb-15px']" shadow="never"> + <template v-if="title" #header> + <div class="flex items-center"> + <span class="text-16px font-700">{{ title }}</span> + <ElTooltip v-if="message" effect="dark" placement="right"> + <template #content> + <div class="max-w-200px">{{ message }}</div> + </template> + <Icon :size="14" class="ml-5px" icon="ep:question-filled" /> + </ElTooltip> + <div class="flex flex-grow pl-20px"> + <slot name="header"></slot> + </div> + </div> + </template> + <slot></slot> + </ElCard> +</template> diff --git a/src/components/CountTo/index.ts b/src/components/CountTo/index.ts new file mode 100644 index 0000000..2119f02 --- /dev/null +++ b/src/components/CountTo/index.ts @@ -0,0 +1,3 @@ +import CountTo from './src/CountTo.vue' + +export { CountTo } diff --git a/src/components/CountTo/src/CountTo.vue b/src/components/CountTo/src/CountTo.vue new file mode 100644 index 0000000..7a19bec --- /dev/null +++ b/src/components/CountTo/src/CountTo.vue @@ -0,0 +1,182 @@ +<script lang="ts" setup> +import { PropType } from 'vue' +import { isNumber } from '@/utils/is' +import { propTypes } from '@/utils/propTypes' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'CountTo' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('count-to') + +const props = defineProps({ + startVal: propTypes.number.def(0), // 开始播放值 + endVal: propTypes.number.def(2021), // 最终值 + duration: propTypes.number.def(3000), // 动画时长 + autoplay: propTypes.bool.def(true), // 是否自动播放动画, 默认播放 + decimals: propTypes.number.validate((value: number) => value >= 0).def(0), // 显示的小数位数, 默认不显示小数 + decimal: propTypes.string.def('.'), // 小数分隔符号, 默认为点 + separator: propTypes.string.def(','), // 数字每三位的分隔符, 默认为逗号 + prefix: propTypes.string.def(''), // 前缀, 数值前面显示的内容 + suffix: propTypes.string.def(''), // 后缀, 数值后面显示的内容 + useEasing: propTypes.bool.def(true), // 是否使用缓动效果, 默认启用 + easingFn: { + type: Function as PropType<(t: number, b: number, c: number, d: number) => number>, + default(t: number, b: number, c: number, d: number) { + return (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b + } // 缓动函数 + } +}) + +const emit = defineEmits(['mounted', 'callback']) + +const formatNumber = (num: number | string) => { + const { decimals, decimal, separator, suffix, prefix } = props + num = Number(num).toFixed(decimals) + num += '' + const x = num.split('.') + let x1 = x[0] + const x2 = x.length > 1 ? decimal + x[1] : '' + const rgx = /(\d+)(\d{3})/ + if (separator && !isNumber(separator)) { + while (rgx.test(x1)) { + x1 = x1.replace(rgx, '$1' + separator + '$2') + } + } + return prefix + x1 + x2 + suffix +} + +const state = reactive<{ + localStartVal: number + printVal: number | null + displayValue: string + paused: boolean + localDuration: number | null + startTime: number | null + timestamp: number | null + rAF: any + remaining: number | null +}>({ + localStartVal: props.startVal, + displayValue: formatNumber(props.startVal), + printVal: null, + paused: false, + localDuration: props.duration, + startTime: null, + timestamp: null, + remaining: null, + rAF: null +}) + +const displayValue = toRef(state, 'displayValue') + +onMounted(() => { + if (props.autoplay) { + start() + } + emit('mounted') +}) + +const getCountDown = computed(() => { + return props.startVal > props.endVal +}) + +watch([() => props.startVal, () => props.endVal], () => { + if (props.autoplay) { + start() + } +}) + +const start = () => { + const { startVal, duration } = props + state.localStartVal = startVal + state.startTime = null + state.localDuration = duration + state.paused = false + state.rAF = requestAnimationFrame(count) +} + +const pauseResume = () => { + if (state.paused) { + resume() + state.paused = false + } else { + pause() + state.paused = true + } +} + +const pause = () => { + cancelAnimationFrame(state.rAF) +} + +const resume = () => { + state.startTime = null + state.localDuration = +(state.remaining as number) + state.localStartVal = +(state.printVal as number) + requestAnimationFrame(count) +} + +const reset = () => { + state.startTime = null + cancelAnimationFrame(state.rAF) + state.displayValue = formatNumber(props.startVal) +} + +const count = (timestamp: number) => { + const { useEasing, easingFn, endVal } = props + if (!state.startTime) state.startTime = timestamp + state.timestamp = timestamp + const progress = timestamp - state.startTime + state.remaining = (state.localDuration as number) - progress + if (useEasing) { + if (unref(getCountDown)) { + state.printVal = + state.localStartVal - + easingFn(progress, 0, state.localStartVal - endVal, state.localDuration as number) + } else { + state.printVal = easingFn( + progress, + state.localStartVal, + endVal - state.localStartVal, + state.localDuration as number + ) + } + } else { + if (unref(getCountDown)) { + state.printVal = + state.localStartVal - + (state.localStartVal - endVal) * (progress / (state.localDuration as number)) + } else { + state.printVal = + state.localStartVal + + (endVal - state.localStartVal) * (progress / (state.localDuration as number)) + } + } + if (unref(getCountDown)) { + state.printVal = state.printVal < endVal ? endVal : state.printVal + } else { + state.printVal = state.printVal > endVal ? endVal : state.printVal + } + state.displayValue = formatNumber(state.printVal!) + if (progress < (state.localDuration as number)) { + state.rAF = requestAnimationFrame(count) + } else { + emit('callback') + } +} + +defineExpose({ + pauseResume, + reset, + start, + pause +}) +</script> + +<template> + <span :class="prefixCls"> + {{ displayValue }} + </span> +</template> diff --git a/src/components/Crontab/index.ts b/src/components/Crontab/index.ts new file mode 100644 index 0000000..6beeef8 --- /dev/null +++ b/src/components/Crontab/index.ts @@ -0,0 +1,2 @@ +import Crontab from './src/Crontab.vue' +export { Crontab } diff --git a/src/components/Crontab/src/Crontab.vue b/src/components/Crontab/src/Crontab.vue new file mode 100644 index 0000000..e61fef8 --- /dev/null +++ b/src/components/Crontab/src/Crontab.vue @@ -0,0 +1,1015 @@ +<script lang="ts" setup> +import { ElMessage } from 'element-plus' +import { PropType } from 'vue' + +defineOptions({ name: 'Crontab' }) + +interface shortcutsType { + text: string + value: string +} + +const props = defineProps({ + modelValue: { + type: String, + default: '* * * * * ?' + }, + shortcuts: { type: Array as PropType<shortcutsType[]>, default: () => [] } +}) +const defaultValue = ref('') +const dialogVisible = ref(false) +const getYear = () => { + let v: number[] = [] + let y = new Date().getFullYear() + for (let i = 0; i < 11; i++) { + v.push(y + i) + } + return v +} +const cronValue = reactive({ + second: { + type: '0', + range: { + start: 1, + end: 2 + }, + loop: { + start: 0, + end: 1 + }, + appoint: [] as string[] + }, + minute: { + type: '0', + range: { + start: 1, + end: 2 + }, + loop: { + start: 0, + end: 1 + }, + appoint: [] as string[] + }, + hour: { + type: '0', + range: { + start: 1, + end: 2 + }, + loop: { + start: 0, + end: 1 + }, + appoint: [] as string[] + }, + day: { + type: '0', + range: { + start: 1, + end: 2 + }, + loop: { + start: 1, + end: 1 + }, + appoint: [] as string[] + }, + month: { + type: '0', + range: { + start: 1, + end: 2 + }, + loop: { + start: 1, + end: 1 + }, + appoint: [] as string[] + }, + week: { + type: '5', + range: { + start: '2', + end: '3' + }, + loop: { + start: 0, + end: '2' + }, + last: '2', + appoint: [] as string[] + }, + year: { + type: '-1', + range: { + start: getYear()[0], + end: getYear()[1] + }, + loop: { + start: getYear()[0], + end: 1 + }, + appoint: [] as string[] + } +}) +const data = reactive({ + second: ['0', '5', '15', '20', '25', '30', '35', '40', '45', '50', '55', '59'], + minute: ['0', '5', '15', '20', '25', '30', '35', '40', '45', '50', '55', '59'], + hour: [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '19', + '20', + '21', + '22', + '23' + ], + day: [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '17', + '18', + '19', + '20', + '21', + '22', + '23', + '24', + '25', + '26', + '27', + '28', + '29', + '30', + '31' + ], + month: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'], + week: [ + { + value: '1', + label: '周日' + }, + { + value: '2', + label: '周一' + }, + { + value: '3', + label: '周二' + }, + { + value: '4', + label: '周三' + }, + { + value: '5', + label: '周四' + }, + { + value: '6', + label: '周五' + }, + { + value: '7', + label: '周六' + } + ], + year: getYear() +}) + +const value_second = computed(() => { + let v = cronValue.second + if (v.type == '0') { + return '*' + } else if (v.type == '1') { + return v.range.start + '-' + v.range.end + } else if (v.type == '2') { + return v.loop.start + '/' + v.loop.end + } else if (v.type == '3') { + return v.appoint.length > 0 ? v.appoint.join(',') : '*' + } else { + return '*' + } +}) +const value_minute = computed(() => { + let v = cronValue.minute + if (v.type == '0') { + return '*' + } else if (v.type == '1') { + return v.range.start + '-' + v.range.end + } else if (v.type == '2') { + return v.loop.start + '/' + v.loop.end + } else if (v.type == '3') { + return v.appoint.length > 0 ? v.appoint.join(',') : '*' + } else { + return '*' + } +}) +const value_hour = computed(() => { + let v = cronValue.hour + if (v.type == '0') { + return '*' + } else if (v.type == '1') { + return v.range.start + '-' + v.range.end + } else if (v.type == '2') { + return v.loop.start + '/' + v.loop.end + } else if (v.type == '3') { + return v.appoint.length > 0 ? v.appoint.join(',') : '*' + } else { + return '*' + } +}) +const value_day = computed(() => { + let v = cronValue.day + if (v.type == '0') { + return '*' + } else if (v.type == '1') { + return v.range.start + '-' + v.range.end + } else if (v.type == '2') { + return v.loop.start + '/' + v.loop.end + } else if (v.type == '3') { + return v.appoint.length > 0 ? v.appoint.join(',') : '*' + } else if (v.type == '4') { + return 'L' + } else if (v.type == '5') { + return '?' + } else { + return '*' + } +}) +const value_month = computed(() => { + let v = cronValue.month + if (v.type == '0') { + return '*' + } else if (v.type == '1') { + return v.range.start + '-' + v.range.end + } else if (v.type == '2') { + return v.loop.start + '/' + v.loop.end + } else if (v.type == '3') { + return v.appoint.length > 0 ? v.appoint.join(',') : '*' + } else { + return '*' + } +}) +const value_week = computed(() => { + let v = cronValue.week + if (v.type == '0') { + return '*' + } else if (v.type == '1') { + return v.range.start + '-' + v.range.end + } else if (v.type == '2') { + return v.loop.end + '#' + v.loop.start + } else if (v.type == '3') { + return v.appoint.length > 0 ? v.appoint.join(',') : '*' + } else if (v.type == '4') { + return v.last + 'L' + } else if (v.type == '5') { + return '?' + } else { + return '*' + } +}) +const value_year = computed(() => { + let v = cronValue.year + if (v.type == '-1') { + return '' + } else if (v.type == '0') { + return '*' + } else if (v.type == '1') { + return v.range.start + '-' + v.range.end + } else if (v.type == '2') { + return v.loop.start + '/' + v.loop.end + } else if (v.type == '3') { + return v.appoint.length > 0 ? v.appoint.join(',') : '' + } else { + return '' + } +}) +watch( + () => cronValue.week.type, + (val) => { + if (val != '5') { + cronValue.day.type = '5' + } + } +) +watch( + () => cronValue.day.type, + (val) => { + if (val != '5') { + cronValue.week.type = '5' + } + } +) +watch( + () => props.modelValue, + () => { + defaultValue.value = props.modelValue + } +) +onMounted(() => { + defaultValue.value = props.modelValue +}) +const emit = defineEmits(['update:modelValue']) +const select = ref() +watch( + () => select.value, + () => { + if (select.value == 'custom') { + open() + } else { + defaultValue.value = select.value + emit('update:modelValue', defaultValue.value) + } + } +) +const open = () => { + set() + dialogVisible.value = true +} +const set = () => { + defaultValue.value = props.modelValue + let arr = (props.modelValue || '* * * * * ?').split(' ') + //简单检查 + if (arr.length < 6) { + ElMessage.warning('cron表达式错误,已转换为默认表达式') + arr = '* * * * * ?'.split(' ') + } + + //秒 + if (arr[0] == '*') { + cronValue.second.type = '0' + } else if (arr[0].includes('-')) { + cronValue.second.type = '1' + cronValue.second.range.start = Number(arr[0].split('-')[0]) + cronValue.second.range.end = Number(arr[0].split('-')[1]) + } else if (arr[0].includes('/')) { + cronValue.second.type = '2' + cronValue.second.loop.start = Number(arr[0].split('/')[0]) + cronValue.second.loop.end = Number(arr[0].split('/')[1]) + } else { + cronValue.second.type = '3' + cronValue.second.appoint = arr[0].split(',') + } + //分 + if (arr[1] == '*') { + cronValue.minute.type = '0' + } else if (arr[1].includes('-')) { + cronValue.minute.type = '1' + cronValue.minute.range.start = Number(arr[1].split('-')[0]) + cronValue.minute.range.end = Number(arr[1].split('-')[1]) + } else if (arr[1].includes('/')) { + cronValue.minute.type = '2' + cronValue.minute.loop.start = Number(arr[1].split('/')[0]) + cronValue.minute.loop.end = Number(arr[1].split('/')[1]) + } else { + cronValue.minute.type = '3' + cronValue.minute.appoint = arr[1].split(',') + } + //小时 + if (arr[2] == '*') { + cronValue.hour.type = '0' + } else if (arr[2].includes('-')) { + cronValue.hour.type = '1' + cronValue.hour.range.start = Number(arr[2].split('-')[0]) + cronValue.hour.range.end = Number(arr[2].split('-')[1]) + } else if (arr[2].includes('/')) { + cronValue.hour.type = '2' + cronValue.hour.loop.start = Number(arr[2].split('/')[0]) + cronValue.hour.loop.end = Number(arr[2].split('/')[1]) + } else { + cronValue.hour.type = '3' + cronValue.hour.appoint = arr[2].split(',') + } + //日 + if (arr[3] == '*') { + cronValue.day.type = '0' + } else if (arr[3] == 'L') { + cronValue.day.type = '4' + } else if (arr[3] == '?') { + cronValue.day.type = '5' + } else if (arr[3].includes('-')) { + cronValue.day.type = '1' + cronValue.day.range.start = Number(arr[3].split('-')[0]) + cronValue.day.range.end = Number(arr[3].split('-')[1]) + } else if (arr[3].includes('/')) { + cronValue.day.type = '2' + cronValue.day.loop.start = Number(arr[3].split('/')[0]) + cronValue.day.loop.end = Number(arr[3].split('/')[1]) + } else { + cronValue.day.type = '3' + cronValue.day.appoint = arr[3].split(',') + } + //月 + if (arr[4] == '*') { + cronValue.month.type = '0' + } else if (arr[4].includes('-')) { + cronValue.month.type = '1' + cronValue.month.range.start = Number(arr[4].split('-')[0]) + cronValue.month.range.end = Number(arr[4].split('-')[1]) + } else if (arr[4].includes('/')) { + cronValue.month.type = '2' + cronValue.month.loop.start = Number(arr[4].split('/')[0]) + cronValue.month.loop.end = Number(arr[4].split('/')[1]) + } else { + cronValue.month.type = '3' + cronValue.month.appoint = arr[4].split(',') + } + //周 + if (arr[5] == '*') { + cronValue.week.type = '0' + } else if (arr[5] == '?') { + cronValue.week.type = '5' + } else if (arr[5].includes('-')) { + cronValue.week.type = '1' + cronValue.week.range.start = arr[5].split('-')[0] + cronValue.week.range.end = arr[5].split('-')[1] + } else if (arr[5].includes('#')) { + cronValue.week.type = '2' + cronValue.week.loop.start = Number(arr[5].split('#')[1]) + cronValue.week.loop.end = arr[5].split('#')[0] + } else if (arr[5].includes('L')) { + cronValue.week.type = '4' + cronValue.week.last = arr[5].split('L')[0] + } else { + cronValue.week.type = '3' + cronValue.week.appoint = arr[5].split(',') + } + //年 + if (!arr[6]) { + cronValue.year.type = '-1' + } else if (arr[6] == '*') { + cronValue.year.type = '0' + } else if (arr[6].includes('-')) { + cronValue.year.type = '1' + cronValue.year.range.start = Number(arr[6].split('-')[0]) + cronValue.year.range.end = Number(arr[6].split('-')[1]) + } else if (arr[6].includes('/')) { + cronValue.year.type = '2' + cronValue.year.loop.start = Number(arr[6].split('/')[1]) + cronValue.year.loop.end = Number(arr[6].split('/')[0]) + } else { + cronValue.year.type = '3' + cronValue.year.appoint = arr[6].split(',') + } +} +const submit = () => { + let year = value_year.value ? ' ' + value_year.value : '' + defaultValue.value = + value_second.value + + ' ' + + value_minute.value + + ' ' + + value_hour.value + + ' ' + + value_day.value + + ' ' + + value_month.value + + ' ' + + value_week.value + + year + emit('update:modelValue', defaultValue.value) + dialogVisible.value = false +} + +const inputChange = () => { + emit('update:modelValue', defaultValue.value) +} +</script> +<template> + <el-input v-model="defaultValue" class="input-with-select" v-bind="$attrs" @input="inputChange"> + <template #append> + <el-select v-model="select" placeholder="生成器" style="width: 115px"> + <el-option label="每分钟" value="0 * * * * ?" /> + <el-option label="每小时" value="0 0 * * * ?" /> + <el-option label="每天零点" value="0 0 0 * * ?" /> + <el-option label="每月一号零点" value="0 0 0 1 * ?" /> + <el-option label="每月最后一天零点" value="0 0 0 L * ?" /> + <el-option label="每周星期日零点" value="0 0 0 ? * 1" /> + <el-option + v-for="(item, index) in shortcuts" + :key="index" + :label="item.text" + :value="item.value" + /> + <el-option label="自定义" value="custom" /> + </el-select> + </template> + </el-input> + + <el-dialog + v-model="dialogVisible" + :width="580" + append-to-body + destroy-on-close + title="cron规则生成器" + > + <div class="sc-cron"> + <el-tabs> + <el-tab-pane> + <template #label> + <div class="sc-cron-num"> + <h2>秒</h2> + <h4>{{ value_second }}</h4> + </div> + </template> + <el-form> + <el-form-item label="类型"> + <el-radio-group v-model="cronValue.second.type"> + <el-radio-button label="0">任意值</el-radio-button> + <el-radio-button label="1">范围</el-radio-button> + <el-radio-button label="2">间隔</el-radio-button> + <el-radio-button label="3">指定</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item v-if="cronValue.second.type == '1'" label="范围"> + <el-input-number + v-model="cronValue.second.range.start" + :max="59" + :min="0" + controls-position="right" + /> + <span style="padding: 0 15px">-</span> + <el-input-number + v-model="cronValue.second.range.end" + :max="59" + :min="0" + controls-position="right" + /> + </el-form-item> + <el-form-item v-if="cronValue.second.type == '2'" label="间隔"> + <el-input-number + v-model="cronValue.second.loop.start" + :max="59" + :min="0" + controls-position="right" + /> + 秒开始,每 + <el-input-number + v-model="cronValue.second.loop.end" + :max="59" + :min="0" + controls-position="right" + /> + 秒执行一次 + </el-form-item> + <el-form-item v-if="cronValue.second.type == '3'" label="指定"> + <el-select v-model="cronValue.second.appoint" multiple style="width: 100%"> + <el-option + v-for="(item, index) in data.second" + :key="index" + :label="item" + :value="item" + /> + </el-select> + </el-form-item> + </el-form> + </el-tab-pane> + <el-tab-pane> + <template #label> + <div class="sc-cron-num"> + <h2>分钟</h2> + <h4>{{ value_minute }}</h4> + </div> + </template> + <el-form> + <el-form-item label="类型"> + <el-radio-group v-model="cronValue.minute.type"> + <el-radio-button label="0">任意值</el-radio-button> + <el-radio-button label="1">范围</el-radio-button> + <el-radio-button label="2">间隔</el-radio-button> + <el-radio-button label="3">指定</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item v-if="cronValue.minute.type == '1'" label="范围"> + <el-input-number + v-model="cronValue.minute.range.start" + :max="59" + :min="0" + controls-position="right" + /> + <span style="padding: 0 15px">-</span> + <el-input-number + v-model="cronValue.minute.range.end" + :max="59" + :min="0" + controls-position="right" + /> + </el-form-item> + <el-form-item v-if="cronValue.minute.type == '2'" label="间隔"> + <el-input-number + v-model="cronValue.minute.loop.start" + :max="59" + :min="0" + controls-position="right" + /> + 分钟开始,每 + <el-input-number + v-model="cronValue.minute.loop.end" + :max="59" + :min="0" + controls-position="right" + /> + 分钟执行一次 + </el-form-item> + <el-form-item v-if="cronValue.minute.type == '3'" label="指定"> + <el-select v-model="cronValue.minute.appoint" multiple style="width: 100%"> + <el-option + v-for="(item, index) in data.minute" + :key="index" + :label="item" + :value="item" + /> + </el-select> + </el-form-item> + </el-form> + </el-tab-pane> + <el-tab-pane> + <template #label> + <div class="sc-cron-num"> + <h2>小时</h2> + <h4>{{ value_hour }}</h4> + </div> + </template> + <el-form> + <el-form-item label="类型"> + <el-radio-group v-model="cronValue.hour.type"> + <el-radio-button label="0">任意值</el-radio-button> + <el-radio-button label="1">范围</el-radio-button> + <el-radio-button label="2">间隔</el-radio-button> + <el-radio-button label="3">指定</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item v-if="cronValue.hour.type == '1'" label="范围"> + <el-input-number + v-model="cronValue.hour.range.start" + :max="23" + :min="0" + controls-position="right" + /> + <span style="padding: 0 15px">-</span> + <el-input-number + v-model="cronValue.hour.range.end" + :max="23" + :min="0" + controls-position="right" + /> + </el-form-item> + <el-form-item v-if="cronValue.hour.type == '2'" label="间隔"> + <el-input-number + v-model="cronValue.hour.loop.start" + :max="23" + :min="0" + controls-position="right" + /> + 小时开始,每 + <el-input-number + v-model="cronValue.hour.loop.end" + :max="23" + :min="0" + controls-position="right" + /> + 小时执行一次 + </el-form-item> + <el-form-item v-if="cronValue.hour.type == '3'" label="指定"> + <el-select v-model="cronValue.hour.appoint" multiple style="width: 100%"> + <el-option + v-for="(item, index) in data.hour" + :key="index" + :label="item" + :value="item" + /> + </el-select> + </el-form-item> + </el-form> + </el-tab-pane> + <el-tab-pane> + <template #label> + <div class="sc-cron-num"> + <h2>日</h2> + <h4>{{ value_day }}</h4> + </div> + </template> + <el-form> + <el-form-item label="类型"> + <el-radio-group v-model="cronValue.day.type"> + <el-radio-button label="0">任意值</el-radio-button> + <el-radio-button label="1">范围</el-radio-button> + <el-radio-button label="2">间隔</el-radio-button> + <el-radio-button label="3">指定</el-radio-button> + <el-radio-button label="4">本月最后一天</el-radio-button> + <el-radio-button label="5">不指定</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item v-if="cronValue.day.type == '1'" label="范围"> + <el-input-number + v-model="cronValue.day.range.start" + :max="31" + :min="1" + controls-position="right" + /> + <span style="padding: 0 15px">-</span> + <el-input-number + v-model="cronValue.day.range.end" + :max="31" + :min="1" + controls-position="right" + /> + </el-form-item> + <el-form-item v-if="cronValue.day.type == '2'" label="间隔"> + <el-input-number + v-model="cronValue.day.loop.start" + :max="31" + :min="1" + controls-position="right" + /> + 号开始,每 + <el-input-number + v-model="cronValue.day.loop.end" + :max="31" + :min="1" + controls-position="right" + /> + 天执行一次 + </el-form-item> + <el-form-item v-if="cronValue.day.type == '3'" label="指定"> + <el-select v-model="cronValue.day.appoint" multiple style="width: 100%"> + <el-option + v-for="(item, index) in data.day" + :key="index" + :label="item" + :value="item" + /> + </el-select> + </el-form-item> + </el-form> + </el-tab-pane> + <el-tab-pane> + <template #label> + <div class="sc-cron-num"> + <h2>月</h2> + <h4>{{ value_month }}</h4> + </div> + </template> + <el-form> + <el-form-item label="类型"> + <el-radio-group v-model="cronValue.month.type"> + <el-radio-button label="0">任意值</el-radio-button> + <el-radio-button label="1">范围</el-radio-button> + <el-radio-button label="2">间隔</el-radio-button> + <el-radio-button label="3">指定</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item v-if="cronValue.month.type == '1'" label="范围"> + <el-input-number + v-model="cronValue.month.range.start" + :max="12" + :min="1" + controls-position="right" + /> + <span style="padding: 0 15px">-</span> + <el-input-number + v-model="cronValue.month.range.end" + :max="12" + :min="1" + controls-position="right" + /> + </el-form-item> + <el-form-item v-if="cronValue.month.type == '2'" label="间隔"> + <el-input-number + v-model="cronValue.month.loop.start" + :max="12" + :min="1" + controls-position="right" + /> + 月开始,每 + <el-input-number + v-model="cronValue.month.loop.end" + :max="12" + :min="1" + controls-position="right" + /> + 月执行一次 + </el-form-item> + <el-form-item v-if="cronValue.month.type == '3'" label="指定"> + <el-select v-model="cronValue.month.appoint" multiple style="width: 100%"> + <el-option + v-for="(item, index) in data.month" + :key="index" + :label="item" + :value="item" + /> + </el-select> + </el-form-item> + </el-form> + </el-tab-pane> + <el-tab-pane> + <template #label> + <div class="sc-cron-num"> + <h2>周</h2> + <h4>{{ value_week }}</h4> + </div> + </template> + <el-form> + <el-form> + <el-form-item label="类型"> + <el-radio-group v-model="cronValue.week.type"> + <el-radio-button label="0">任意值</el-radio-button> + <el-radio-button label="1">范围</el-radio-button> + <el-radio-button label="2">间隔</el-radio-button> + <el-radio-button label="3">指定</el-radio-button> + <el-radio-button label="4">本月最后一周</el-radio-button> + <el-radio-button label="5">不指定</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item v-if="cronValue.week.type == '1'" label="范围"> + <el-select v-model="cronValue.week.range.start"> + <el-option + v-for="(item, index) in data.week" + :key="index" + :label="item.label" + :value="item.value" + /> + </el-select> + <span style="padding: 0 15px">-</span> + <el-select v-model="cronValue.week.range.end"> + <el-option + v-for="(item, index) in data.week" + :key="index" + :label="item.label" + :value="item.value" + /> + </el-select> + </el-form-item> + <el-form-item v-if="cronValue.week.type == '2'" label="间隔"> + 第 + <el-input-number + v-model="cronValue.week.loop.start" + :max="4" + :min="1" + controls-position="right" + /> + 周的星期 + <el-select v-model="cronValue.week.loop.end"> + <el-option + v-for="(item, index) in data.week" + :key="index" + :label="item.label" + :value="item.value" + /> + </el-select> + 执行一次 + </el-form-item> + <el-form-item v-if="cronValue.week.type == '3'" label="指定"> + <el-select v-model="cronValue.week.appoint" multiple style="width: 100%"> + <el-option + v-for="(item, index) in data.week" + :key="index" + :label="item.label" + :value="item.value" + /> + </el-select> + </el-form-item> + <el-form-item v-if="cronValue.week.type == '4'" label="最后一周"> + <el-select v-model="cronValue.week.last"> + <el-option + v-for="(item, index) in data.week" + :key="index" + :label="item.label" + :value="item.value" + /> + </el-select> + </el-form-item> + </el-form> + </el-form> + </el-tab-pane> + <el-tab-pane> + <template #label> + <div class="sc-cron-num"> + <h2>年</h2> + <h4>{{ value_year }}</h4> + </div> + </template> + <el-form> + <el-form-item label="类型"> + <el-radio-group v-model="cronValue.year.type"> + <el-radio-button label="-1">忽略</el-radio-button> + <el-radio-button label="0">任意值</el-radio-button> + <el-radio-button label="1">范围</el-radio-button> + <el-radio-button label="2">间隔</el-radio-button> + <el-radio-button label="3">指定</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item v-if="cronValue.year.type == '1'" label="范围"> + <el-input-number v-model="cronValue.year.range.start" controls-position="right" /> + <span style="padding: 0 15px">-</span> + <el-input-number v-model="cronValue.year.range.end" controls-position="right" /> + </el-form-item> + <el-form-item v-if="cronValue.year.type == '2'" label="间隔"> + <el-input-number v-model="cronValue.year.loop.start" controls-position="right" /> + 年开始,每 + <el-input-number + v-model="cronValue.year.loop.end" + :min="1" + controls-position="right" + /> + 年执行一次 + </el-form-item> + <el-form-item v-if="cronValue.year.type == '3'" label="指定"> + <el-select v-model="cronValue.year.appoint" multiple style="width: 100%"> + <el-option + v-for="(item, index) in data.year" + :key="index" + :label="item" + :value="item" + /> + </el-select> + </el-form-item> + </el-form> + </el-tab-pane> + </el-tabs> + </div> + + <template #footer> + <el-button @click="dialogVisible = false">取 消</el-button> + <el-button type="primary" @click="submit()">确 认</el-button> + </template> + </el-dialog> +</template> + +<style scoped> +.sc-cron:deep(.el-tabs__item) { + height: auto; + padding: 0 7px; + line-height: 1; + vertical-align: bottom; +} + +.sc-cron-num { + width: 100%; + margin-bottom: 15px; + text-align: center; +} + +.sc-cron-num h2 { + margin-bottom: 15px; + font-size: 12px; + font-weight: normal; +} + +.sc-cron-num h4 { + display: block; + width: 100%; + height: 32px; + padding: 0 15px; + font-size: 12px; + line-height: 30px; + background: var(--el-color-primary-light-9); + border-radius: 4px; +} + +.sc-cron:deep(.el-tabs__item.is-active) .sc-cron-num h4 { + color: #fff; + background: var(--el-color-primary); +} + +[data-theme='dark'] .sc-cron-num h4 { + background: var(--el-color-white); +} + +.input-with-select .el-input-group__prepend { + background-color: var(--el-fill-color-blank); +} +</style> diff --git a/src/components/Cropper/index.ts b/src/components/Cropper/index.ts new file mode 100644 index 0000000..8fcc618 --- /dev/null +++ b/src/components/Cropper/index.ts @@ -0,0 +1,4 @@ +import CropperImage from './src/Cropper.vue' +import CropperAvatar from './src/CropperAvatar.vue' + +export { CropperImage, CropperAvatar } diff --git a/src/components/Cropper/src/CopperModal.vue b/src/components/Cropper/src/CopperModal.vue new file mode 100644 index 0000000..27052b8 --- /dev/null +++ b/src/components/Cropper/src/CopperModal.vue @@ -0,0 +1,261 @@ +<template> + <div> + <Dialog + v-model="dialogVisible" + :canFullscreen="false" + :title="t('cropper.modalTitle')" + maxHeight="380px" + width="800px" + > + <div :class="prefixCls"> + <div :class="`${prefixCls}-left`"> + <div :class="`${prefixCls}-cropper`"> + <CropperImage + v-if="src" + :circled="circled" + :src="src" + height="300px" + @cropend="handleCropend" + @ready="handleReady" + /> + </div> + + <div :class="`${prefixCls}-toolbar`"> + <el-upload :beforeUpload="handleBeforeUpload" :fileList="[]" accept="image/*"> + <el-tooltip :content="t('cropper.selectImage')" placement="bottom"> + <XButton preIcon="ant-design:upload-outlined" type="primary" /> + </el-tooltip> + </el-upload> + <el-space> + <el-tooltip :content="t('cropper.btn_reset')" placement="bottom"> + <XButton + :disabled="!src" + preIcon="ant-design:reload-outlined" + size="small" + type="primary" + @click="handlerToolbar('reset')" + /> + </el-tooltip> + <el-tooltip :content="t('cropper.btn_rotate_left')" placement="bottom"> + <XButton + :disabled="!src" + preIcon="ant-design:rotate-left-outlined" + size="small" + type="primary" + @click="handlerToolbar('rotate', -45)" + /> + </el-tooltip> + <el-tooltip :content="t('cropper.btn_rotate_right')" placement="bottom"> + <XButton + :disabled="!src" + preIcon="ant-design:rotate-right-outlined" + size="small" + type="primary" + @click="handlerToolbar('rotate', 45)" + /> + </el-tooltip> + <el-tooltip :content="t('cropper.btn_scale_x')" placement="bottom"> + <XButton + :disabled="!src" + preIcon="vaadin:arrows-long-h" + size="small" + type="primary" + @click="handlerToolbar('scaleX')" + /> + </el-tooltip> + <el-tooltip :content="t('cropper.btn_scale_y')" placement="bottom"> + <XButton + :disabled="!src" + preIcon="vaadin:arrows-long-v" + size="small" + type="primary" + @click="handlerToolbar('scaleY')" + /> + </el-tooltip> + <el-tooltip :content="t('cropper.btn_zoom_in')" placement="bottom"> + <XButton + :disabled="!src" + preIcon="ant-design:zoom-in-outlined" + size="small" + type="primary" + @click="handlerToolbar('zoom', 0.1)" + /> + </el-tooltip> + <el-tooltip :content="t('cropper.btn_zoom_out')" placement="bottom"> + <XButton + :disabled="!src" + preIcon="ant-design:zoom-out-outlined" + size="small" + type="primary" + @click="handlerToolbar('zoom', -0.1)" + /> + </el-tooltip> + </el-space> + </div> + </div> + <div :class="`${prefixCls}-right`"> + <div :class="`${prefixCls}-preview`"> + <img v-if="previewSource" :alt="t('cropper.preview')" :src="previewSource" /> + </div> + <template v-if="previewSource"> + <div :class="`${prefixCls}-group`"> + <el-avatar :src="previewSource" size="large" /> + <el-avatar :size="48" :src="previewSource" /> + <el-avatar :size="64" :src="previewSource" /> + <el-avatar :size="80" :src="previewSource" /> + </div> + </template> + </div> + </div> + <template #footer> + <el-button type="primary" @click="handleOk">{{ t('cropper.okText') }}</el-button> + </template> + </Dialog> + </div> +</template> +<script lang="ts" setup> +import { useDesign } from '@/hooks/web/useDesign' +import { dataURLtoBlob } from '@/utils/filt' +import { useI18n } from 'vue-i18n' +import type { CropendResult, Cropper } from './types' +import { propTypes } from '@/utils/propTypes' +import { CropperImage } from '@/components/Cropper' + +defineOptions({ name: 'CopperModal' }) + +const props = defineProps({ + srcValue: propTypes.string.def(''), + circled: propTypes.bool.def(true) +}) +const emit = defineEmits(['uploadSuccess']) +const { t } = useI18n() +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('cropper-am') + +const src = ref(props.srcValue) +const previewSource = ref('') +const cropper = ref<Cropper>() +const dialogVisible = ref(false) +let filename = '' +let scaleX = 1 +let scaleY = 1 + +// Block upload +function handleBeforeUpload(file: File) { + const reader = new FileReader() + reader.readAsDataURL(file) + src.value = '' + previewSource.value = '' + reader.onload = function (e) { + src.value = (e.target?.result as string) ?? '' + filename = file.name + } + return false +} + +function handleCropend({ imgBase64 }: CropendResult) { + previewSource.value = imgBase64 +} + +function handleReady(cropperInstance: Cropper) { + cropper.value = cropperInstance +} + +function handlerToolbar(event: string, arg?: number) { + if (event === 'scaleX') { + scaleX = arg = scaleX === -1 ? 1 : -1 + } + if (event === 'scaleY') { + scaleY = arg = scaleY === -1 ? 1 : -1 + } + cropper?.value?.[event]?.(arg) +} + +async function handleOk() { + const blob = dataURLtoBlob(previewSource.value) + emit('uploadSuccess', { source: previewSource.value, data: blob, filename: filename }) +} + +function openModal() { + dialogVisible.value = true +} + +function closeModal() { + dialogVisible.value = false +} + +defineExpose({ openModal, closeModal }) +</script> +<style lang="scss"> +$prefix-cls: #{$namespace}-cropper-am; + +.#{$prefix-cls} { + display: flex; + + &-left, + &-right { + height: 340px; + } + + &-left { + width: 55%; + } + + &-right { + width: 45%; + } + + &-cropper { + height: 300px; + background: #eee; + background-image: linear-gradient( + 45deg, + rgb(0 0 0 / 25%) 25%, + transparent 0, + transparent 75%, + rgb(0 0 0 / 25%) 0 + ), + linear-gradient( + 45deg, + rgb(0 0 0 / 25%) 25%, + transparent 0, + transparent 75%, + rgb(0 0 0 / 25%) 0 + ); + background-position: + 0 0, + 12px 12px; + background-size: 24px 24px; + } + + &-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 10px; + } + + &-preview { + width: 220px; + height: 220px; + margin: 0 auto; + overflow: hidden; + border: 1px solid; + border-radius: 50%; + + img { + width: 100%; + height: 100%; + } + } + + &-group { + display: flex; + padding-top: 8px; + margin-top: 8px; + border-top: 1px solid; + justify-content: space-around; + align-items: center; + } +} +</style> diff --git a/src/components/Cropper/src/Cropper.vue b/src/components/Cropper/src/Cropper.vue new file mode 100644 index 0000000..871aed8 --- /dev/null +++ b/src/components/Cropper/src/Cropper.vue @@ -0,0 +1,183 @@ +<template> + <div :class="getClass" :style="getWrapperStyle"> + <img + v-show="isReady" + ref="imgElRef" + :alt="alt" + :crossorigin="crossorigin" + :src="src" + :style="getImageStyle" + /> + </div> +</template> +<script lang="ts" setup> +import { CSSProperties, PropType } from 'vue' +import Cropper from 'cropperjs' +import 'cropperjs/dist/cropper.css' +import { useDesign } from '@/hooks/web/useDesign' +import { propTypes } from '@/utils/propTypes' +import { useDebounceFn } from '@vueuse/core' + +defineOptions({ name: 'Cropper' }) + +type Options = Cropper.Options + +const defaultOptions: Options = { + aspectRatio: 1, + zoomable: true, + zoomOnTouch: true, + zoomOnWheel: true, + cropBoxMovable: true, + cropBoxResizable: true, + toggleDragModeOnDblclick: true, + autoCrop: true, + background: true, + highlight: true, + center: true, + responsive: true, + restore: true, + checkCrossOrigin: true, + checkOrientation: true, + scalable: true, + modal: true, + guides: true, + movable: true, + rotatable: true +} + +const props = defineProps({ + src: propTypes.string.def(''), + alt: propTypes.string.def(''), + circled: propTypes.bool.def(false), + realTimePreview: propTypes.bool.def(true), + height: propTypes.string.def('360px'), + crossorigin: { + type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>, + default: undefined + }, + imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) }, + options: { type: Object as PropType<Options>, default: () => ({}) } +}) + +const emit = defineEmits(['cropend', 'ready', 'cropendError']) +const attrs = useAttrs() +const imgElRef = ref<ElRef<HTMLImageElement>>() +const cropper = ref<Nullable<Cropper>>() +const isReady = ref(false) + +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('cropper-image') +const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80) + +const getImageStyle = computed((): CSSProperties => { + return { + height: props.height, + maxWidth: '100%', + ...props.imageStyle + } +}) + +const getClass = computed(() => { + return [ + prefixCls, + attrs.class, + { + [`${prefixCls}--circled`]: props.circled + } + ] +}) +const getWrapperStyle = computed((): CSSProperties => { + return { height: `${props.height}`.replace(/px/, '') + 'px' } +}) + +onMounted(init) + +onUnmounted(() => { + cropper.value?.destroy() +}) + +async function init() { + const imgEl = unref(imgElRef) + if (!imgEl) { + return + } + cropper.value = new Cropper(imgEl, { + ...defaultOptions, + ready: () => { + isReady.value = true + realTimeCroppered() + emit('ready', cropper.value) + }, + crop() { + debounceRealTimeCroppered() + }, + zoom() { + debounceRealTimeCroppered() + }, + cropmove() { + debounceRealTimeCroppered() + }, + ...props.options + }) +} + +// Real-time display preview +function realTimeCroppered() { + props.realTimePreview && croppered() +} + +// event: return base64 and width and height information after cropping +function croppered() { + if (!cropper.value) { + return + } + let imgInfo = cropper.value.getData() + const canvas = props.circled ? getRoundedCanvas() : cropper.value.getCroppedCanvas() + canvas.toBlob((blob) => { + if (!blob) { + return + } + let fileReader: FileReader = new FileReader() + fileReader.readAsDataURL(blob) + fileReader.onloadend = (e) => { + emit('cropend', { + imgBase64: e.target?.result ?? '', + imgInfo + }) + } + fileReader.onerror = () => { + emit('cropendError') + } + }, 'image/png') +} + +// Get a circular picture canvas +function getRoundedCanvas() { + const sourceCanvas = cropper.value!.getCroppedCanvas() + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d')! + const width = sourceCanvas.width + const height = sourceCanvas.height + canvas.width = width + canvas.height = height + context.imageSmoothingEnabled = true + context.drawImage(sourceCanvas, 0, 0, width, height) + context.globalCompositeOperation = 'destination-in' + context.beginPath() + context.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, 2 * Math.PI, true) + context.fill() + return canvas +} +</script> +<style lang="scss"> +$prefix-cls: #{$namespace}-cropper-image; + +.#{$prefix-cls} { + &--circled { + .cropper-view-box, + .cropper-face { + border-radius: 50%; + } + } +} +</style> diff --git a/src/components/Cropper/src/CropperAvatar.vue b/src/components/Cropper/src/CropperAvatar.vue new file mode 100644 index 0000000..9464c2a --- /dev/null +++ b/src/components/Cropper/src/CropperAvatar.vue @@ -0,0 +1,142 @@ +<template> + <div class="user-info-head" @click="open()"> + <el-avatar v-if="sourceValue" :src="sourceValue" alt="avatar" class="img-circle img-lg" /> + <el-avatar v-if="!sourceValue" :src="avatar" alt="avatar" class="img-circle img-lg" /> + <el-button v-if="showBtn" :class="`${prefixCls}-upload-btn`" @click="open()"> + {{ btnText ? btnText : t('cropper.selectImage') }} + </el-button> + <CopperModal + ref="cropperModelRef" + :srcValue="sourceValue" + @upload-success="handleUploadSuccess" + /> + </div> +</template> +<script lang="ts" setup> +import { useDesign } from '@/hooks/web/useDesign' + +import { propTypes } from '@/utils/propTypes' +import { useI18n } from 'vue-i18n' +import CopperModal from './CopperModal.vue' +import avatar from '@/assets/imgs/avatar.gif' + +defineOptions({ name: 'CropperAvatar' }) + +const props = defineProps({ + width: propTypes.string.def('200px'), + value: propTypes.string.def(''), + showBtn: propTypes.bool.def(true), + btnText: propTypes.string.def('') +}) + +const emit = defineEmits(['update:value', 'change']) +const sourceValue = ref(props.value) +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('cropper-avatar') +const message = useMessage() +const { t } = useI18n() + +const cropperModelRef = ref() + +watchEffect(() => { + sourceValue.value = props.value +}) + +watch( + () => sourceValue.value, + (v: string) => { + emit('update:value', v) + } +) + +function handleUploadSuccess({ source, data, filename }) { + sourceValue.value = source + emit('change', { source, data, filename }) + message.success(t('cropper.uploadSuccess')) +} + +function open() { + cropperModelRef.value.openModal() +} + +function close() { + cropperModelRef.value.closeModal() +} + +defineExpose({ + open, + close +}) +</script> +<style lang="scss" scoped> +$prefix-cls: #{$namespace}--cropper-avatar; + +.#{$prefix-cls} { + display: inline-block; + text-align: center; + + &-image-wrapper { + overflow: hidden; + cursor: pointer; + border: 1px solid; + border-radius: 50%; + + img { + width: 100%; + } + } + + &-image-mask { + position: absolute; + width: inherit; + height: inherit; + cursor: pointer; + background: rgb(0 0 0 / 40%); + border: inherit; + border-radius: inherit; + opacity: 0; + transition: opacity 0.4s; + + ::v-deep(svg) { + margin: auto; + } + } + + &-image-mask:hover { + opacity: 40; + } + + &-upload-btn { + margin: 10px auto; + } +} + +.user-info-head { + position: relative; + display: inline-block; +} + +.img-circle { + border-radius: 50%; +} + +.img-lg { + width: 120px; + height: 120px; +} + +.user-info-head:hover::after { + position: absolute; + inset: 0; + font-size: 24px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-style: normal; + line-height: 110px; + color: #eee; + cursor: pointer; + background: rgb(0 0 0 / 50%); + border-radius: 50%; + content: '+'; +} +</style> diff --git a/src/components/Cropper/src/types.ts b/src/components/Cropper/src/types.ts new file mode 100644 index 0000000..bcad3b4 --- /dev/null +++ b/src/components/Cropper/src/types.ts @@ -0,0 +1,8 @@ +import type Cropper from 'cropperjs' + +export interface CropendResult { + imgBase64: string + imgInfo: Cropper.Data +} + +export type { Cropper } diff --git a/src/components/Descriptions/index.ts b/src/components/Descriptions/index.ts new file mode 100644 index 0000000..243bc39 --- /dev/null +++ b/src/components/Descriptions/index.ts @@ -0,0 +1,4 @@ +import Descriptions from './src/Descriptions.vue' +import DescriptionsItemLabel from './src/DescriptionsItemLabel.vue' + +export { Descriptions, DescriptionsItemLabel } diff --git a/src/components/Descriptions/src/Descriptions.vue b/src/components/Descriptions/src/Descriptions.vue new file mode 100644 index 0000000..184d95c --- /dev/null +++ b/src/components/Descriptions/src/Descriptions.vue @@ -0,0 +1,167 @@ +<script lang="ts" setup> +import { PropType } from 'vue' +import dayjs from 'dayjs' +import { useDesign } from '@/hooks/web/useDesign' +import { propTypes } from '@/utils/propTypes' +import { useAppStore } from '@/store/modules/app' +import { DescriptionsSchema } from '@/types/descriptions' + +defineOptions({ name: 'Descriptions' }) + +const appStore = useAppStore() + +const mobile = computed(() => appStore.getMobile) + +const attrs = useAttrs() + +const slots = useSlots() + +const props = defineProps({ + title: propTypes.string.def(''), + message: propTypes.string.def(''), + collapse: propTypes.bool.def(true), + columns: propTypes.number.def(1), + schema: { + type: Array as PropType<DescriptionsSchema[]>, + default: () => [] + }, + data: { + type: Object as PropType<any>, + default: () => ({}) + } +}) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('descriptions') + +const getBindValue = computed(() => { + const delArr: string[] = ['title', 'message', 'collapse', 'schema', 'data', 'class'] + const obj = { ...attrs, ...props } + for (const key in obj) { + if (delArr.indexOf(key) !== -1) { + delete obj[key] + } + } + return obj +}) + +const getBindItemValue = (item: DescriptionsSchema) => { + const delArr: string[] = ['field'] + const obj = { ...item } + for (const key in obj) { + if (delArr.indexOf(key) !== -1) { + delete obj[key] + } + } + return obj +} + +// 折叠 +const show = ref(true) + +const toggleClick = () => { + if (props.collapse) { + show.value = !unref(show) + } +} +</script> + +<template> + <div + :class="[ + prefixCls, + 'bg-[var(--el-color-white)] dark:bg-[var(--el-bg-color)] dark:border-[var(--el-border-color)] dark:border-1px' + ]" + > + <div + v-if="title" + :class="[ + `${prefixCls}-header`, + 'h-50px flex justify-between items-center b-b-1 border-solid border-[var(--el-border-color)] px-10px cursor-pointer dark:border-[var(--el-border-color)]' + ]" + @click="toggleClick" + > + <div :class="[`${prefixCls}-header__title`, 'relative font-18px font-bold ml-10px']"> + <div class="flex items-center"> + {{ title }} + <ElTooltip v-if="message" :content="message" placement="right"> + <Icon class="ml-5px" icon="ep:warning" /> + </ElTooltip> + </div> + </div> + <Icon v-if="collapse" :icon="show ? 'ep:arrow-down' : 'ep:arrow-up'" /> + </div> + + <ElCollapseTransition> + <div v-show="show" :class="[`${prefixCls}-content`, 'p-10px']"> + <ElDescriptions + :column="props.columns" + :direction="mobile ? 'vertical' : 'horizontal'" + border + v-bind="getBindValue" + > + <template v-if="slots['extra']" #extra> + <slot name="extra"></slot> + </template> + <ElDescriptionsItem + v-for="item in schema" + :key="item.field" + min-width="80" + v-bind="getBindItemValue(item)" + > + <template #label> + <slot + :name="`${item.field}-label`" + :row="{ + label: item.label + }" + >{{ item.label }} + </slot> + </template> + + <template #default> + <slot v-if="item.dateFormat"> + {{ + data[item.field] !== null ? dayjs(data[item.field]).format(item.dateFormat) : '' + }} + </slot> + <slot v-else-if="item.dictType"> + <DictTag :type="item.dictType" :value="data[item.field] + ''" /> + </slot> + <slot v-else :name="item.field" :row="data"> + {{ + item.mappedField ? data[item.mappedField] : data[item.field] + }} + </slot> + </template> + </ElDescriptionsItem> + </ElDescriptions> + </div> + </ElCollapseTransition> + </div> +</template> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-descriptions; + +.#{$prefix-cls}-header { + &__title { + &::after { + position: absolute; + top: 3px; + left: -10px; + width: 4px; + height: 70%; + background: var(--el-color-primary); + content: ''; + } + } +} + +.#{$prefix-cls}-content { + :deep(.#{$elNamespace}-descriptions__cell) { + width: 0; + } +} +</style> diff --git a/src/components/Descriptions/src/DescriptionsItemLabel.vue b/src/components/Descriptions/src/DescriptionsItemLabel.vue new file mode 100644 index 0000000..4efb2fb --- /dev/null +++ b/src/components/Descriptions/src/DescriptionsItemLabel.vue @@ -0,0 +1,29 @@ +<script setup lang="ts"> +const { label } = defineProps({ + label: { + type: String, + required: true + }, + icon: { + type: String, + required: false + } +}) +</script> + +<template> + <div class="cell-item"> + <Icon :icon="icon" v-if="icon" style="vertical-align: middle" :size="18" /> + {{ label }} + </div> +</template> + +<style scoped lang="scss"> +.cell-item { + display: inline; +} + +.cell-item::after { + content: ':'; +} +</style> diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts new file mode 100644 index 0000000..1655dad --- /dev/null +++ b/src/components/Dialog/index.ts @@ -0,0 +1,3 @@ +import Dialog from './src/Dialog.vue' + +export { Dialog } diff --git a/src/components/Dialog/src/Dialog.vue b/src/components/Dialog/src/Dialog.vue new file mode 100644 index 0000000..a1eb550 --- /dev/null +++ b/src/components/Dialog/src/Dialog.vue @@ -0,0 +1,140 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { isNumber } from '@/utils/is' +defineOptions({ name: 'Dialog' }) + +const slots = useSlots() + +const props = defineProps({ + modelValue: propTypes.bool.def(false), + title: propTypes.string.def('Dialog'), + fullscreen: propTypes.bool.def(true), + width: propTypes.oneOfType([String, Number]).def('40%'), + scroll: propTypes.bool.def(false), // 是否开启滚动条。如果是的话,按照 maxHeight 设置最大高度 + maxHeight: propTypes.oneOfType([String, Number]).def('400px') +}) + +const getBindValue = computed(() => { + const delArr: string[] = ['fullscreen', 'title', 'maxHeight', 'appendToBody'] + const attrs = useAttrs() + const obj = { ...attrs, ...props } + for (const key in obj) { + if (delArr.indexOf(key) !== -1) { + delete obj[key] + } + } + return obj +}) + +const isFullscreen = ref(false) + +const toggleFull = () => { + isFullscreen.value = !unref(isFullscreen) +} + +const dialogHeight = ref(isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight) + +watch( + () => isFullscreen.value, + async (val: boolean) => { + await nextTick() + if (val) { + const windowHeight = document.documentElement.offsetHeight + dialogHeight.value = `${windowHeight - 55 - 60 - (slots.footer ? 63 : 0)}px` + } else { + dialogHeight.value = isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight + } + }, + { + immediate: true + } +) + +const dialogStyle = computed(() => { + return { + height: unref(dialogHeight) + } +}) +</script> + +<template> + <ElDialog + v-bind="getBindValue" + :close-on-click-modal="true" + :fullscreen="isFullscreen" + :width="width" + destroy-on-close + lock-scroll + draggable + class="com-dialog" + :show-close="false" + > + <template #header="{ close }"> + <div class="relative h-54px flex items-center justify-between pl-15px pr-15px"> + <slot name="title"> + {{ title }} + </slot> + <div + class="absolute right-15px top-[50%] h-54px flex translate-y-[-50%] items-center justify-between" + > + <Icon + v-if="fullscreen" + class="is-hover mr-10px cursor-pointer" + :icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'" + color="var(--el-color-info)" + hover-color="var(--el-color-primary)" + @click="toggleFull" + /> + <Icon + class="is-hover cursor-pointer" + icon="ep:close" + hover-color="var(--el-color-primary)" + color="var(--el-color-info)" + @click="close" + /> + </div> + </div> + </template> + + <ElScrollbar v-if="scroll" :style="dialogStyle"> + <slot></slot> + </ElScrollbar> + <slot v-else></slot> + <template v-if="slots.footer" #footer> + <slot name="footer"></slot> + </template> + </ElDialog> +</template> + +<style lang="scss"> +.com-dialog { + .#{$elNamespace}-overlay-dialog { + display: flex; + justify-content: center; + align-items: center; + } + + .#{$elNamespace}-dialog { + margin: 0 !important; + + &__header { + height: 54px; + padding: 0; + margin-right: 0 !important; + border-bottom: 1px solid var(--el-border-color); + } + + &__body { + padding: 15px !important; + } + + &__footer { + border-top: 1px solid var(--el-border-color); + } + + &__headerbtn { + top: 0; + } + } +} +</style> diff --git a/src/components/DictTag/index.ts b/src/components/DictTag/index.ts new file mode 100644 index 0000000..4db2742 --- /dev/null +++ b/src/components/DictTag/index.ts @@ -0,0 +1,3 @@ +import DictTag from './src/DictTag.vue' + +export { DictTag } diff --git a/src/components/DictTag/src/DictTag.vue b/src/components/DictTag/src/DictTag.vue new file mode 100644 index 0000000..8835774 --- /dev/null +++ b/src/components/DictTag/src/DictTag.vue @@ -0,0 +1,90 @@ +<script lang="tsx"> +import { defineComponent, PropType, computed } from 'vue' +import { isHexColor } from '@/utils/color' +import { ElTag } from 'element-plus' +import { DictDataType, getDictOptions } from '@/utils/dict' +import { isArray, isString, isNumber, isBoolean } from '@/utils/is' + +export default defineComponent({ + name: 'DictTag', + props: { + type: { + type: String as PropType<string>, + required: true + }, + value: { + type: [String, Number, Boolean, Array], + required: true + }, + // 字符串分隔符 只有当 props.value 传入值为字符串时有效 + separator: { + type: String as PropType<string>, + default: ',' + }, + // 每个 tag 之间的间隔,默认为 5px,参考的 el-row 的 gutter + gutter: { + type: String as PropType<string>, + default: '5px' + } + }, + setup(props) { + const valueArr: any = computed(() => { + // 1. 是 Number 类型和 Boolean 类型的情况 + if (isNumber(props.value) || isBoolean(props.value)) { + return [String(props.value)] + } + // 2. 是字符串(进一步判断是否有包含分隔符号 -> props.sepSymbol ) + else if (isString(props.value)) { + return props.value.split(props.separator) + } + // 3. 数组 + else if (isArray(props.value)) { + return props.value.map(String) + } + return [] + }) + const renderDictTag = () => { + if (!props.type) { + return null + } + // 解决自定义字典标签值为零时标签不渲染的问题 + if (props.value === undefined || props.value === null || props.value === '') { + return null + } + const dictOptions = getDictOptions(props.type) + + return ( + <div + class="dict-tag" + style={{ + display: 'inline-flex', + gap: props.gutter, + justifyContent: 'center', + alignItems: 'center' + }} + > + {dictOptions.map((dict: DictDataType) => { + if (valueArr.value.includes(dict.value)) { + if (dict.colorType + '' === 'primary' || dict.colorType + '' === 'default') { + dict.colorType = '' + } + return ( + // 添加标签的文字颜色为白色,解决自定义背景颜色时标签文字看不清的问题 + <ElTag + style={dict?.cssClass ? 'color: #fff' : ''} + type={dict?.colorType || null} + color={dict?.cssClass && isHexColor(dict?.cssClass) ? dict?.cssClass : ''} + disableTransitions={true} + > + {dict?.label} + </ElTag> + ) + } + })} + </div> + ) + } + return () => renderDictTag() + } +}) +</script> diff --git a/src/components/DiyEditor/components/ComponentContainer.vue b/src/components/DiyEditor/components/ComponentContainer.vue new file mode 100644 index 0000000..0137278 --- /dev/null +++ b/src/components/DiyEditor/components/ComponentContainer.vue @@ -0,0 +1,238 @@ +<template> + <div :class="['component', { active: active }]"> + <div + :style="{ + ...style + }" + > + <component :is="component.id" :property="component.property" /> + </div> + <div class="component-wrap"> + <!-- 左侧:组件名(悬浮的小贴条) --> + <div class="component-name" v-if="component.name"> + {{ component.name }} + </div> + <!-- 右侧:组件操作工具栏 --> + <div class="component-toolbar" v-if="showToolbar && component.name && active"> + <VerticalButtonGroup type="primary"> + <el-tooltip content="上移" placement="right"> + <el-button :disabled="!canMoveUp" @click.stop="handleMoveComponent(-1)"> + <Icon icon="ep:arrow-up" /> + </el-button> + </el-tooltip> + <el-tooltip content="下移" placement="right"> + <el-button :disabled="!canMoveDown" @click.stop="handleMoveComponent(1)"> + <Icon icon="ep:arrow-down" /> + </el-button> + </el-tooltip> + <el-tooltip content="复制" placement="right"> + <el-button @click.stop="handleCopyComponent()"> + <Icon icon="ep:copy-document" /> + </el-button> + </el-tooltip> + <el-tooltip content="删除" placement="right"> + <el-button @click.stop="handleDeleteComponent()"> + <Icon icon="ep:delete" /> + </el-button> + </el-tooltip> + </VerticalButtonGroup> + </div> + </div> + </div> +</template> + +<script lang="ts"> +// 注册所有的组件 +import { components } from '../components/mobile/index' +export default { + components: { ...components } +} +</script> +<script setup lang="ts"> +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' +import { propTypes } from '@/utils/propTypes' +import { object } from 'vue-types' + +/** + * 组件容器:目前在中间部分 + * 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式 + */ +defineOptions({ name: 'ComponentContainer' }) + +type DiyComponentWithStyle = DiyComponent<any> & { property: { style?: ComponentStyle } } +const props = defineProps({ + component: object<DiyComponentWithStyle>().isRequired, + active: propTypes.bool.def(false), + canMoveUp: propTypes.bool.def(false), + canMoveDown: propTypes.bool.def(false), + showToolbar: propTypes.bool.def(true) +}) + +/** + * 组件样式 + */ +const style = computed(() => { + let componentStyle = props.component.property.style + if (!componentStyle) { + return {} + } + return { + marginTop: `${componentStyle.marginTop || 0}px`, + marginBottom: `${componentStyle.marginBottom || 0}px`, + marginLeft: `${componentStyle.marginLeft || 0}px`, + marginRight: `${componentStyle.marginRight || 0}px`, + paddingTop: `${componentStyle.paddingTop || 0}px`, + paddingRight: `${componentStyle.paddingRight || 0}px`, + paddingBottom: `${componentStyle.paddingBottom || 0}px`, + paddingLeft: `${componentStyle.paddingLeft || 0}px`, + borderTopLeftRadius: `${componentStyle.borderTopLeftRadius || 0}px`, + borderTopRightRadius: `${componentStyle.borderTopRightRadius || 0}px`, + borderBottomRightRadius: `${componentStyle.borderBottomRightRadius || 0}px`, + borderBottomLeftRadius: `${componentStyle.borderBottomLeftRadius || 0}px`, + overflow: 'hidden', + background: + componentStyle.bgType === 'color' ? componentStyle.bgColor : `url(${componentStyle.bgImg})` + } +}) + +const emits = defineEmits<{ + (e: 'move', direction: number): void + (e: 'copy'): void + (e: 'delete'): void +}>() + +/** + * 移动组件 + * @param direction 移动方向 + */ +const handleMoveComponent = (direction: number) => { + emits('move', direction) +} + +/** + * 复制组件 + */ +const handleCopyComponent = () => { + emits('copy') +} + +/** + * 删除组件 + */ +const handleDeleteComponent = () => { + emits('delete') +} +</script> + +<style scoped lang="scss"> +$active-border-width: 2px; +$hover-border-width: 1px; +$name-position: -85px; +$toolbar-position: -55px; + +/* 组件 */ +.component { + position: relative; + cursor: move; + + .component-wrap { + position: absolute; + top: 0; + left: -$active-border-width; + display: block; + width: 100%; + height: 100%; + + /* 鼠标放到组件上时 */ + &:hover { + border: $hover-border-width dashed var(--el-color-primary); + box-shadow: 0 0 5px 0 rgb(24 144 255 / 30%); + + .component-name { + top: $hover-border-width; + + /* 防止加了边框之后,位置移动 */ + left: $name-position - $hover-border-width; + } + } + + /* 左侧:组件名称 */ + .component-name { + position: absolute; + top: $active-border-width; + left: $name-position; + display: block; + width: 80px; + height: 25px; + font-size: 12px; + line-height: 25px; + text-align: center; + background: #fff; + box-shadow: + 0 0 4px #00000014, + 0 2px 6px #0000000f, + 0 4px 8px 2px #0000000a; + + /* 右侧小三角 */ + &::after { + position: absolute; + top: 7.5px; + right: -10px; + width: 0; + height: 0; + border: 5px solid transparent; + border-left-color: #fff; + content: ' '; + } + } + + /* 右侧:组件操作工具栏 */ + .component-toolbar { + position: absolute; + top: 0; + right: $toolbar-position; + display: none; + + /* 左侧小三角 */ + &::before { + position: absolute; + top: 10px; + left: -10px; + width: 0; + height: 0; + border: 5px solid transparent; + border-right-color: #2d8cf0; + content: ' '; + } + } + } + + /* 组件选中时 */ + &.active { + margin-bottom: 4px; + + .component-wrap { + margin-bottom: $active-border-width + $active-border-width; + border: $active-border-width solid var(--el-color-primary) !important; + box-shadow: 0 0 10px 0 rgb(24 144 255 / 30%); + + .component-name { + top: 0 !important; + + /* 防止加了边框之后,位置移动 */ + left: $name-position - $active-border-width !important; + color: #fff; + background: var(--el-color-primary); + + &::after { + border-left-color: var(--el-color-primary); + } + } + + .component-toolbar { + display: block; + } + } + } +} +</style> diff --git a/src/components/DiyEditor/components/ComponentContainerProperty.vue b/src/components/DiyEditor/components/ComponentContainerProperty.vue new file mode 100644 index 0000000..9d0750d --- /dev/null +++ b/src/components/DiyEditor/components/ComponentContainerProperty.vue @@ -0,0 +1,167 @@ +<template> + <el-tabs stretch> + <!-- 每个组件的自定义内容 --> + <el-tab-pane label="内容" v-if="$slots.default"> + <slot></slot> + </el-tab-pane> + + <!-- 每个组件的通用内容 --> + <el-tab-pane label="样式" lazy> + <el-card header="组件样式" class="property-group"> + <el-form :model="formData" label-width="80px"> + <el-form-item label="组件背景" prop="bgType"> + <el-radio-group v-model="formData.bgType"> + <el-radio label="color">纯色</el-radio> + <el-radio label="img">图片</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="选择颜色" prop="bgColor" v-if="formData.bgType === 'color'"> + <ColorInput v-model="formData.bgColor" /> + </el-form-item> + <el-form-item label="上传图片" prop="bgImg" v-else> + <UploadImg v-model="formData.bgImg" :limit="1"> + <template #tip>建议宽度 750px</template> + </UploadImg> + </el-form-item> + <el-tree :data="treeData" :expand-on-click-node="false" default-expand-all> + <template #default="{ node, data }"> + <el-form-item + :label="data.label" + :prop="data.prop" + :label-width="node.level === 1 ? '80px' : '62px'" + class="w-full m-b-0!" + > + <el-slider + v-model="formData[data.prop]" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + @input="handleSliderChange(data.prop)" + /> + </el-form-item> + </template> + </el-tree> + <slot name="style" :style="formData"></slot> + </el-form> + </el-card> + </el-tab-pane> + </el-tabs> +</template> + +<script setup lang="ts"> +import { ComponentStyle, usePropertyForm } from '@/components/DiyEditor/util' + +/** + * 组件容器属性:目前右边部分 + * 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式 + */ +defineOptions({ name: 'ComponentContainer' }) + +const props = defineProps<{ modelValue: ComponentStyle }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +const treeData = [ + { + label: '外部边距', + prop: 'margin', + children: [ + { + label: '上', + prop: 'marginTop' + }, + { + label: '右', + prop: 'marginRight' + }, + { + label: '下', + prop: 'marginBottom' + }, + { + label: '左', + prop: 'marginLeft' + } + ] + }, + { + label: '内部边距', + prop: 'padding', + children: [ + { + label: '上', + prop: 'paddingTop' + }, + { + label: '右', + prop: 'paddingRight' + }, + { + label: '下', + prop: 'paddingBottom' + }, + { + label: '左', + prop: 'paddingLeft' + } + ] + }, + { + label: '边框圆角', + prop: 'borderRadius', + children: [ + { + label: '上左', + prop: 'borderTopLeftRadius' + }, + { + label: '上右', + prop: 'borderTopRightRadius' + }, + { + label: '下右', + prop: 'borderBottomRightRadius' + }, + { + label: '下左', + prop: 'borderBottomLeftRadius' + } + ] + } +] + +const handleSliderChange = (prop: string) => { + switch (prop) { + case 'margin': + formData.value.marginTop = formData.value.margin + formData.value.marginRight = formData.value.margin + formData.value.marginBottom = formData.value.margin + formData.value.marginLeft = formData.value.margin + break + case 'padding': + formData.value.paddingTop = formData.value.padding + formData.value.paddingRight = formData.value.padding + formData.value.paddingBottom = formData.value.padding + formData.value.paddingLeft = formData.value.padding + break + case 'borderRadius': + formData.value.borderTopLeftRadius = formData.value.borderRadius + formData.value.borderTopRightRadius = formData.value.borderRadius + formData.value.borderBottomRightRadius = formData.value.borderRadius + formData.value.borderBottomLeftRadius = formData.value.borderRadius + break + } +} +</script> + +<style scoped lang="scss"> +:deep(.el-slider__runway) { + margin-right: 16px; +} + +:deep(.el-input-number) { + width: 50px; +} +</style> diff --git a/src/components/DiyEditor/components/ComponentLibrary.vue b/src/components/DiyEditor/components/ComponentLibrary.vue new file mode 100644 index 0000000..fdb0b1d --- /dev/null +++ b/src/components/DiyEditor/components/ComponentLibrary.vue @@ -0,0 +1,211 @@ +<template> + <el-aside class="editor-left" width="261px"> + <el-scrollbar> + <el-collapse v-model="extendGroups"> + <el-collapse-item + v-for="group in groups" + :key="group.name" + :name="group.name" + :title="group.name" + > + <draggable + class="component-container" + ghost-class="draggable-ghost" + item-key="index" + :list="group.components" + :sort="false" + :group="{ name: 'component', pull: 'clone', put: false }" + :clone="handleCloneComponent" + :animation="200" + :force-fallback="true" + > + <template #item="{ element }"> + <div> + <div class="drag-placement">组件放置区域</div> + <div class="component"> + <Icon :icon="element.icon" :size="32" /> + <span class="mt-4px text-12px">{{ element.name }}</span> + </div> + </div> + </template> + </draggable> + </el-collapse-item> + </el-collapse> + </el-scrollbar> + </el-aside> +</template> + +<script setup lang="ts"> +import draggable from 'vuedraggable' +import { componentConfigs } from '../components/mobile/index' +import { cloneDeep } from 'lodash-es' +import { DiyComponent, DiyComponentLibrary } from '@/components/DiyEditor/util' + +/** 组件库:目前左侧的【基础组件】、【图文组件】部分 */ +defineOptions({ name: 'ComponentLibrary' }) + +// 组件列表 +const props = defineProps<{ + list: DiyComponentLibrary[] +}>() +// 组件分组 +const groups = reactive<any[]>([]) +// 展开的折叠面板 +const extendGroups = reactive<string[]>([]) + +// 监听 list 属性,按照 DiyComponentLibrary 的 name 分组 +watch( + () => props.list, + () => { + // 清除旧数据 + extendGroups.length = 0 + groups.length = 0 + // 重新生成数据 + props.list.forEach((group) => { + // 是否展开分组 + if (group.extended) { + extendGroups.push(group.name) + } + // 查找组件 + const components = group.components + .map((name) => componentConfigs[name] as DiyComponent<any>) + .filter((component) => component) + if (components.length > 0) { + groups.push({ + name: group.name, + components + }) + } + }) + }, + { + immediate: true + } +) + +// 克隆组件 +const handleCloneComponent = (component: DiyComponent<any>) => { + const instance = cloneDeep(component) + instance.uid = new Date().getTime() + return instance +} +</script> + +<style scoped lang="scss"> +.editor-left { + z-index: 1; + flex-shrink: 0; + user-select: none; + box-shadow: 8px 0 8px -8px rgb(0 0 0 / 12%); + + :deep(.el-collapse) { + border-top: none; + } + + :deep(.el-collapse-item__wrap) { + border-bottom: none; + } + + :deep(.el-collapse-item__content) { + padding-bottom: 0; + } + + :deep(.el-collapse-item__header) { + height: 32px; + padding: 0 24px; + line-height: 32px; + background-color: var(--el-bg-color-page); + border-bottom: none; + } + + .component-container { + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .component { + display: flex; + width: 86px; + height: 86px; + cursor: move; + border-right: 1px solid var(--el-border-color-lighter); + border-bottom: 1px solid var(--el-border-color-lighter); + flex-direction: column; + align-items: center; + justify-content: center; + + .el-icon { + margin-bottom: 4px; + color: gray; + } + } + + .component.active, + .component:hover { + color: var(--el-color-white); + background: var(--el-color-primary); + + .el-icon { + color: var(--el-color-white); + } + } + + .component:nth-of-type(3n) { + border-right: none; + } +} + +/* 拖拽占位提示,默认不显示 */ +.drag-placement { + display: none; + color: #fff; +} + +.drag-area { + /* 拖拽到手机区域时的样式 */ + .draggable-ghost { + display: flex; + width: 100%; + height: 40px; + + /* 条纹背景 */ + background: linear-gradient( + 45deg, + #91a8d5 0, + #91a8d5 10%, + #94b4eb 10%, + #94b4eb 50%, + #91a8d5 50%, + #91a8d5 60%, + #94b4eb 60%, + #94b4eb + ); + background-size: 1rem 1rem; + transition: all 0.5s; + justify-content: center; + align-items: center; + + span { + display: inline-block; + width: 140px; + height: 25px; + font-size: 12px; + line-height: 25px; + color: #fff; + text-align: center; + background: #5487df; + } + + /* 拖拽时隐藏组件 */ + .component { + display: none; + } + + /* 拖拽时显示占位提示 */ + .drag-placement { + display: block; + } + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/Carousel/config.ts b/src/components/DiyEditor/components/mobile/Carousel/config.ts new file mode 100644 index 0000000..3e74a51 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Carousel/config.ts @@ -0,0 +1,50 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 轮播图属性 */ +export interface CarouselProperty { + // 类型:默认 | 卡片 + type: 'default' | 'card' + // 指示器样式:点 | 数字 + indicator: 'dot' | 'number' + // 是否自动播放 + autoplay: boolean + // 播放间隔 + interval: number + // 轮播内容 + items: CarouselItemProperty[] + // 组件样式 + style: ComponentStyle +} +// 轮播内容属性 +export interface CarouselItemProperty { + // 类型:图片 | 视频 + type: 'img' | 'video' + // 图片链接 + imgUrl: string + // 视频链接 + videoUrl: string + // 跳转链接 + url: string +} + +// 定义组件 +export const component = { + id: 'Carousel', + name: '轮播图', + icon: 'system-uicons:carousel', + property: { + type: 'default', + indicator: 'dot', + autoplay: false, + interval: 3, + items: [ + { type: 'img', imgUrl: 'https://static.iocoder.cn/mall/banner-01.jpg', videoUrl: '' }, + { type: 'img', imgUrl: 'https://static.iocoder.cn/mall/banner-02.jpg', videoUrl: '' } + ] as CarouselItemProperty[], + style: { + bgType: 'color', + bgColor: '#fff', + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<CarouselProperty> diff --git a/src/components/DiyEditor/components/mobile/Carousel/index.vue b/src/components/DiyEditor/components/mobile/Carousel/index.vue new file mode 100644 index 0000000..360b4a4 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Carousel/index.vue @@ -0,0 +1,43 @@ +<template> + <!-- 无图片 --> + <div + class="h-250px flex items-center justify-center bg-gray-3" + v-if="property.items.length === 0" + > + <Icon icon="tdesign:image" class="text-gray-8 text-120px!" /> + </div> + <div v-else class="relative"> + <el-carousel + height="174px" + :type="property.type === 'card' ? 'card' : ''" + :autoplay="property.autoplay" + :interval="property.interval * 1000" + :indicator-position="property.indicator === 'number' ? 'none' : undefined" + @change="handleIndexChange" + > + <el-carousel-item v-for="(item, index) in property.items" :key="index"> + <el-image class="h-full w-full" :src="item.imgUrl" /> + </el-carousel-item> + </el-carousel> + <div + v-if="property.indicator === 'number'" + class="absolute bottom-10px right-10px rounded-xl bg-black p-x-8px p-y-2px text-10px text-white opacity-40" + >{{ currentIndex }} / {{ property.items.length }}</div + > + </div> +</template> +<script setup lang="ts"> +import { CarouselProperty } from './config' + +/** 轮播图 */ +defineOptions({ name: 'Carousel' }) + +defineProps<{ property: CarouselProperty }>() + +const currentIndex = ref(0) +const handleIndexChange = (index: number) => { + currentIndex.value = index + 1 +} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/Carousel/property.vue b/src/components/DiyEditor/components/mobile/Carousel/property.vue new file mode 100644 index 0000000..c3a5154 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Carousel/property.vue @@ -0,0 +1,106 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="80px" :model="formData"> + <el-card header="样式设置" class="property-group" shadow="never"> + <el-form-item label="样式" prop="type"> + <el-radio-group v-model="formData.type"> + <el-tooltip class="item" content="默认" placement="bottom"> + <el-radio-button label="default"> + <Icon icon="system-uicons:carousel" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="卡片" placement="bottom"> + <el-radio-button label="card"> + <Icon icon="ic:round-view-carousel" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="指示器" prop="indicator"> + <el-radio-group v-model="formData.indicator"> + <el-radio label="dot">小圆点</el-radio> + <el-radio label="number">数字</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="是否轮播" prop="autoplay"> + <el-switch v-model="formData.autoplay" /> + </el-form-item> + <el-form-item label="播放间隔" prop="interval" v-if="formData.autoplay"> + <el-slider + v-model="formData.interval" + :max="10" + :min="0.5" + :step="0.5" + show-input + input-size="small" + :show-input-controls="false" + /> + <el-text type="info">单位:秒</el-text> + </el-form-item> + </el-card> + <el-card header="内容设置" class="property-group" shadow="never"> + <Draggable v-model="formData.items" :empty-item="{ type: 'img' }"> + <template #default="{ element }"> + <el-form-item label="类型" prop="type" class="m-b-8px!" label-width="40px"> + <el-radio-group v-model="element.type"> + <el-radio label="img">图片</el-radio> + <el-radio label="video">视频</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item + label="图片" + class="m-b-8px!" + label-width="40px" + v-if="element.type === 'img'" + > + <UploadImg + v-model="element.imgUrl" + draggable="false" + height="80px" + width="100%" + class="min-w-80px" + /> + </el-form-item> + <template v-else> + <el-form-item label="封面" class="m-b-8px!" label-width="40px"> + <UploadImg + v-model="element.imgUrl" + draggable="false" + height="80px" + width="100%" + class="min-w-80px" + /> + </el-form-item> + <el-form-item label="视频" class="m-b-8px!" label-width="40px"> + <UploadFile + v-model="element.videoUrl" + :file-type="['mp4']" + :limit="1" + :file-size="100" + class="min-w-80px" + /> + </el-form-item> + </template> + <el-form-item label="链接" class="m-b-8px!" label-width="40px"> + <AppLinkInput v-model="element.url" /> + </el-form-item> + </template> + </Draggable> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { CarouselProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' + +// 轮播图属性面板 +defineOptions({ name: 'CarouselProperty' }) + +const props = defineProps<{ modelValue: CarouselProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/CouponCard/component.tsx b/src/components/DiyEditor/components/mobile/CouponCard/component.tsx new file mode 100644 index 0000000..689690b --- /dev/null +++ b/src/components/DiyEditor/components/mobile/CouponCard/component.tsx @@ -0,0 +1,73 @@ +import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate' +import { CouponTemplateValidityTypeEnum, PromotionDiscountTypeEnum } from '@/utils/constants' +import { floatToFixed2 } from '@/utils' +import { formatDate } from '@/utils/formatTime' +import { object } from 'vue-types' + +// 优惠值 +export const CouponDiscount = defineComponent({ + name: 'CouponDiscount', + props: { + coupon: object<CouponTemplateApi.CouponTemplateVO>() + }, + setup(props) { + const coupon = props.coupon as CouponTemplateApi.CouponTemplateVO + // 折扣 + let value = coupon.discountPercent + '' + let suffix = ' 折' + // 满减 + if (coupon.discountType === PromotionDiscountTypeEnum.PRICE.type) { + value = floatToFixed2(coupon.discountPrice) + suffix = ' 元' + } + return () => ( + <div> + <span class={'text-20px font-bold'}>{value}</span> + <span>{suffix}</span> + </div> + ) + } +}) + +// 优惠描述 +export const CouponDiscountDesc = defineComponent({ + name: 'CouponDiscountDesc', + props: { + coupon: object<CouponTemplateApi.CouponTemplateVO>() + }, + setup(props) { + const coupon = props.coupon as CouponTemplateApi.CouponTemplateVO + // 使用条件 + const useCondition = coupon.usePrice > 0 ? `满${floatToFixed2(coupon.usePrice)}元,` : '' + // 优惠描述 + const discountDesc = + coupon.discountType === PromotionDiscountTypeEnum.PRICE.type + ? `减${floatToFixed2(coupon.discountPrice)}元` + : `打${coupon.discountPercent}折` + return () => ( + <div> + <span>{useCondition}</span> + <span>{discountDesc}</span> + </div> + ) + } +}) + +// 有效期 +export const CouponValidTerm = defineComponent({ + name: 'CouponValidTerm', + props: { + coupon: object<CouponTemplateApi.CouponTemplateVO>() + }, + setup(props) { + const coupon = props.coupon as CouponTemplateApi.CouponTemplateVO + const text = + coupon.validityType === CouponTemplateValidityTypeEnum.DATE.type + ? `有效期:${formatDate(coupon.validStartTime, 'YYYY-MM-DD')} 至 ${formatDate( + coupon.validEndTime, + 'YYYY-MM-DD' + )}` + : `领取后第 ${coupon.fixedStartTerm} - ${coupon.fixedEndTerm} 天内可用` + return () => <div>{text}</div> + } +}) diff --git a/src/components/DiyEditor/components/mobile/CouponCard/config.ts b/src/components/DiyEditor/components/mobile/CouponCard/config.ts new file mode 100644 index 0000000..304533d --- /dev/null +++ b/src/components/DiyEditor/components/mobile/CouponCard/config.ts @@ -0,0 +1,47 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 商品卡片属性 */ +export interface CouponCardProperty { + // 列数 + columns: number + // 背景图 + bgImg: string + // 文字颜色 + textColor: string + // 按钮样式 + button: { + // 颜色 + color: string + // 背景颜色 + bgColor: string + } + // 间距 + space: number + // 优惠券编号列表 + couponIds: number[] + // 组件样式 + style: ComponentStyle +} + +// 定义组件 +export const component = { + id: 'CouponCard', + name: '优惠券', + icon: 'ep:ticket', + property: { + columns: 1, + bgImg: '', + textColor: '#E9B461', + button: { + color: '#434343', + bgColor: '' + }, + space: 0, + couponIds: [], + style: { + bgType: 'color', + bgColor: '', + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<CouponCardProperty> diff --git a/src/components/DiyEditor/components/mobile/CouponCard/index.vue b/src/components/DiyEditor/components/mobile/CouponCard/index.vue new file mode 100644 index 0000000..3e2302a --- /dev/null +++ b/src/components/DiyEditor/components/mobile/CouponCard/index.vue @@ -0,0 +1,142 @@ +<template> + <el-scrollbar class="z-1 min-h-30px" wrap-class="w-full" ref="containerRef"> + <div + class="flex flex-row text-12px" + :style="{ + gap: `${property.space}px`, + width: scrollbarWidth + }" + > + <div + class="box-content" + :style="{ + background: property.bgImg + ? `url(${property.bgImg}) 100% center / 100% 100% no-repeat` + : '#fff', + width: `${couponWidth}px`, + color: property.textColor + }" + v-for="(coupon, index) in couponList" + :key="index" + > + <!-- 布局1:1列--> + <div v-if="property.columns === 1" class="m-l-16px flex flex-row justify-between p-8px"> + <div class="flex flex-col justify-evenly gap-4px"> + <!-- 优惠值 --> + <CouponDiscount :coupon="coupon" /> + <!-- 优惠描述 --> + <CouponDiscountDesc :coupon="coupon" /> + <!-- 有效期 --> + <CouponValidTerm :coupon="coupon" /> + </div> + <div class="flex flex-col justify-evenly"> + <div + class="rounded-20px p-x-8px p-y-2px" + :style="{ + color: property.button.color, + background: property.button.bgColor + }" + > + 立即领取 + </div> + </div> + </div> + <!-- 布局2:2列--> + <div + v-else-if="property.columns === 2" + class="m-l-16px flex flex-row justify-between p-8px" + > + <div class="flex flex-col justify-evenly gap-4px"> + <!-- 优惠值 --> + <CouponDiscount :coupon="coupon" /> + <div>{{ coupon.name }}</div> + </div> + <div class="flex flex-col"> + <div + class="h-full w-20px rounded-20px p-x-2px p-y-8px text-center" + :style="{ + color: property.button.color, + background: property.button.bgColor + }" + > + 立即领取 + </div> + </div> + </div> + <!-- 布局3:3列--> + <div v-else class="flex flex-col items-center justify-around gap-4px p-4px"> + <!-- 优惠值 --> + <CouponDiscount :coupon="coupon" /> + <div>{{ coupon.name }}</div> + <div + class="rounded-20px p-x-8px p-y-2px" + :style="{ + color: property.button.color, + background: property.button.bgColor + }" + > + 立即领取 + </div> + </div> + </div> + </div> + </el-scrollbar> +</template> +<script setup lang="ts"> +import { CouponCardProperty } from './config' +import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate' +import { CouponDiscount } from './component' +import { + CouponDiscountDesc, + CouponValidTerm +} from '@/components/DiyEditor/components/mobile/CouponCard/component' + +/** 商品卡片 */ +defineOptions({ name: 'CouponCard' }) +// 定义属性 +const props = defineProps<{ property: CouponCardProperty }>() +// 商品列表 +const couponList = ref<CouponTemplateApi.CouponTemplateVO[]>([]) +watch( + () => props.property.couponIds, + async () => { + if (props.property.couponIds?.length > 0) { + couponList.value = await CouponTemplateApi.getCouponTemplateList(props.property.couponIds) + } + }, + { + immediate: true, + deep: true + } +) + +// 手机宽度 +const phoneWidth = ref(375) +// 容器 +const containerRef = ref() +// 滚动条宽度 +const scrollbarWidth = ref('100%') +// 优惠券的宽度 +const couponWidth = ref(375) +// 计算布局参数 +watch( + () => [props.property, phoneWidth, couponList.value.length], + () => { + // 每列的宽度为:(总宽度 - 间距 * (列数 - 1))/ 列数 + couponWidth.value = + (phoneWidth.value * 0.95 - props.property.space * (props.property.columns - 1)) / + props.property.columns + // 显示滚动条 + scrollbarWidth.value = `${ + couponWidth.value * couponList.value.length + + props.property.space * (couponList.value.length - 1) + }px` + }, + { immediate: true, deep: true } +) +onMounted(() => { + // 提取手机宽度 + phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375 +}) +</script> +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/CouponCard/property.vue b/src/components/DiyEditor/components/mobile/CouponCard/property.vue new file mode 100644 index 0000000..4f32c21 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/CouponCard/property.vue @@ -0,0 +1,104 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="80px" :model="formData"> + <el-card header="优惠券列表" class="property-group" shadow="never"> + <div + v-for="(coupon, index) in couponList" + :key="index" + class="flex items-center justify-between" + > + <el-text size="large" truncated>{{ coupon.name }}</el-text> + <el-text type="info" truncated> + <span v-if="coupon.usePrice > 0">满{{ floatToFixed2(coupon.usePrice) }}元,</span> + <span v-if="coupon.discountType === PromotionDiscountTypeEnum.PRICE.type"> + 减{{ floatToFixed2(coupon.discountPrice) }}元 + </span> + <span v-else> 打{{ coupon.discountPercent }}折 </span> + </el-text> + </div> + <el-form-item label-width="0"> + <el-button @click="handleAddCoupon" type="primary" plain class="m-t-8px w-full"> + <Icon icon="ep:plus" class="mr-5px" /> 添加 + </el-button> + </el-form-item> + </el-card> + <el-card header="优惠券样式" class="property-group" shadow="never"> + <el-form-item label="列数" prop="type"> + <el-radio-group v-model="formData.columns"> + <el-tooltip class="item" content="一列" placement="bottom"> + <el-radio-button :label="1"> + <Icon icon="fluent:text-column-one-24-filled" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="二列" placement="bottom"> + <el-radio-button :label="2"> + <Icon icon="fluent:text-column-two-24-filled" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="三列" placement="bottom"> + <el-radio-button :label="3"> + <Icon icon="fluent:text-column-three-24-filled" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="背景图片" prop="bgImg"> + <UploadImg v-model="formData.bgImg" height="80px" width="100%" class="min-w-160px" /> + </el-form-item> + <el-form-item label="文字颜色" prop="textColor"> + <ColorInput v-model="formData.textColor" /> + </el-form-item> + <el-form-item label="按钮背景" prop="button.bgColor"> + <ColorInput v-model="formData.button.bgColor" /> + </el-form-item> + <el-form-item label="按钮文字" prop="button.color"> + <ColorInput v-model="formData.button.color" /> + </el-form-item> + <el-form-item label="间隔" prop="space"> + <el-slider + v-model="formData.space" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + </el-card> + </el-form> + </ComponentContainerProperty> + <!-- 优惠券选择 --> + <CouponSelect ref="couponSelectDialog" v-model:multiple-selection="couponList" /> +</template> + +<script setup lang="ts"> +import { CouponCardProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate' +import { floatToFixed2 } from '@/utils' +import { PromotionDiscountTypeEnum } from '@/utils/constants' +import CouponSelect from '@/views/mall/promotion/coupon/components/CouponSelect.vue' + +// 优惠券卡片属性面板 +defineOptions({ name: 'CouponCardProperty' }) + +const props = defineProps<{ modelValue: CouponCardProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +// 优惠券列表 +const couponList = ref<CouponTemplateApi.CouponTemplateVO[]>([]) +const couponSelectDialog = ref() +// 添加优惠券 +const handleAddCoupon = () => { + couponSelectDialog.value.open() +} +watch( + () => couponList.value, + () => { + formData.value.couponIds = couponList.value.map((coupon) => coupon.id) + } +) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/Divider/config.ts b/src/components/DiyEditor/components/mobile/Divider/config.ts new file mode 100644 index 0000000..9b55360 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Divider/config.ts @@ -0,0 +1,29 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 分割线属性 */ +export interface DividerProperty { + // 高度 + height: number + // 线宽 + lineWidth: number + // 边距类型 + paddingType: 'none' | 'horizontal' + // 颜色 + lineColor: string + // 类型 + borderType: 'solid' | 'dashed' | 'dotted' | 'none' +} + +// 定义组件 +export const component = { + id: 'Divider', + name: '分割线', + icon: 'tdesign:component-divider-vertical', + property: { + height: 30, + lineWidth: 1, + paddingType: 'none', + lineColor: '#dcdfe6', + borderType: 'solid' + } +} as DiyComponent<DividerProperty> diff --git a/src/components/DiyEditor/components/mobile/Divider/index.vue b/src/components/DiyEditor/components/mobile/Divider/index.vue new file mode 100644 index 0000000..f778504 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Divider/index.vue @@ -0,0 +1,29 @@ +<template> + <div + class="flex items-center" + :style="{ + height: property.height + 'px' + }" + > + <div + class="w-full" + :style="{ + borderTopStyle: property.borderType, + borderTopColor: property.lineColor, + borderTopWidth: `${property.lineWidth}px`, + margin: property.paddingType === 'none' ? '0' : '0px 16px' + }" + ></div> + </div> +</template> + +<script setup lang="ts"> +import { DividerProperty } from './config' + +/** 页面顶部导航栏 */ +defineOptions({ name: 'Divider' }) + +defineProps<{ property: DividerProperty }>() +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/Divider/property.vue b/src/components/DiyEditor/components/mobile/Divider/property.vue new file mode 100644 index 0000000..3d7be26 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Divider/property.vue @@ -0,0 +1,80 @@ +<template> + <el-form label-width="80px" :model="formData"> + <el-form-item label="高度" prop="height"> + <el-slider v-model="formData.height" :min="1" :max="100" show-input input-size="small" /> + </el-form-item> + <el-form-item label="选择样式" prop="borderType"> + <el-radio-group v-model="formData!.borderType"> + <el-tooltip + placement="top" + v-for="(item, index) in BORDER_TYPES" + :key="index" + :content="item.text" + > + <el-radio-button :label="item.type"> + <Icon :icon="item.icon" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <template v-if="formData.borderType !== 'none'"> + <el-form-item label="线宽" prop="lineWidth"> + <el-slider v-model="formData.lineWidth" :min="1" :max="30" show-input input-size="small" /> + </el-form-item> + <el-form-item label="左右边距" prop="paddingType"> + <el-radio-group v-model="formData!.paddingType"> + <el-tooltip content="无边距" placement="top"> + <el-radio-button label="none"> + <Icon icon="tabler:box-padding" /> + </el-radio-button> + </el-tooltip> + <el-tooltip content="左右留边" placement="top"> + <el-radio-button label="horizontal"> + <Icon icon="vaadin:padding" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="颜色"> + <!-- 分割线颜色 --> + <ColorInput v-model="formData.lineColor" /> + </el-form-item> + </template> + </el-form> +</template> + +<script setup lang="ts"> +import { DividerProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +// 导航栏属性面板 +defineOptions({ name: 'DividerProperty' }) +const props = defineProps<{ modelValue: DividerProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +//线类型 +const BORDER_TYPES = [ + { + icon: 'vaadin:line-h', + text: '实线', + type: 'solid' + }, + { + icon: 'tabler:line-dashed', + text: '虚线', + type: 'dashed' + }, + { + icon: 'tabler:line-dotted', + text: '点线', + type: 'dotted' + }, + { + icon: 'entypo:progress-empty', + text: '无', + type: 'none' + } +] +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/FloatingActionButton/config.ts b/src/components/DiyEditor/components/mobile/FloatingActionButton/config.ts new file mode 100644 index 0000000..fcf129f --- /dev/null +++ b/src/components/DiyEditor/components/mobile/FloatingActionButton/config.ts @@ -0,0 +1,36 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +// 悬浮按钮属性 +export interface FloatingActionButtonProperty { + // 展开方向 + direction: 'horizontal' | 'vertical' + // 是否显示文字 + showText: boolean + // 按钮列表 + list: FloatingActionButtonItemProperty[] +} + +// 悬浮按钮项属性 +export interface FloatingActionButtonItemProperty { + // 图片地址 + imgUrl: string + // 跳转连接 + url: string + // 文字 + text: string + // 文字颜色 + textColor: string +} + +// 定义组件 +export const component = { + id: 'FloatingActionButton', + name: '悬浮按钮', + icon: 'tabler:float-right', + position: 'fixed', + property: { + direction: 'vertical', + showText: true, + list: [{ textColor: '#fff' }] + } +} as DiyComponent<FloatingActionButtonProperty> diff --git a/src/components/DiyEditor/components/mobile/FloatingActionButton/index.vue b/src/components/DiyEditor/components/mobile/FloatingActionButton/index.vue new file mode 100644 index 0000000..19e42cb --- /dev/null +++ b/src/components/DiyEditor/components/mobile/FloatingActionButton/index.vue @@ -0,0 +1,74 @@ +<template> + <div + :class="[ + 'absolute bottom-32px right-[calc(50%-375px/2+32px)] flex z-12 gap-12px items-center', + { + 'flex-row': property.direction === 'horizontal', + 'flex-col': property.direction === 'vertical' + } + ]" + > + <template v-if="expanded"> + <div + v-for="(item, index) in property.list" + :key="index" + class="flex flex-col items-center" + @click="handleActive(index)" + > + <el-image :src="item.imgUrl" fit="contain" class="h-27px w-27px"> + <template #error> + <div class="h-full w-full flex items-center justify-center"> + <Icon icon="ep:picture" :color="item.textColor" /> + </div> + </template> + </el-image> + <span v-if="property.showText" class="mt-4px text-12px" :style="{ color: item.textColor }"> + {{ item.text }} + </span> + </div> + </template> + <!-- todo: @owen 使用APP主题色 --> + <el-button type="primary" size="large" circle @click="handleToggleFab"> + <Icon icon="ep:plus" :class="['fab-icon', { active: expanded }]" /> + </el-button> + </div> + <!-- 模态背景:展开时显示,点击后折叠 --> + <div v-if="expanded" class="modal-bg" @click="handleToggleFab"></div> +</template> +<script setup lang="ts"> +import { FloatingActionButtonProperty } from './config' + +/** 悬浮按钮 */ +defineOptions({ name: 'FloatingActionButton' }) +// 定义属性 +defineProps<{ property: FloatingActionButtonProperty }>() + +// 是否展开 +const expanded = ref(true) +// 处理展开/折叠 +const handleToggleFab = () => { + expanded.value = !expanded.value +} +</script> + +<style scoped lang="scss"> +/* 模态背景 */ +.modal-bg { + position: absolute; + left: calc(50% - 375px / 2); + top: 0; + z-index: 11; + width: 375px; + height: 100%; + background-color: rgba(#000000, 0.4); +} + +.fab-icon { + transform: rotate(0deg); + transition: transform 0.3s; + + &.active { + transform: rotate(135deg); + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/FloatingActionButton/property.vue b/src/components/DiyEditor/components/mobile/FloatingActionButton/property.vue new file mode 100644 index 0000000..5db08d0 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/FloatingActionButton/property.vue @@ -0,0 +1,44 @@ +<template> + <el-form label-width="80px" :model="formData"> + <el-card header="按钮配置" class="property-group" shadow="never"> + <el-form-item label="展开方向" prop="direction"> + <el-radio-group v-model="formData.direction"> + <el-radio label="vertical">垂直</el-radio> + <el-radio label="horizontal">水平</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="显示文字" prop="showText"> + <el-switch v-model="formData.showText" /> + </el-form-item> + </el-card> + <el-card header="按钮列表" class="property-group" shadow="never"> + <Draggable v-model="formData.list" :empty-item="{ textColor: '#fff' }"> + <template #default="{ element, index }"> + <el-form-item label="图标" :prop="`list[${index}].imgUrl`"> + <UploadImg v-model="element.imgUrl" height="56px" width="56px" /> + </el-form-item> + <el-form-item label="文字" :prop="`list[${index}].text`"> + <InputWithColor v-model="element.text" v-model:color="element.textColor" /> + </el-form-item> + <el-form-item label="跳转链接" :prop="`list[${index}].url`"> + <AppLinkInput v-model="element.url" /> + </el-form-item> + </template> + </Draggable> + </el-card> + </el-form> +</template> + +<script setup lang="ts"> +import { FloatingActionButtonProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' + +// 悬浮按钮属性面板 +defineOptions({ name: 'FloatingActionButtonProperty' }) + +const props = defineProps<{ modelValue: FloatingActionButtonProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/controller.ts b/src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/controller.ts new file mode 100644 index 0000000..a7bd762 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/controller.ts @@ -0,0 +1,143 @@ +import { HotZoneItemProperty } from '@/components/DiyEditor/components/mobile/HotZone/config' +import { StyleValue } from 'vue' + +// 热区的最小宽高 +export const HOT_ZONE_MIN_SIZE = 100 + +// 控制的类型 +export enum CONTROL_TYPE_ENUM { + LEFT, + TOP, + WIDTH, + HEIGHT +} + +// 定义热区的控制点 +export interface ControlDot { + position: string + types: CONTROL_TYPE_ENUM[] + style: StyleValue +} + +// 热区的8个控制点 +export const CONTROL_DOT_LIST = [ + { + position: '左上角', + types: [ + CONTROL_TYPE_ENUM.LEFT, + CONTROL_TYPE_ENUM.TOP, + CONTROL_TYPE_ENUM.WIDTH, + CONTROL_TYPE_ENUM.HEIGHT + ], + style: { left: '-5px', top: '-5px', cursor: 'nwse-resize' } + }, + { + position: '上方中间', + types: [CONTROL_TYPE_ENUM.TOP, CONTROL_TYPE_ENUM.HEIGHT], + style: { left: '50%', top: '-5px', cursor: 'n-resize', transform: 'translateX(-50%)' } + }, + { + position: '右上角', + types: [CONTROL_TYPE_ENUM.TOP, CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT], + style: { right: '-5px', top: '-5px', cursor: 'nesw-resize' } + }, + { + position: '右侧中间', + types: [CONTROL_TYPE_ENUM.WIDTH], + style: { right: '-5px', top: '50%', cursor: 'e-resize', transform: 'translateX(-50%)' } + }, + { + position: '右下角', + types: [CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT], + style: { right: '-5px', bottom: '-5px', cursor: 'nwse-resize' } + }, + { + position: '下方中间', + types: [CONTROL_TYPE_ENUM.HEIGHT], + style: { left: '50%', bottom: '-5px', cursor: 's-resize', transform: 'translateX(-50%)' } + }, + { + position: '左下角', + types: [CONTROL_TYPE_ENUM.LEFT, CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT], + style: { left: '-5px', bottom: '-5px', cursor: 'nesw-resize' } + }, + { + position: '左侧中间', + types: [CONTROL_TYPE_ENUM.LEFT, CONTROL_TYPE_ENUM.WIDTH], + style: { left: '-5px', top: '50%', cursor: 'w-resize', transform: 'translateX(-50%)' } + } +] as ControlDot[] + +//region 热区的缩放 +// 热区的缩放比例 +export const HOT_ZONE_SCALE_RATE = 2 +// 缩小:缩回适合手机屏幕的大小 +export const zoomOut = (list?: HotZoneItemProperty[]) => { + return ( + list?.map((hotZone) => ({ + ...hotZone, + left: (hotZone.left /= HOT_ZONE_SCALE_RATE), + top: (hotZone.top /= HOT_ZONE_SCALE_RATE), + width: (hotZone.width /= HOT_ZONE_SCALE_RATE), + height: (hotZone.height /= HOT_ZONE_SCALE_RATE) + })) || [] + ) +} +// 放大:作用是为了方便在电脑屏幕上编辑 +export const zoomIn = (list?: HotZoneItemProperty[]) => { + return ( + list?.map((hotZone) => ({ + ...hotZone, + left: (hotZone.left *= HOT_ZONE_SCALE_RATE), + top: (hotZone.top *= HOT_ZONE_SCALE_RATE), + width: (hotZone.width *= HOT_ZONE_SCALE_RATE), + height: (hotZone.height *= HOT_ZONE_SCALE_RATE) + })) || [] + ) +} +//endregion + +/** + * 封装热区拖拽 + * + * 注:为什么不使用vueuse的useDraggable。在本场景下,其使用方式比较复杂 + * @param hotZone 热区 + * @param downEvent 鼠标按下事件 + * @param callback 回调函数 + */ +export const useDraggable = ( + hotZone: HotZoneItemProperty, + downEvent: MouseEvent, + callback: ( + left: number, + top: number, + width: number, + height: number, + moveWidth: number, + moveHeight: number + ) => void +) => { + // 阻止事件冒泡 + downEvent.stopPropagation() + + // 移动前的鼠标坐标 + const { clientX: startX, clientY: startY } = downEvent + // 移动前的热区坐标、大小 + const { left, top, width, height } = hotZone + + // 监听鼠标移动 + document.onmousemove = (e) => { + // 移动宽度 + const moveWidth = e.clientX - startX + // 移动高度 + const moveHeight = e.clientY - startY + // 移动回调 + callback(left, top, width, height, moveWidth, moveHeight) + } + + // 松开鼠标后,结束拖拽 + document.onmouseup = () => { + document.onmousemove = null + document.onmouseup = null + } +} diff --git a/src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/index.vue b/src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/index.vue new file mode 100644 index 0000000..3925057 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/index.vue @@ -0,0 +1,236 @@ +<template> + <Dialog v-model="dialogVisible" title="设置热区" width="780" @close="handleClose"> + <div ref="container" class="relative h-full w-750px"> + <el-image :src="imgUrl" class="pointer-events-none h-full w-750px select-none" /> + <div + v-for="(item, hotZoneIndex) in formData" + :key="hotZoneIndex" + class="hot-zone" + :style="{ + width: `${item.width}px`, + height: `${item.height}px`, + top: `${item.top}px`, + left: `${item.left}px` + }" + @mousedown="handleMove(item, $event)" + @dblclick="handleShowAppLinkDialog(item)" + > + <span class="pointer-events-none select-none">{{ item.name || '双击选择链接' }}</span> + <Icon icon="ep:close" class="delete" :size="14" @click="handleRemove(item)" /> + + <!-- 8个控制点 --> + <span + class="ctrl-dot" + v-for="(dot, dotIndex) in CONTROL_DOT_LIST" + :key="dotIndex" + :style="dot.style" + @mousedown="handleResize(item, dot, $event)" + ></span> + </div> + </div> + <template #footer> + <el-button @click="handleAdd" type="primary" plain> + <Icon icon="ep:plus" class="mr-5px" /> + 添加热区 + </el-button> + <el-button @click="handleSubmit" type="primary" plain> + <Icon icon="ep:check" class="mr-5px" /> + 确定 + </el-button> + </template> + </Dialog> + <AppLinkSelectDialog ref="appLinkDialogRef" @app-link-change="handleAppLinkChange" /> +</template> + +<script setup lang="ts"> +import { HotZoneItemProperty } from '@/components/DiyEditor/components/mobile/HotZone/config' +import { array, string } from 'vue-types' +import { + CONTROL_DOT_LIST, + CONTROL_TYPE_ENUM, + ControlDot, + HOT_ZONE_MIN_SIZE, + useDraggable, + zoomIn, + zoomOut +} from './controller' +import { AppLink } from '@/components/AppLinkInput/data' +import { remove } from 'lodash-es' + +/** 热区编辑对话框 */ +defineOptions({ name: 'HotZoneEditDialog' }) + +// 定义属性 +const props = defineProps({ + modelValue: array<HotZoneItemProperty>(), + imgUrl: string().def('') +}) +const emit = defineEmits(['update:modelValue']) +const formData = ref<HotZoneItemProperty[]>([]) + +// 弹窗的是否显示 +const dialogVisible = ref(false) +// 打开弹窗 +const open = () => { + // 放大 + formData.value = zoomIn(props.modelValue) + dialogVisible.value = true +} +// 提供 open 方法,用于打开弹窗 +defineExpose({ open }) + +// 热区容器 +const container = ref<HTMLDivElement>() + +// 增加热区 +const handleAdd = () => { + formData.value.push({ + width: HOT_ZONE_MIN_SIZE, + height: HOT_ZONE_MIN_SIZE, + top: 0, + left: 0 + } as HotZoneItemProperty) +} +// 删除热区 +const handleRemove = (hotZone: HotZoneItemProperty) => { + remove(formData.value, hotZone) +} + +// 移动热区 +const handleMove = (item: HotZoneItemProperty, e: MouseEvent) => { + useDraggable(item, e, (left, top, _, __, moveWidth, moveHeight) => { + setLeft(item, left + moveWidth) + setTop(item, top + moveHeight) + }) +} + +// 调整热区大小、位置 +const handleResize = (item: HotZoneItemProperty, ctrlDot: ControlDot, e: MouseEvent) => { + useDraggable(item, e, (left, top, width, height, moveWidth, moveHeight) => { + ctrlDot.types.forEach((type) => { + switch (type) { + case CONTROL_TYPE_ENUM.LEFT: + setLeft(item, left + moveWidth) + break + case CONTROL_TYPE_ENUM.TOP: + setTop(item, top + moveHeight) + break + case CONTROL_TYPE_ENUM.WIDTH: + { + // 上移时,高度为减少 + const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.LEFT) ? -1 : 1 + setWidth(item, width + moveWidth * direction) + } + break + case CONTROL_TYPE_ENUM.HEIGHT: + { + // 左移时,宽度为减少 + const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.TOP) ? -1 : 1 + setHeight(item, height + moveHeight * direction) + } + break + } + }) + }) +} + +// 设置X轴坐标 +const setLeft = (item: HotZoneItemProperty, left: number) => { + // 不能超出容器 + if (left >= 0 && left <= container.value!.offsetWidth - item.width) { + item.left = left + } +} +// 设置Y轴坐标 +const setTop = (item: HotZoneItemProperty, top: number) => { + // 不能超出容器 + if (top >= 0 && top <= container.value!.offsetHeight - item.height) { + item.top = top + } +} +// 设置宽度 +const setWidth = (item: HotZoneItemProperty, width: number) => { + // 不能小于最小宽度 && 不能超出容器右边 + if (width >= HOT_ZONE_MIN_SIZE && item.left + width <= container.value!.offsetWidth) { + item.width = width + } +} +// 设置高度 +const setHeight = (item: HotZoneItemProperty, height: number) => { + // 不能小于最小高度 && 不能超出容器底部 + if (height >= HOT_ZONE_MIN_SIZE && item.top + height <= container.value!.offsetHeight) { + item.height = height + } +} + +// 处理对话框关闭 +const handleSubmit = () => { + // 会自动触发handleClose + dialogVisible.value = false +} + +// 处理对话框关闭 +const handleClose = () => { + // 缩小 + const list = zoomOut(formData.value) + emit('update:modelValue', list) +} + +const activeHotZone = ref<HotZoneItemProperty>() +const appLinkDialogRef = ref() +const handleShowAppLinkDialog = (hotZone: HotZoneItemProperty) => { + activeHotZone.value = hotZone + appLinkDialogRef.value.open(hotZone.url) +} +const handleAppLinkChange = (appLink: AppLink) => { + if (!appLink || !activeHotZone.value) return + activeHotZone.value.name = appLink.name + activeHotZone.value.url = appLink.path +} +</script> + +<style scoped lang="scss"> +.hot-zone { + position: absolute; + background: var(--el-color-primary-light-7); + opacity: 0.8; + border: 1px solid var(--el-color-primary); + color: var(--el-color-primary); + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: move; + z-index: 10; + + /* 控制点 */ + .ctrl-dot { + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + border: inherit; + background-color: #fff; + z-index: 11; + } + + .delete { + display: none; + position: absolute; + top: 0; + right: 0; + padding: 2px 2px 6px 6px; + background-color: var(--el-color-primary); + border-radius: 0 0 0 80%; + cursor: pointer; + color: #fff; + text-align: right; + } + + &:hover { + .delete { + display: block; + } + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/HotZone/config.ts b/src/components/DiyEditor/components/mobile/HotZone/config.ts new file mode 100644 index 0000000..80ed855 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/HotZone/config.ts @@ -0,0 +1,43 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 热区属性 */ +export interface HotZoneProperty { + // 图片地址 + imgUrl: string + // 导航菜单列表 + list: HotZoneItemProperty[] + // 组件样式 + style: ComponentStyle +} + +/** 热区项目属性 */ +export interface HotZoneItemProperty { + // 链接的名称 + name: string + // 链接 + url: string + // 宽 + width: number + // 高 + height: number + // 上 + top: number + // 左 + left: number +} + +// 定义组件 +export const component = { + id: 'HotZone', + name: '热区', + icon: 'tabler:hand-click', + property: { + imgUrl: '', + list: [] as HotZoneItemProperty[], + style: { + bgType: 'color', + bgColor: '#fff', + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<HotZoneProperty> diff --git a/src/components/DiyEditor/components/mobile/HotZone/index.vue b/src/components/DiyEditor/components/mobile/HotZone/index.vue new file mode 100644 index 0000000..3a9b842 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/HotZone/index.vue @@ -0,0 +1,42 @@ +<template> + <div class="relative h-full min-h-30px w-full"> + <el-image :src="property.imgUrl" class="pointer-events-none h-full w-full select-none" /> + <div + v-for="(item, index) in property.list" + :key="index" + class="hot-zone" + :style="{ + width: `${item.width}px`, + height: `${item.height}px`, + top: `${item.top}px`, + left: `${item.left}px` + }" + > + {{ item.name }} + </div> + </div> +</template> + +<script setup lang="ts"> +import { HotZoneProperty } from './config' + +/** 热区 */ +defineOptions({ name: 'HotZone' }) +const props = defineProps<{ property: HotZoneProperty }>() +</script> + +<style scoped lang="scss"> +.hot-zone { + position: absolute; + background: var(--el-color-primary-light-7); + opacity: 0.8; + border: 1px solid var(--el-color-primary); + color: var(--el-color-primary); + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + cursor: move; + z-index: 10; +} +</style> diff --git a/src/components/DiyEditor/components/mobile/HotZone/property.vue b/src/components/DiyEditor/components/mobile/HotZone/property.vue new file mode 100644 index 0000000..495cbdc --- /dev/null +++ b/src/components/DiyEditor/components/mobile/HotZone/property.vue @@ -0,0 +1,63 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <!-- 表单 --> + <el-form label-width="80px" :model="formData" class="m-t-8px"> + <el-form-item label="上传图片" prop="imgUrl"> + <UploadImg v-model="formData.imgUrl" height="50px" width="auto" class="min-w-80px"> + <template #tip> + <el-text type="info" size="small"> 推荐宽度 750</el-text> + </template> + </UploadImg> + </el-form-item> + </el-form> + + <el-button type="primary" plain class="w-full" @click="handleOpenEditDialog"> + 设置热区 + </el-button> + </ComponentContainerProperty> + <!-- 热区编辑对话框 --> + <HotZoneEditDialog ref="editDialogRef" v-model="formData.list" :img-url="formData.imgUrl" /> +</template> + +<script setup lang="ts"> +import { usePropertyForm } from '@/components/DiyEditor/util' +import { HotZoneProperty } from '@/components/DiyEditor/components/mobile/HotZone/config' +import HotZoneEditDialog from './components/HotZoneEditDialog/index.vue' + +/** 热区属性面板 */ +defineOptions({ name: 'HotZoneProperty' }) + +const props = defineProps<{ modelValue: HotZoneProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +// 热区编辑对话框 +const editDialogRef = ref() +// 打开热区编辑对话框 +const handleOpenEditDialog = () => { + editDialogRef.value.open() +} +</script> + +<style scoped lang="scss"> +.hot-zone { + position: absolute; + background: #409effbf; + border: 1px solid var(--el-color-primary); + color: #fff; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: move; + + /* 控制点 */ + .ctrl-dot { + position: absolute; + width: 4px; + height: 4px; + border-radius: 50%; + background-color: #fff; + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/ImageBar/config.ts b/src/components/DiyEditor/components/mobile/ImageBar/config.ts new file mode 100644 index 0000000..68edf72 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ImageBar/config.ts @@ -0,0 +1,27 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 图片展示属性 */ +export interface ImageBarProperty { + // 图片链接 + imgUrl: string + // 跳转链接 + url: string + // 组件样式 + style: ComponentStyle +} + +// 定义组件 +export const component = { + id: 'ImageBar', + name: '图片展示', + icon: 'ep:picture', + property: { + imgUrl: '', + url: '', + style: { + bgType: 'color', + bgColor: '#fff', + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<ImageBarProperty> diff --git a/src/components/DiyEditor/components/mobile/ImageBar/index.vue b/src/components/DiyEditor/components/mobile/ImageBar/index.vue new file mode 100644 index 0000000..d9685b5 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ImageBar/index.vue @@ -0,0 +1,24 @@ +<template> + <!-- 无图片 --> + <div class="h-50px flex items-center justify-center bg-gray-3" v-if="!property.imgUrl"> + <Icon icon="ep:picture" class="text-gray-8 text-30px!" /> + </div> + <el-image class="min-h-30px" v-else :src="property.imgUrl" /> +</template> +<script setup lang="ts"> +import { ImageBarProperty } from './config' + +/** 图片展示 */ +defineOptions({ name: 'ImageBar' }) + +defineProps<{ property: ImageBarProperty }>() +</script> + +<style scoped lang="scss"> +/* 图片 */ +img { + display: block; + width: 100%; + height: 100%; +} +</style> diff --git a/src/components/DiyEditor/components/mobile/ImageBar/property.vue b/src/components/DiyEditor/components/mobile/ImageBar/property.vue new file mode 100644 index 0000000..d816361 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ImageBar/property.vue @@ -0,0 +1,34 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="80px" :model="formData"> + <el-form-item label="上传图片" prop="imgUrl"> + <UploadImg + v-model="formData.imgUrl" + draggable="false" + height="80px" + width="100%" + class="min-w-80px" + > + <template #tip> 建议宽度750 </template> + </UploadImg> + </el-form-item> + <el-form-item label="链接" prop="url"> + <AppLinkInput v-model="formData.url" /> + </el-form-item> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { ImageBarProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' + +// 图片展示属性面板 +defineOptions({ name: 'ImageBarProperty' }) + +const props = defineProps<{ modelValue: ImageBarProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/MagicCube/config.ts b/src/components/DiyEditor/components/mobile/MagicCube/config.ts new file mode 100644 index 0000000..5e10ab5 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MagicCube/config.ts @@ -0,0 +1,49 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 广告魔方属性 */ +export interface MagicCubeProperty { + // 上圆角 + borderRadiusTop: number + // 下圆角 + borderRadiusBottom: number + // 间隔 + space: number + // 导航菜单列表 + list: MagicCubeItemProperty[] + // 组件样式 + style: ComponentStyle +} + +/** 广告魔方项目属性 */ +export interface MagicCubeItemProperty { + // 图标链接 + imgUrl: string + // 链接 + url: string + // 宽 + width: number + // 高 + height: number + // 上 + top: number + // 左 + left: number +} + +// 定义组件 +export const component = { + id: 'MagicCube', + name: '广告魔方', + icon: 'bi:columns', + property: { + borderRadiusTop: 0, + borderRadiusBottom: 0, + space: 0, + list: [], + style: { + bgType: 'color', + bgColor: '#fff', + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<MagicCubeProperty> diff --git a/src/components/DiyEditor/components/mobile/MagicCube/index.vue b/src/components/DiyEditor/components/mobile/MagicCube/index.vue new file mode 100644 index 0000000..48fb6c7 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MagicCube/index.vue @@ -0,0 +1,73 @@ +<template> + <div + class="relative" + :style="{ height: `${rowCount * CUBE_SIZE}px`, width: `${4 * CUBE_SIZE}px` }" + > + <div + v-for="(item, index) in property.list" + :key="index" + class="absolute" + :style="{ + width: `${item.width * CUBE_SIZE - property.space * 2}px`, + height: `${item.height * CUBE_SIZE - property.space * 2}px`, + margin: `${property.space}px`, + top: `${item.top * CUBE_SIZE}px`, + left: `${item.left * CUBE_SIZE}px` + }" + > + <el-image + class="h-full w-full" + fit="cover" + :src="item.imgUrl" + :style="{ + borderTopLeftRadius: `${property.borderRadiusTop}px`, + borderTopRightRadius: `${property.borderRadiusTop}px`, + borderBottomLeftRadius: `${property.borderRadiusBottom}px`, + borderBottomRightRadius: `${property.borderRadiusBottom}px` + }" + > + <template #error> + <div class="image-slot"> + <div + class="flex items-center justify-center" + :style="{ + width: `${item.width * CUBE_SIZE}px`, + height: `${item.height * CUBE_SIZE}px` + }" + > + <Icon icon="ep-picture" color="gray" :size="CUBE_SIZE" /> + </div> + </div> + </template> + </el-image> + </div> + </div> +</template> + +<script setup lang="ts"> +import { MagicCubeProperty } from './config' + +/** 广告魔方 */ +defineOptions({ name: 'MagicCube' }) +const props = defineProps<{ property: MagicCubeProperty }>() +// 一个方块的大小 +const CUBE_SIZE = 93.75 +/** + * 计算方块的行数 + * 行数用于计算魔方的总体高度,存在以下情况: + * 1. 没有数据时,默认就只显示一行的高度 + * 2. 底部的空白不算高度,例如只有第一行有数据,那么就只显示一行的高度 + * 3. 顶部及中间的空白算高度,例如一共有四行,只有最后一行有数据,那么也显示四行的高度 + */ +const rowCount = computed(() => { + let count = 0 + if (props.property.list.length > 0) { + // 最大行号 + count = Math.max(...props.property.list.map((item) => item.bottom)) + } + // 行号从 0 开始,所以加 1 + return count + 1 +}) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/MagicCube/property.vue b/src/components/DiyEditor/components/mobile/MagicCube/property.vue new file mode 100644 index 0000000..fe938e5 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MagicCube/property.vue @@ -0,0 +1,76 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <!-- 表单 --> + <el-form label-width="80px" :model="formData" class="m-t-8px"> + <el-text tag="p"> 魔方设置 </el-text> + <el-text type="info" size="small"> 每格尺寸187 * 187 </el-text> + <MagicCubeEditor + class="m-y-16px" + v-model="formData.list" + :rows="4" + :cols="4" + @hot-area-selected="handleHotAreaSelected" + /> + <template v-for="(hotArea, index) in formData.list" :key="index"> + <template v-if="selectedHotAreaIndex === index"> + <el-form-item label="上传图片" :prop="`list[${index}].imgUrl`"> + <UploadImg v-model="hotArea.imgUrl" height="80px" width="80px" /> + </el-form-item> + <el-form-item label="链接" :prop="`list[${index}].url`"> + <AppLinkInput v-model="hotArea.url" /> + </el-form-item> + </template> + </template> + <el-form-item label="上圆角" prop="borderRadiusTop"> + <el-slider + v-model="formData.borderRadiusTop" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="下圆角" prop="borderRadiusBottom"> + <el-slider + v-model="formData.borderRadiusBottom" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="间隔" prop="space"> + <el-slider + v-model="formData.space" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { usePropertyForm } from '@/components/DiyEditor/util' +import { MagicCubeProperty } from '@/components/DiyEditor/components/mobile/MagicCube/config' + +/** 广告魔方属性面板 */ +defineOptions({ name: 'MagicCubeProperty' }) + +const props = defineProps<{ modelValue: MagicCubeProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +// 选中的热区 +const selectedHotAreaIndex = ref(-1) +const handleHotAreaSelected = (_: any, index: number) => { + selectedHotAreaIndex.value = index +} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/MenuGrid/config.ts b/src/components/DiyEditor/components/mobile/MenuGrid/config.ts new file mode 100644 index 0000000..9f91ceb --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MenuGrid/config.ts @@ -0,0 +1,79 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' +import { cloneDeep } from 'lodash-es' + +/** 宫格导航属性 */ +export interface MenuGridProperty { + // 列数 + column: number + // 导航菜单列表 + list: MenuGridItemProperty[] + // 组件样式 + style: ComponentStyle +} + +/** 宫格导航项目属性 */ +export interface MenuGridItemProperty { + // 图标链接 + iconUrl: string + // 标题 + title: string + // 标题颜色 + titleColor: string + // 副标题 + subtitle: string + // 副标题颜色 + subtitleColor: string + // 链接 + url: string + // 角标 + badge: { + // 是否显示 + show: boolean + // 角标文字 + text: string + // 角标文字颜色 + textColor: string + // 角标背景颜色 + bgColor: string + } +} + +export const EMPTY_MENU_GRID_ITEM_PROPERTY = { + title: '标题', + titleColor: '#333', + subtitle: '副标题', + subtitleColor: '#bbb', + badge: { + show: false, + textColor: '#fff', + bgColor: '#FF6000' + } +} as MenuGridItemProperty + +// 定义组件 +export const component = { + id: 'MenuGrid', + name: '宫格导航', + icon: 'bi:grid-3x3-gap', + property: { + column: 3, + list: [cloneDeep(EMPTY_MENU_GRID_ITEM_PROPERTY)], + style: { + bgType: 'color', + bgColor: '#fff', + marginBottom: 8, + marginLeft: 8, + marginRight: 8, + padding: 8, + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8, + borderRadius: 8, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + borderBottomRightRadius: 8, + borderBottomLeftRadius: 8 + } as ComponentStyle + } +} as DiyComponent<MenuGridProperty> diff --git a/src/components/DiyEditor/components/mobile/MenuGrid/index.vue b/src/components/DiyEditor/components/mobile/MenuGrid/index.vue new file mode 100644 index 0000000..1c5ef1d --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MenuGrid/index.vue @@ -0,0 +1,35 @@ +<template> + <div class="flex flex-row flex-wrap"> + <div + v-for="(item, index) in property.list" + :key="index" + class="relative flex flex-col items-center p-b-14px p-t-20px" + :style="{ width: `${100 * (1 / property.column)}%` }" + > + <!-- 右上角角标 --> + <span + v-if="item.badge?.show" + class="absolute left-50% top-10px z-1 h-20px rounded-50% p-x-6px text-center text-12px leading-20px" + :style="{ color: item.badge.textColor, backgroundColor: item.badge.bgColor }" + > + {{ item.badge.text }} + </span> + <el-image v-if="item.iconUrl" class="h-28px w-28px" :src="item.iconUrl" /> + <span class="m-t-8px h-16px text-12px leading-16px" :style="{ color: item.titleColor }"> + {{ item.title }} + </span> + <span class="m-t-6px h-12px text-10px leading-12px" :style="{ color: item.subtitleColor }"> + {{ item.subtitle }} + </span> + </div> + </div> +</template> + +<script setup lang="ts"> +import { MenuGridProperty } from './config' +/** 宫格导航 */ +defineOptions({ name: 'MenuGrid' }) +defineProps<{ property: MenuGridProperty }>() +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/MenuGrid/property.vue b/src/components/DiyEditor/components/mobile/MenuGrid/property.vue new file mode 100644 index 0000000..7940fd0 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MenuGrid/property.vue @@ -0,0 +1,65 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <!-- 表单 --> + <el-form label-width="80px" :model="formData" class="m-t-8px"> + <el-form-item label="每行数量" prop="column"> + <el-radio-group v-model="formData.column"> + <el-radio :label="3">3个</el-radio> + <el-radio :label="4">4个</el-radio> + </el-radio-group> + </el-form-item> + + <el-card header="菜单设置" class="property-group" shadow="never"> + <Draggable v-model="formData.list" :empty-item="EMPTY_MENU_GRID_ITEM_PROPERTY"> + <template #default="{ element }"> + <el-form-item label="图标" prop="iconUrl"> + <UploadImg v-model="element.iconUrl" height="80px" width="80px"> + <template #tip> 建议尺寸:44 * 44 </template> + </UploadImg> + </el-form-item> + <el-form-item label="标题" prop="title"> + <InputWithColor v-model="element.title" v-model:color="element.titleColor" /> + </el-form-item> + <el-form-item label="副标题" prop="subtitle"> + <InputWithColor v-model="element.subtitle" v-model:color="element.subtitleColor" /> + </el-form-item> + <el-form-item label="链接" prop="url"> + <AppLinkInput v-model="element.url" /> + </el-form-item> + <el-form-item label="显示角标" prop="badge.show"> + <el-switch v-model="element.badge.show" /> + </el-form-item> + <template v-if="element.badge.show"> + <el-form-item label="角标内容" prop="badge.text"> + <InputWithColor + v-model="element.badge.text" + v-model:color="element.badge.textColor" + /> + </el-form-item> + <el-form-item label="背景颜色" prop="badge.bgColor"> + <ColorInput v-model="element.badge.bgColor" /> + </el-form-item> + </template> + </template> + </Draggable> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { usePropertyForm } from '@/components/DiyEditor/util' +import { + EMPTY_MENU_GRID_ITEM_PROPERTY, + MenuGridProperty +} from '@/components/DiyEditor/components/mobile/MenuGrid/config' + +/** 宫格导航属性面板 */ +defineOptions({ name: 'MenuGridProperty' }) + +const props = defineProps<{ modelValue: MenuGridProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/MenuList/config.ts b/src/components/DiyEditor/components/mobile/MenuList/config.ts new file mode 100644 index 0000000..f96fd0a --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MenuList/config.ts @@ -0,0 +1,48 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' +import { cloneDeep } from 'lodash-es' + +/** 列表导航属性 */ +export interface MenuListProperty { + // 导航菜单列表 + list: MenuListItemProperty[] + // 组件样式 + style: ComponentStyle +} + +/** 列表导航项目属性 */ +export interface MenuListItemProperty { + // 图标链接 + iconUrl: string + // 标题 + title: string + // 标题颜色 + titleColor: string + // 副标题 + subtitle: string + // 副标题颜色 + subtitleColor: string + // 链接 + url: string +} + +export const EMPTY_MENU_LIST_ITEM_PROPERTY = { + title: '标题', + titleColor: '#333', + subtitle: '副标题', + subtitleColor: '#bbb' +} + +// 定义组件 +export const component = { + id: 'MenuList', + name: '列表导航', + icon: 'fa-solid:list', + property: { + list: [cloneDeep(EMPTY_MENU_LIST_ITEM_PROPERTY)], + style: { + bgType: 'color', + bgColor: '#fff', + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<MenuListProperty> diff --git a/src/components/DiyEditor/components/mobile/MenuList/index.vue b/src/components/DiyEditor/components/mobile/MenuList/index.vue new file mode 100644 index 0000000..9a56fd9 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MenuList/index.vue @@ -0,0 +1,31 @@ +<template> + <div class="min-h-42px flex flex-col"> + <div + v-for="(item, index) in property.list" + :key="index" + class="item h-42px flex flex-row items-center justify-between gap-4px p-x-12px" + > + <div class="flex flex-1 flex-row items-center gap-8px"> + <el-image v-if="item.iconUrl" class="h-16px w-16px" :src="item.iconUrl" /> + <span class="text-16px" :style="{ color: item.titleColor }">{{ item.title }}</span> + </div> + <div class="item-center flex flex-row justify-center gap-4px"> + <span class="text-12px" :style="{ color: item.subtitleColor }">{{ item.subtitle }}</span> + <Icon icon="ep-arrow-right" color="#000" :size="16" /> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { MenuListProperty } from './config' +/** 列表导航 */ +defineOptions({ name: 'MenuList' }) +defineProps<{ property: MenuListProperty }>() +</script> + +<style scoped lang="scss"> +.item + .item { + border-top: 1px solid #eee; +} +</style> diff --git a/src/components/DiyEditor/components/mobile/MenuList/property.vue b/src/components/DiyEditor/components/mobile/MenuList/property.vue new file mode 100644 index 0000000..a5fb460 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MenuList/property.vue @@ -0,0 +1,45 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-text tag="p"> 菜单设置 </el-text> + <el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text> + + <!-- 表单 --> + <el-form label-width="60px" :model="formData" class="m-t-8px"> + <Draggable v-model="formData.list" :empty-item="EMPTY_MENU_LIST_ITEM_PROPERTY"> + <template #default="{ element }"> + <el-form-item label="图标" prop="iconUrl"> + <UploadImg v-model="element.iconUrl" height="80px" width="80px"> + <template #tip> 建议尺寸:44 * 44 </template> + </UploadImg> + </el-form-item> + <el-form-item label="标题" prop="title"> + <InputWithColor v-model="element.title" v-model:color="element.titleColor" /> + </el-form-item> + <el-form-item label="副标题" prop="subtitle"> + <InputWithColor v-model="element.subtitle" v-model:color="element.subtitleColor" /> + </el-form-item> + <el-form-item label="链接" prop="url"> + <AppLinkInput v-model="element.url" /> + </el-form-item> + </template> + </Draggable> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { usePropertyForm } from '@/components/DiyEditor/util' +import { + EMPTY_MENU_LIST_ITEM_PROPERTY, + MenuListProperty +} from '@/components/DiyEditor/components/mobile/MenuList/config' + +/** 列表导航属性面板 */ +defineOptions({ name: 'MenuListProperty' }) + +const props = defineProps<{ modelValue: MenuListProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/MenuSwiper/config.ts b/src/components/DiyEditor/components/mobile/MenuSwiper/config.ts new file mode 100644 index 0000000..fe5f4e8 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MenuSwiper/config.ts @@ -0,0 +1,66 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' +import { cloneDeep } from 'lodash-es' + +/** 菜单导航属性 */ +export interface MenuSwiperProperty { + // 布局: 图标+文字 | 图标 + layout: 'iconText' | 'icon' + // 行数 + row: number + // 列数 + column: number + // 导航菜单列表 + list: MenuSwiperItemProperty[] + // 组件样式 + style: ComponentStyle +} +/** 菜单导航项目属性 */ +export interface MenuSwiperItemProperty { + // 图标链接 + iconUrl: string + // 标题 + title: string + // 标题颜色 + titleColor: string + // 链接 + url: string + // 角标 + badge: { + // 是否显示 + show: boolean + // 角标文字 + text: string + // 角标文字颜色 + textColor: string + // 角标背景颜色 + bgColor: string + } +} + +export const EMPTY_MENU_SWIPER_ITEM_PROPERTY = { + title: '标题', + titleColor: '#333', + badge: { + show: false, + textColor: '#fff', + bgColor: '#FF6000' + } +} as MenuSwiperItemProperty + +// 定义组件 +export const component = { + id: 'MenuSwiper', + name: '菜单导航', + icon: 'bi:grid-3x2-gap', + property: { + layout: 'iconText', + row: 1, + column: 3, + list: [cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY)], + style: { + bgType: 'color', + bgColor: '#fff', + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<MenuSwiperProperty> diff --git a/src/components/DiyEditor/components/mobile/MenuSwiper/index.vue b/src/components/DiyEditor/components/mobile/MenuSwiper/index.vue new file mode 100644 index 0000000..f8e2bbc --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MenuSwiper/index.vue @@ -0,0 +1,119 @@ +<template> + <el-carousel + :height="`${carouselHeight}px`" + :autoplay="false" + arrow="hover" + indicator-position="outside" + > + <el-carousel-item v-for="(page, pageIndex) in pages" :key="pageIndex"> + <div class="flex flex-row flex-wrap"> + <div + v-for="(item, index) in page" + :key="index" + class="relative flex flex-col items-center justify-center" + :style="{ width: columnWidth, height: `${rowHeight}px` }" + > + <!-- 图标 + 角标 --> + <div class="relative" :class="`h-${ICON_SIZE}px w-${ICON_SIZE}px`"> + <!-- 右上角角标 --> + <span + v-if="item.badge?.show" + class="absolute right--10px top--10px z-1 h-20px rounded-10px p-x-6px text-center text-12px leading-20px" + :style="{ color: item.badge.textColor, backgroundColor: item.badge.bgColor }" + > + {{ item.badge.text }} + </span> + <el-image v-if="item.iconUrl" :src="item.iconUrl" class="h-full w-full" /> + </div> + <!-- 标题 --> + <span + v-if="property.layout === 'iconText'" + class="text-12px" + :style="{ + color: item.titleColor, + height: `${TITLE_HEIGHT}px`, + lineHeight: `${TITLE_HEIGHT}px` + }" + > + {{ item.title }} + </span> + </div> + </div> + </el-carousel-item> + </el-carousel> +</template> + +<script setup lang="ts"> +import { MenuSwiperProperty, MenuSwiperItemProperty } from './config' +/** 菜单导航 */ +defineOptions({ name: 'MenuSwiper' }) +const props = defineProps<{ property: MenuSwiperProperty }>() +// 标题的高度 +const TITLE_HEIGHT = 20 +// 图标的高度 +const ICON_SIZE = 42 +// 垂直间距:一行上下的间距 +const SPACE_Y = 16 + +// 分页 +const pages = ref<MenuSwiperItemProperty[][]>([]) +// 轮播图高度 +const carouselHeight = ref(0) +// 行高 +const rowHeight = ref(0) +// 列宽 +const columnWidth = ref('') +watch( + () => props.property, + () => { + // 计算列宽:每一列的百分比 + columnWidth.value = `${100 * (1 / props.property.column)}%` + // 计算行高:图标 + 文字(仅显示图片时为0) + 垂直间距 * 2 + rowHeight.value = + (props.property.layout === 'iconText' ? ICON_SIZE + TITLE_HEIGHT : ICON_SIZE) + SPACE_Y * 2 + // 计算轮播的高度:行数 * 行高 + carouselHeight.value = props.property.row * rowHeight.value + + // 每页数量:行数 * 列数 + const pageSize = props.property.row * props.property.column + // 清空分页 + pages.value = [] + // 每一页的菜单 + let pageItems: MenuSwiperItemProperty[] = [] + for (const item of props.property.list) { + // 本页满员,新建下一页 + if (pageItems.length === pageSize) { + pageItems = [] + } + // 增加一页 + if (pageItems.length === 0) { + pages.value.push(pageItems) + } + // 本页增加一个 + pageItems.push(item) + } + }, + { immediate: true, deep: true } +) +</script> + +<style lang="scss"> +// 重写指示器样式,与 APP 保持一致 +:root { + .el-carousel__indicator { + padding-top: 0; + padding-bottom: 0; + .el-carousel__button { + --el-carousel-indicator-height: 6px; + --el-carousel-indicator-width: 6px; + --el-carousel-indicator-out-color: #ff6000; + border-radius: 6px; + } + } + .el-carousel__indicator.is-active { + .el-carousel__button { + --el-carousel-indicator-width: 12px; + } + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/MenuSwiper/property.vue b/src/components/DiyEditor/components/mobile/MenuSwiper/property.vue new file mode 100644 index 0000000..81266bc --- /dev/null +++ b/src/components/DiyEditor/components/mobile/MenuSwiper/property.vue @@ -0,0 +1,76 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <!-- 表单 --> + <el-form label-width="80px" :model="formData" class="m-t-8px"> + <el-form-item label="布局" prop="layout"> + <el-radio-group v-model="formData.layout"> + <el-radio label="iconText">图标+文字</el-radio> + <el-radio label="icon">仅图标</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="行数" prop="row"> + <el-radio-group v-model="formData.row"> + <el-radio :label="1">1行</el-radio> + <el-radio :label="2">2行</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="列数" prop="column"> + <el-radio-group v-model="formData.column"> + <el-radio :label="3">3列</el-radio> + <el-radio :label="4">4列</el-radio> + <el-radio :label="5">5列</el-radio> + </el-radio-group> + </el-form-item> + + <el-card header="菜单设置" class="property-group" shadow="never"> + <Draggable v-model="formData.list" :empty-item="cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY)"> + <template #default="{ element }"> + <el-form-item label="图标" prop="iconUrl"> + <UploadImg v-model="element.iconUrl" height="80px" width="80px"> + <template #tip> 建议尺寸:98 * 98 </template> + </UploadImg> + </el-form-item> + <el-form-item label="标题" prop="title"> + <InputWithColor v-model="element.title" v-model:color="element.titleColor" /> + </el-form-item> + <el-form-item label="链接" prop="url"> + <AppLinkInput v-model="element.url" /> + </el-form-item> + <el-form-item label="显示角标" prop="badge.show"> + <el-switch v-model="element.badge.show" /> + </el-form-item> + <template v-if="element.badge.show"> + <el-form-item label="角标内容" prop="badge.text"> + <InputWithColor + v-model="element.badge.text" + v-model:color="element.badge.textColor" + /> + </el-form-item> + <el-form-item label="背景颜色" prop="badge.bgColor"> + <ColorInput v-model="element.badge.bgColor" /> + </el-form-item> + </template> + </template> + </Draggable> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { usePropertyForm } from '@/components/DiyEditor/util' +import { + EMPTY_MENU_SWIPER_ITEM_PROPERTY, + MenuSwiperProperty +} from '@/components/DiyEditor/components/mobile/MenuSwiper/config' +import { cloneDeep } from 'lodash-es' + +/** 菜单导航属性面板 */ +defineOptions({ name: 'MenuSwiperProperty' }) + +const props = defineProps<{ modelValue: MenuSwiperProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue b/src/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue new file mode 100644 index 0000000..edc85f1 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue @@ -0,0 +1,90 @@ +<template> + <div class="h-40px flex items-center justify-center"> + <MagicCubeEditor + v-model="cellList" + class="m-b-16px" + :rows="1" + :cols="cellCount" + :cube-size="38" + @hot-area-selected="handleHotAreaSelected" + /> + <img src="@/assets/imgs/diy/app-nav-bar-mp.png" alt="" class="h-30px w-76px" v-if="isMp" /> + </div> + <template v-for="(cell, cellIndex) in cellList" :key="cellIndex"> + <template v-if="selectedHotAreaIndex === cellIndex"> + <el-form-item label="类型" :prop="`cell[${cellIndex}].type`"> + <el-radio-group v-model="cell.type"> + <el-radio label="text">文字</el-radio> + <el-radio label="image">图片</el-radio> + <el-radio label="search">搜索框</el-radio> + </el-radio-group> + </el-form-item> + <!-- 1. 文字 --> + <template v-if="cell.type === 'text'"> + <el-form-item label="内容" :prop="`cell[${cellIndex}].text`"> + <el-input v-model="cell!.text" maxlength="10" show-word-limit /> + </el-form-item> + <el-form-item label="颜色" :prop="`cell[${cellIndex}].text`"> + <ColorInput v-model="cell!.textColor" /> + </el-form-item> + </template> + <!-- 2. 图片 --> + <template v-else-if="cell.type === 'image'"> + <el-form-item label="图片" :prop="`cell[${cellIndex}].imgUrl`"> + <UploadImg v-model="cell.imgUrl" :limit="1" height="56px" width="56px"> + <template #tip>建议尺寸 56*56</template> + </UploadImg> + </el-form-item> + <el-form-item label="链接" :prop="`cell[${cellIndex}].url`"> + <AppLinkInput v-model="cell.url" /> + </el-form-item> + </template> + <!-- 3. 搜索框 --> + <template v-else> + <el-form-item label="提示文字" :prop="`cell[${cellIndex}].placeholder`"> + <el-input v-model="cell.placeholder" maxlength="10" show-word-limit /> + </el-form-item> + <el-form-item label="圆角" :prop="`cell[${cellIndex}].borderRadius`"> + <el-slider + v-model="cell.borderRadius" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + </template> + </template> + </template> +</template> + +<script setup lang="ts"> +import { NavigationBarCellProperty } from '../config' +import { usePropertyForm } from '@/components/DiyEditor/util' +// 导航栏属性面板 +defineOptions({ name: 'NavigationBarCellProperty' }) + +const props = defineProps<{ + modelValue: NavigationBarCellProperty[] + isMp: boolean +}>() +const emit = defineEmits(['update:modelValue']) +const { formData: cellList } = usePropertyForm(props.modelValue, emit) +if (!cellList.value) cellList.value = [] + +// 单元格数量:小程序6个(右侧胶囊按钮占了2个),其它平台8个 +const cellCount = computed(() => (props.isMp ? 6 : 8)) + +// 选中的热区 +const selectedHotAreaIndex = ref(0) +const handleHotAreaSelected = (cellValue: NavigationBarCellProperty, index: number) => { + selectedHotAreaIndex.value = index + if (!cellValue.type) { + cellValue.type = 'text' + cellValue.textColor = '#111111' + } +} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/NavigationBar/config.ts b/src/components/DiyEditor/components/mobile/NavigationBar/config.ts new file mode 100644 index 0000000..36612a3 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NavigationBar/config.ts @@ -0,0 +1,82 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 顶部导航栏属性 */ +export interface NavigationBarProperty { + // 背景类型 + bgType: 'color' | 'img' + // 背景颜色 + bgColor: string + // 图片链接 + bgImg: string + // 样式类型:默认 | 沉浸式 + styleType: 'normal' | 'inner' + // 常驻显示 + alwaysShow: boolean + // 小程序单元格列表 + mpCells: NavigationBarCellProperty[] + // 其它平台单元格列表 + otherCells: NavigationBarCellProperty[] + // 本地变量 + _local: { + // 预览顶部导航(小程序) + previewMp: boolean + // 预览顶部导航(非小程序) + previewOther: boolean + } +} + +/** 顶部导航栏 - 单元格 属性 */ +export interface NavigationBarCellProperty { + // 类型:文字 | 图片 | 搜索框 + type: 'text' | 'image' | 'search' + // 宽度 + width: number + // 高度 + height: number + // 顶部位置 + top: number + // 左侧位置 + left: number + // 文字内容 + text: string + // 文字颜色 + textColor: string + // 图片地址 + imgUrl: string + // 图片链接 + url: string + // 搜索框:提示文字 + placeholder: string + // 搜索框:边框圆角半径 + borderRadius: number +} + +// 定义组件 +export const component = { + id: 'NavigationBar', + name: '顶部导航栏', + icon: 'tabler:layout-navbar', + property: { + bgType: 'color', + bgColor: '#fff', + bgImg: '', + styleType: 'normal', + alwaysShow: true, + mpCells: [ + { + type: 'text', + textColor: '#111111' + } + ], + otherCells: [ + { + type: 'text', + textColor: '#111111' + } + ], + _local: { + previewMp: true, + previewOther: false + } + } +} as DiyComponent<NavigationBarProperty> diff --git a/src/components/DiyEditor/components/mobile/NavigationBar/index.vue b/src/components/DiyEditor/components/mobile/NavigationBar/index.vue new file mode 100644 index 0000000..c684aee --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NavigationBar/index.vue @@ -0,0 +1,90 @@ +<template> + <div class="navigation-bar" :style="bgStyle"> + <div class="h-full w-full flex items-center"> + <div v-for="(cell, cellIndex) in cellList" :key="cellIndex" :style="getCellStyle(cell)"> + <span v-if="cell.type === 'text'">{{ cell.text }}</span> + <img v-else-if="cell.type === 'image'" :src="cell.imgUrl" alt="" class="h-full w-full" /> + <SearchBar v-else :property="getSearchProp" /> + </div> + </div> + <img + v-if="property._local?.previewMp" + src="@/assets/imgs/diy/app-nav-bar-mp.png" + alt="" + class="h-30px w-86px" + /> + </div> +</template> +<script setup lang="ts"> +import { NavigationBarCellProperty, NavigationBarProperty } from './config' +import SearchBar from '@/components/DiyEditor/components/mobile/SearchBar/index.vue' +import { StyleValue } from 'vue' +import { SearchProperty } from '@/components/DiyEditor/components/mobile/SearchBar/config' + +/** 页面顶部导航栏 */ +defineOptions({ name: 'NavigationBar' }) + +const props = defineProps<{ property: NavigationBarProperty }>() + +// 背景 +const bgStyle = computed(() => { + const background = + props.property.bgType === 'img' && props.property.bgImg + ? `url(${props.property.bgImg}) no-repeat top center / 100% 100%` + : props.property.bgColor + return { background } +}) +// 单元格列表 +const cellList = computed(() => + props.property._local?.previewMp ? props.property.mpCells : props.property.otherCells +) +// 单元格宽度 +const cellWidth = computed(() => { + return props.property._local?.previewMp ? (375 - 80 - 86) / 6 : (375 - 90) / 8 +}) +// 获得单元格样式 +const getCellStyle = (cell: NavigationBarCellProperty) => { + return { + width: cell.width * cellWidth.value + (cell.width - 1) * 10 + 'px', + left: cell.left * cellWidth.value + (cell.left + 1) * 10 + 'px', + position: 'absolute' + } as StyleValue +} +// 获得搜索框属性 +const getSearchProp = (cell: NavigationBarCellProperty) => { + return { + height: 30, + showScan: false, + placeholder: cell.placeholder, + borderRadius: cell.borderRadius + } as SearchProperty +} +</script> +<style lang="scss" scoped> +.navigation-bar { + display: flex; + height: 50px; + background: #fff; + justify-content: space-between; + align-items: center; + padding: 0 6px; + + /* 左边 */ + .left { + margin-left: 8px; + } + + .center { + font-size: 14px; + line-height: 35px; + color: #333; + text-align: center; + flex: 1; + } + + /* 右边 */ + .right { + margin-right: 8px; + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/NavigationBar/property.vue b/src/components/DiyEditor/components/mobile/NavigationBar/property.vue new file mode 100644 index 0000000..b2bc8c1 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NavigationBar/property.vue @@ -0,0 +1,86 @@ +<template> + <el-form label-width="80px" :model="formData" :rules="rules"> + <el-form-item label="样式" prop="styleType"> + <el-radio-group v-model="formData!.styleType"> + <el-radio label="normal">标准</el-radio> + <el-tooltip + content="沉侵式头部仅支持微信小程序、APP,建议页面第一个组件为图片展示类组件" + placement="top" + > + <el-radio label="inner">沉浸式</el-radio> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="常驻显示" prop="alwaysShow" v-if="formData.styleType === 'inner'"> + <el-radio-group v-model="formData!.alwaysShow"> + <el-radio :label="false">关闭</el-radio> + <el-tooltip content="常驻显示关闭后,头部小组件将在页面滑动时淡入" placement="top"> + <el-radio :label="true">开启</el-radio> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="背景类型" prop="bgType"> + <el-radio-group v-model="formData.bgType"> + <el-radio label="color">纯色</el-radio> + <el-radio label="img">图片</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="背景颜色" prop="bgColor" v-if="formData.bgType === 'color'"> + <ColorInput v-model="formData.bgColor" /> + </el-form-item> + <el-form-item label="背景图片" prop="bgImg" v-else> + <UploadImg v-model="formData.bgImg" :limit="1" width="56px" height="56px" /> + </el-form-item> + <el-card class="property-group" shadow="never"> + <template #header> + <div class="flex items-center justify-between"> + <span>内容(小程序)</span> + <el-form-item prop="_local.previewMp" class="m-b-0!"> + <el-checkbox + v-model="formData._local.previewMp" + @change="formData._local.previewOther = !formData._local.previewMp" + >预览</el-checkbox + > + </el-form-item> + </div> + </template> + <NavigationBarCellProperty v-model="formData.mpCells" is-mp /> + </el-card> + <el-card class="property-group" shadow="never"> + <template #header> + <div class="flex items-center justify-between"> + <span>内容(非小程序)</span> + <el-form-item prop="_local.previewOther" class="m-b-0!"> + <el-checkbox + v-model="formData._local.previewOther" + @change="formData._local.previewMp = !formData._local.previewOther" + >预览</el-checkbox + > + </el-form-item> + </div> + </template> + <NavigationBarCellProperty v-model="formData.otherCells" :is-mp="false" /> + </el-card> + </el-form> +</template> + +<script setup lang="ts"> +import { NavigationBarProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +import NavigationBarCellProperty from '@/components/DiyEditor/components/mobile/NavigationBar/components/CellProperty.vue' +// 导航栏属性面板 +defineOptions({ name: 'NavigationBarProperty' }) +// 表单校验 +const rules = { + name: [{ required: true, message: '请输入页面名称', trigger: 'blur' }] +} + +const props = defineProps<{ modelValue: NavigationBarProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +if (!formData.value._local) { + formData.value._local = { previewMp: true, previewOther: false } +} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/NoticeBar/config.ts b/src/components/DiyEditor/components/mobile/NoticeBar/config.ts new file mode 100644 index 0000000..b6b0860 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NoticeBar/config.ts @@ -0,0 +1,46 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 公告栏属性 */ +export interface NoticeBarProperty { + // 图标地址 + iconUrl: string + // 公告内容列表 + contents: NoticeContentProperty[] + // 背景颜色 + backgroundColor: string + // 文字颜色 + textColor: string + // 组件样式 + style: ComponentStyle +} + +/** 内容属性 */ +export interface NoticeContentProperty { + // 内容文字 + text: string + // 链接地址 + url: string +} + +// 定义组件 +export const component = { + id: 'NoticeBar', + name: '公告栏', + icon: 'ep:bell', + property: { + iconUrl: 'http://mall.yudao.iocoder.cn/static/images/xinjian.png', + contents: [ + { + text: '', + url: '' + } + ], + backgroundColor: '#fff', + textColor: '#333', + style: { + bgType: 'color', + bgColor: '#fff', + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<NoticeBarProperty> diff --git a/src/components/DiyEditor/components/mobile/NoticeBar/index.vue b/src/components/DiyEditor/components/mobile/NoticeBar/index.vue new file mode 100644 index 0000000..fce1afb --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NoticeBar/index.vue @@ -0,0 +1,26 @@ +<template> + <div + class="flex items-center p-y-4px text-12px" + :style="{ backgroundColor: property.backgroundColor, color: property.textColor }" + > + <el-image :src="property.iconUrl" class="h-18px" /> + <el-divider direction="vertical" /> + <el-carousel height="24px" direction="vertical" :autoplay="true" class="flex-1 p-r-8px"> + <el-carousel-item v-for="(item, index) in property.contents" :key="index"> + <div class="h-24px truncate leading-24px">{{ item.text }}</div> + </el-carousel-item> + </el-carousel> + <Icon icon="ep:arrow-right" /> + </div> +</template> + +<script setup lang="ts"> +import { NoticeBarProperty } from './config' + +/** 公告栏 */ +defineOptions({ name: 'NoticeBar' }) + +defineProps<{ property: NoticeBarProperty }>() +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/NoticeBar/property.vue b/src/components/DiyEditor/components/mobile/NoticeBar/property.vue new file mode 100644 index 0000000..a505011 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/NoticeBar/property.vue @@ -0,0 +1,46 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="80px" :model="formData" :rules="rules"> + <el-form-item label="公告图标" prop="iconUrl"> + <UploadImg v-model="formData.iconUrl" height="48px"> + <template #tip>建议尺寸:24 * 24</template> + </UploadImg> + </el-form-item> + <el-form-item label="背景颜色" prop="backgroundColor"> + <ColorInput v-model="formData.backgroundColor" /> + </el-form-item> + <el-form-item label="文字颜色" prop="文字颜色"> + <ColorInput v-model="formData.textColor" /> + </el-form-item> + <el-card header="公告内容" class="property-group" shadow="never"> + <Draggable v-model="formData.contents"> + <template #default="{ element }"> + <el-form-item label="公告" prop="text" label-width="40px"> + <el-input v-model="element.text" placeholder="请输入公告" /> + </el-form-item> + <el-form-item label="链接" prop="url" label-width="40px"> + <AppLinkInput v-model="element.url" /> + </el-form-item> + </template> + </Draggable> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { NoticeBarProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +// 通知栏属性面板 +defineOptions({ name: 'NoticeBarProperty' }) +// 表单校验 +const rules = { + content: [{ required: true, message: '请输入公告', trigger: 'blur' }] +} + +const props = defineProps<{ modelValue: NoticeBarProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/PageConfig/config.ts b/src/components/DiyEditor/components/mobile/PageConfig/config.ts new file mode 100644 index 0000000..f8e45e4 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PageConfig/config.ts @@ -0,0 +1,23 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 页面设置属性 */ +export interface PageConfigProperty { + // 页面描述 + description: string + // 页面背景颜色 + backgroundColor: string + // 页面背景图片 + backgroundImage: string +} + +// 定义页面组件 +export const component = { + id: 'PageConfig', + name: '页面设置', + icon: 'ep:document', + property: { + description: '', + backgroundColor: '#f5f5f5', + backgroundImage: '' + } +} as DiyComponent<PageConfigProperty> diff --git a/src/components/DiyEditor/components/mobile/PageConfig/property.vue b/src/components/DiyEditor/components/mobile/PageConfig/property.vue new file mode 100644 index 0000000..278bc94 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PageConfig/property.vue @@ -0,0 +1,34 @@ +<template> + <el-form label-width="80px" :model="formData" :rules="rules"> + <el-form-item label="页面描述" prop="description"> + <el-input + type="textarea" + v-model="formData!.description" + placeholder="用户通过微信分享给朋友时,会自动显示页面描述" + /> + </el-form-item> + <el-form-item label="背景颜色" prop="backgroundColor"> + <ColorInput v-model="formData!.backgroundColor" /> + </el-form-item> + <el-form-item label="背景图片" prop="backgroundImage"> + <UploadImg v-model="formData!.backgroundImage" :limit="1"> + <template #tip>建议宽度 750px</template> + </UploadImg> + </el-form-item> + </el-form> +</template> + +<script setup lang="ts"> +import { PageConfigProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +// 导航栏属性面板 +defineOptions({ name: 'PageConfigProperty' }) +// 表单校验 +const rules = {} + +const props = defineProps<{ modelValue: PageConfigProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/Popover/config.ts b/src/components/DiyEditor/components/mobile/Popover/config.ts new file mode 100644 index 0000000..e814090 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Popover/config.ts @@ -0,0 +1,26 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 弹窗广告属性 */ +export interface PopoverProperty { + list: PopoverItemProperty[] +} + +export interface PopoverItemProperty { + // 图片地址 + imgUrl: string + // 跳转连接 + url: string + // 显示类型:仅显示一次、每次启动都会显示 + showType: 'once' | 'always' +} + +// 定义组件 +export const component = { + id: 'Popover', + name: '弹窗广告', + icon: 'carbon:popup', + position: 'fixed', + property: { + list: [{ showType: 'once' }] + } +} as DiyComponent<PopoverProperty> diff --git a/src/components/DiyEditor/components/mobile/Popover/index.vue b/src/components/DiyEditor/components/mobile/Popover/index.vue new file mode 100644 index 0000000..347599b --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Popover/index.vue @@ -0,0 +1,38 @@ +<template> + <div + v-for="(item, index) in property.list" + :key="index" + class="absolute bottom-50% right-50% h-454px w-292px border-1px border-gray border-rounded-4px border-solid bg-white p-1px" + :style="{ + zIndex: 100 + index + (activeIndex === index ? 100 : 0), + marginRight: `${-146 - index * 20}px`, + marginBottom: `${-227 - index * 20}px` + }" + @click="handleActive(index)" + > + <el-image :src="item.imgUrl" fit="contain" class="h-full w-full"> + <template #error> + <div class="h-full w-full flex items-center justify-center"> + <Icon icon="ep:picture" /> + </div> + </template> + </el-image> + <div class="absolute right-1 top-1 text-12px">{{ index + 1 }}</div> + </div> +</template> +<script setup lang="ts"> +import { PopoverProperty } from './config' + +/** 弹窗广告 */ +defineOptions({ name: 'Popover' }) +// 定义属性 +defineProps<{ property: PopoverProperty }>() + +// 处理选中 +const activeIndex = ref(0) +const handleActive = (index: number) => { + activeIndex.value = index +} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/Popover/property.vue b/src/components/DiyEditor/components/mobile/Popover/property.vue new file mode 100644 index 0000000..6535e3b --- /dev/null +++ b/src/components/DiyEditor/components/mobile/Popover/property.vue @@ -0,0 +1,38 @@ +<template> + <el-form label-width="80px" :model="formData"> + <Draggable v-model="formData.list" :empty-item="{ showType: 'once' }"> + <template #default="{ element, index }"> + <el-form-item label="图片" :prop="`list[${index}].imgUrl`"> + <UploadImg v-model="element.imgUrl" height="56px" width="56px" /> + </el-form-item> + <el-form-item label="跳转链接" :prop="`list[${index}].url`"> + <AppLinkInput v-model="element.url" /> + </el-form-item> + <el-form-item label="显示次数" :prop="`list[${index}].showType`"> + <el-radio-group v-model="element.showType"> + <el-tooltip content="只显示一次,下次打开时不显示" placement="bottom"> + <el-radio label="once">一次</el-radio> + </el-tooltip> + <el-tooltip content="每次打开时都会显示" placement="bottom"> + <el-radio label="always">不限</el-radio> + </el-tooltip> + </el-radio-group> + </el-form-item> + </template> + </Draggable> + </el-form> +</template> + +<script setup lang="ts"> +import { PopoverProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' + +// 弹窗广告属性面板 +defineOptions({ name: 'PopoverProperty' }) + +const props = defineProps<{ modelValue: PopoverProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/ProductCard/config.ts b/src/components/DiyEditor/components/mobile/ProductCard/config.ts new file mode 100644 index 0000000..735b6ba --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ProductCard/config.ts @@ -0,0 +1,97 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 商品卡片属性 */ +export interface ProductCardProperty { + // 布局类型:单列大图 | 单列小图 | 双列 + layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol' + // 商品字段 + fields: { + // 商品名称 + name: ProductCardFieldProperty + // 商品简介 + introduction: ProductCardFieldProperty + // 商品价格 + price: ProductCardFieldProperty + // 商品市场价 + marketPrice: ProductCardFieldProperty + // 商品销量 + salesCount: ProductCardFieldProperty + // 商品库存 + stock: ProductCardFieldProperty + } + // 角标 + badge: { + // 是否显示 + show: boolean + // 角标图片 + imgUrl: string + } + // 按钮 + btnBuy: { + // 类型:文字 | 图片 + type: 'text' | 'img' + // 文字 + text: string + // 文字按钮:背景渐变起始颜色 + bgBeginColor: string + // 文字按钮:背景渐变结束颜色 + bgEndColor: string + // 图片按钮:图片地址 + imgUrl: string + } + // 上圆角 + borderRadiusTop: number + // 下圆角 + borderRadiusBottom: number + // 间距 + space: number + // 商品编号列表 + spuIds: number[] + // 组件样式 + style: ComponentStyle +} +// 商品字段 +export interface ProductCardFieldProperty { + // 是否显示 + show: boolean + // 颜色 + color: string +} + +// 定义组件 +export const component = { + id: 'ProductCard', + name: '商品卡片', + icon: 'fluent:text-column-two-left-24-filled', + property: { + layoutType: 'oneColBigImg', + fields: { + name: { show: true, color: '#000' }, + introduction: { show: true, color: '#999' }, + price: { show: true, color: '#ff3000' }, + marketPrice: { show: true, color: '#c4c4c4' }, + salesCount: { show: true, color: '#c4c4c4' }, + stock: { show: false, color: '#c4c4c4' } + }, + badge: { show: false, imgUrl: '' }, + btnBuy: { + type: 'text', + text: '立即购买', + // todo: @owen 根据主题色配置 + bgBeginColor: '#FF6000', + bgEndColor: '#FE832A', + imgUrl: '' + }, + borderRadiusTop: 8, + borderRadiusBottom: 8, + space: 8, + spuIds: [], + style: { + bgType: 'color', + bgColor: '', + marginLeft: 8, + marginRight: 8, + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<ProductCardProperty> diff --git a/src/components/DiyEditor/components/mobile/ProductCard/index.vue b/src/components/DiyEditor/components/mobile/ProductCard/index.vue new file mode 100644 index 0000000..f05d4fa --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ProductCard/index.vue @@ -0,0 +1,166 @@ +<template> + <div :class="`box-content min-h-30px w-full flex flex-row flex-wrap`" ref="containerRef"> + <div + class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white" + :style="{ + ...calculateSpace(index), + ...calculateWidth(), + borderTopLeftRadius: `${property.borderRadiusTop}px`, + borderTopRightRadius: `${property.borderRadiusTop}px`, + borderBottomLeftRadius: `${property.borderRadiusBottom}px`, + borderBottomRightRadius: `${property.borderRadiusBottom}px` + }" + v-for="(spu, index) in spuList" + :key="index" + > + <!-- 角标 --> + <div v-if="property.badge.show" class="absolute left-0 top-0 z-1 items-center justify-center"> + <el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" /> + </div> + <!-- 商品封面图 --> + <div + :class="[ + 'h-140px', + { + 'w-full': property.layoutType !== 'oneColSmallImg', + 'w-140px': property.layoutType === 'oneColSmallImg' + } + ]" + > + <el-image fit="cover" class="h-full w-full" :src="spu.picUrl" /> + </div> + <div + :class="[ + ' flex flex-col gap-8px p-8px box-border', + { + 'w-full': property.layoutType !== 'oneColSmallImg', + 'w-[calc(100%-140px-16px)]': property.layoutType === 'oneColSmallImg' + } + ]" + > + <!-- 商品名称 --> + <div + v-if="property.fields.name.show" + :class="[ + 'text-14px ', + { + truncate: property.layoutType !== 'oneColSmallImg', + 'overflow-ellipsis line-clamp-2': property.layoutType === 'oneColSmallImg' + } + ]" + :style="{ color: property.fields.name.color }" + > + {{ spu.name }} + </div> + <!-- 商品简介 --> + <div + v-if="property.fields.introduction.show" + class="truncate text-12px" + :style="{ color: property.fields.introduction.color }" + > + {{ spu.introduction }} + </div> + <div> + <!-- 价格 --> + <span + v-if="property.fields.price.show" + class="text-16px" + :style="{ color: property.fields.price.color }" + > + ¥{{ spu.price }} + </span> + <!-- 市场价 --> + <span + v-if="property.fields.marketPrice.show && spu.marketPrice" + class="ml-4px text-10px line-through" + :style="{ color: property.fields.marketPrice.color }" + >¥{{ spu.marketPrice }}</span + > + </div> + <div class="text-12px"> + <!-- 销量 --> + <span + v-if="property.fields.salesCount.show" + :style="{ color: property.fields.salesCount.color }" + > + 已售{{ (spu.salesCount || 0) + (spu.virtualSalesCount || 0) }}件 + </span> + <!-- 库存 --> + <span v-if="property.fields.stock.show" :style="{ color: property.fields.stock.color }"> + 库存{{ spu.stock || 0 }} + </span> + </div> + </div> + <!-- 购买按钮 --> + <div class="absolute bottom-8px right-8px"> + <!-- 文字按钮 --> + <span + v-if="property.btnBuy.type === 'text'" + class="rounded-full p-x-12px p-y-4px text-12px text-white" + :style="{ + background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}` + }" + > + {{ property.btnBuy.text }} + </span> + <!-- 图片按钮 --> + <el-image + v-else + class="h-28px w-28px rounded-full" + fit="cover" + :src="property.btnBuy.imgUrl" + /> + </div> + </div> + </div> +</template> +<script setup lang="ts"> +import { ProductCardProperty } from './config' +import * as ProductSpuApi from '@/api/mall/product/spu' + +/** 商品卡片 */ +defineOptions({ name: 'ProductCard' }) +// 定义属性 +const props = defineProps<{ property: ProductCardProperty }>() +// 商品列表 +const spuList = ref<ProductSpuApi.Spu[]>([]) +watch( + () => props.property.spuIds, + async () => { + spuList.value = await ProductSpuApi.getSpuDetailList(props.property.spuIds) + }, + { + immediate: true, + deep: true + } +) + +/** + * 计算商品的间距 + * @param index 商品索引 + */ +const calculateSpace = (index: number) => { + // 商品的列数 + const columns = props.property.layoutType === 'twoCol' ? 2 : 1 + // 第一列没有左边距 + const marginLeft = index % columns === 0 ? '0' : props.property.space + 'px' + // 第一行没有上边距 + const marginTop = index < columns ? '0' : props.property.space + 'px' + + return { marginLeft, marginTop } +} + +// 容器 +const containerRef = ref() +// 计算商品的宽度 +const calculateWidth = () => { + let width = '100%' + // 双列时每列的宽度为:(总宽度 - 间距)/ 2 + if (props.property.layoutType === 'twoCol') { + width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px` + } + return { width } +} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/ProductCard/property.vue b/src/components/DiyEditor/components/mobile/ProductCard/property.vue new file mode 100644 index 0000000..cfa5008 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ProductCard/property.vue @@ -0,0 +1,149 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="80px" :model="formData"> + <el-card header="商品列表" class="property-group" shadow="never"> + <SpuShowcase v-model="formData.spuIds" /> + </el-card> + <el-card header="商品样式" class="property-group" shadow="never"> + <el-form-item label="布局" prop="type"> + <el-radio-group v-model="formData.layoutType"> + <el-tooltip class="item" content="单列大图" placement="bottom"> + <el-radio-button label="oneColBigImg"> + <Icon icon="fluent:text-column-one-24-filled" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="单列小图" placement="bottom"> + <el-radio-button label="oneColSmallImg"> + <Icon icon="fluent:text-column-two-left-24-filled" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="双列" placement="bottom"> + <el-radio-button label="twoCol"> + <Icon icon="fluent:text-column-two-24-filled" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="商品名称" prop="fields.name.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.name.color" /> + <el-checkbox v-model="formData.fields.name.show" /> + </div> + </el-form-item> + <el-form-item label="商品简介" prop="fields.introduction.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.introduction.color" /> + <el-checkbox v-model="formData.fields.introduction.show" /> + </div> + </el-form-item> + <el-form-item label="商品价格" prop="fields.price.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.price.color" /> + <el-checkbox v-model="formData.fields.price.show" /> + </div> + </el-form-item> + <el-form-item label="市场价" prop="fields.marketPrice.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.marketPrice.color" /> + <el-checkbox v-model="formData.fields.marketPrice.show" /> + </div> + </el-form-item> + <el-form-item label="商品销量" prop="fields.salesCount.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.salesCount.color" /> + <el-checkbox v-model="formData.fields.salesCount.show" /> + </div> + </el-form-item> + <el-form-item label="商品库存" prop="fields.stock.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.stock.color" /> + <el-checkbox v-model="formData.fields.stock.show" /> + </div> + </el-form-item> + </el-card> + <el-card header="角标" class="property-group" shadow="never"> + <el-form-item label="角标" prop="badge.show"> + <el-switch v-model="formData.badge.show" /> + </el-form-item> + <el-form-item label="角标" prop="badge.imgUrl" v-if="formData.badge.show"> + <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px"> + <template #tip> 建议尺寸:36 * 22 </template> + </UploadImg> + </el-form-item> + </el-card> + <el-card header="按钮" class="property-group" shadow="never"> + <el-form-item label="按钮类型" prop="btnBuy.type"> + <el-radio-group v-model="formData.btnBuy.type"> + <el-radio-button label="text">文字</el-radio-button> + <el-radio-button label="img">图片</el-radio-button> + </el-radio-group> + </el-form-item> + <template v-if="formData.btnBuy.type === 'text'"> + <el-form-item label="按钮文字" prop="btnBuy.text"> + <el-input v-model="formData.btnBuy.text" /> + </el-form-item> + <el-form-item label="左侧背景" prop="btnBuy.bgBeginColor"> + <ColorInput v-model="formData.btnBuy.bgBeginColor" /> + </el-form-item> + <el-form-item label="右侧背景" prop="btnBuy.bgEndColor"> + <ColorInput v-model="formData.btnBuy.bgEndColor" /> + </el-form-item> + </template> + <template v-else> + <el-form-item label="图片" prop="btnBuy.imgUrl"> + <UploadImg v-model="formData.btnBuy.imgUrl" height="56px" width="56px"> + <template #tip> 建议尺寸:56 * 56 </template> + </UploadImg> + </el-form-item> + </template> + </el-card> + <el-card header="商品样式" class="property-group" shadow="never"> + <el-form-item label="上圆角" prop="borderRadiusTop"> + <el-slider + v-model="formData.borderRadiusTop" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="下圆角" prop="borderRadiusBottom"> + <el-slider + v-model="formData.borderRadiusBottom" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="间隔" prop="space"> + <el-slider + v-model="formData.space" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { ProductCardProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue' + +// 商品卡片属性面板 +defineOptions({ name: 'ProductCardProperty' }) + +const props = defineProps<{ modelValue: ProductCardProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/ProductList/config.ts b/src/components/DiyEditor/components/mobile/ProductList/config.ts new file mode 100644 index 0000000..1f16832 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ProductList/config.ts @@ -0,0 +1,64 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 商品栏属性 */ +export interface ProductListProperty { + // 布局类型:双列 | 三列 | 水平滑动 + layoutType: 'twoCol' | 'threeCol' | 'horizSwiper' + // 商品字段 + fields: { + // 商品名称 + name: ProductListFieldProperty + // 商品价格 + price: ProductListFieldProperty + } + // 角标 + badge: { + // 是否显示 + show: boolean + // 角标图片 + imgUrl: string + } + // 上圆角 + borderRadiusTop: number + // 下圆角 + borderRadiusBottom: number + // 间距 + space: number + // 商品编号列表 + spuIds: number[] + // 组件样式 + style: ComponentStyle +} +// 商品字段 +export interface ProductListFieldProperty { + // 是否显示 + show: boolean + // 颜色 + color: string +} + +// 定义组件 +export const component = { + id: 'ProductList', + name: '商品栏', + icon: 'fluent:text-column-two-24-filled', + property: { + layoutType: 'twoCol', + fields: { + name: { show: true, color: '#000' }, + price: { show: true, color: '#ff3000' } + }, + badge: { show: false, imgUrl: '' }, + borderRadiusTop: 8, + borderRadiusBottom: 8, + space: 8, + spuIds: [], + style: { + bgType: 'color', + bgColor: '', + marginLeft: 8, + marginRight: 8, + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<ProductListProperty> diff --git a/src/components/DiyEditor/components/mobile/ProductList/index.vue b/src/components/DiyEditor/components/mobile/ProductList/index.vue new file mode 100644 index 0000000..3ba6367 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ProductList/index.vue @@ -0,0 +1,131 @@ +<template> + <el-scrollbar class="z-1 min-h-30px" wrap-class="w-full" ref="containerRef"> + <!-- 商品网格 --> + <div + class="grid overflow-x-auto" + :style="{ + gridGap: `${property.space}px`, + gridTemplateColumns, + width: scrollbarWidth + }" + > + <!-- 商品 --> + <div + class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white" + :style="{ + borderTopLeftRadius: `${property.borderRadiusTop}px`, + borderTopRightRadius: `${property.borderRadiusTop}px`, + borderBottomLeftRadius: `${property.borderRadiusBottom}px`, + borderBottomRightRadius: `${property.borderRadiusBottom}px` + }" + v-for="(spu, index) in spuList" + :key="index" + > + <!-- 角标 --> + <div + v-if="property.badge.show" + class="absolute left-0 top-0 z-1 items-center justify-center" + > + <el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" /> + </div> + <!-- 商品封面图 --> + <el-image fit="cover" :src="spu.picUrl" :style="{ width: imageSize, height: imageSize }" /> + <div + :class="[ + 'flex flex-col gap-8px p-8px box-border', + { + 'w-[calc(100%-64px)]': columns === 2, + 'w-full': columns === 3 + } + ]" + > + <!-- 商品名称 --> + <div + v-if="property.fields.name.show" + class="truncate text-12px" + :style="{ color: property.fields.name.color }" + > + {{ spu.name }} + </div> + <div> + <!-- 商品价格 --> + <span + v-if="property.fields.price.show" + class="text-12px" + :style="{ color: property.fields.price.color }" + > + ¥{{ spu.price }} + </span> + </div> + </div> + </div> + </div> + </el-scrollbar> +</template> +<script setup lang="ts"> +import { ProductListProperty } from './config' +import * as ProductSpuApi from '@/api/mall/product/spu' + +/** 商品栏 */ +defineOptions({ name: 'ProductList' }) +// 定义属性 +const props = defineProps<{ property: ProductListProperty }>() +// 商品列表 +const spuList = ref<ProductSpuApi.Spu[]>([]) +watch( + () => props.property.spuIds, + async () => { + spuList.value = await ProductSpuApi.getSpuDetailList(props.property.spuIds) + }, + { + immediate: true, + deep: true + } +) +// 手机宽度 +const phoneWidth = ref(375) +// 容器 +const containerRef = ref() +// 商品的列数 +const columns = ref(2) +// 滚动条宽度 +const scrollbarWidth = ref('100%') +// 商品图大小 +const imageSize = ref('0') +// 商品网络列数 +const gridTemplateColumns = ref('') +// 计算布局参数 +watch( + () => [props.property, phoneWidth, spuList.value.length], + () => { + // 计算列数 + columns.value = props.property.layoutType === 'twoCol' ? 2 : 3 + // 每列的宽度为:(总宽度 - 间距 * (列数 - 1))/ 列数 + const productWidth = + (phoneWidth.value - props.property.space * (columns.value - 1)) / columns.value + // 商品图布局:2列时,左右布局 3列时,上下布局 + imageSize.value = columns.value === 2 ? '64px' : `${productWidth}px` + // 根据布局类型,计算行数、列数 + if (props.property.layoutType === 'horizSwiper') { + // 单行显示 + gridTemplateColumns.value = `repeat(auto-fill, ${productWidth}px)` + // 显示滚动条 + scrollbarWidth.value = `${ + productWidth * spuList.value.length + props.property.space * (spuList.value.length - 1) + }px` + } else { + // 指定列数 + gridTemplateColumns.value = `repeat(${columns.value}, auto)` + // 不滚动 + scrollbarWidth.value = '100%' + } + }, + { immediate: true, deep: true } +) +onMounted(() => { + // 提取手机宽度 + phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375 +}) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/ProductList/property.vue b/src/components/DiyEditor/components/mobile/ProductList/property.vue new file mode 100644 index 0000000..e9cf7c0 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/ProductList/property.vue @@ -0,0 +1,99 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="80px" :model="formData"> + <el-card header="商品列表" class="property-group" shadow="never"> + <SpuShowcase v-model="formData.spuIds" /> + </el-card> + <el-card header="商品样式" class="property-group" shadow="never"> + <el-form-item label="布局" prop="type"> + <el-radio-group v-model="formData.layoutType"> + <el-tooltip class="item" content="双列" placement="bottom"> + <el-radio-button label="twoCol"> + <Icon icon="fluent:text-column-two-24-filled" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="三列" placement="bottom"> + <el-radio-button label="threeCol"> + <Icon icon="fluent:text-column-three-24-filled" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="水平滑动" placement="bottom"> + <el-radio-button label="horizSwiper"> + <Icon icon="system-uicons:carousel" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="商品名称" prop="fields.name.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.name.color" /> + <el-checkbox v-model="formData.fields.name.show" /> + </div> + </el-form-item> + <el-form-item label="商品价格" prop="fields.price.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.price.color" /> + <el-checkbox v-model="formData.fields.price.show" /> + </div> + </el-form-item> + </el-card> + <el-card header="角标" class="property-group" shadow="never"> + <el-form-item label="角标" prop="badge.show"> + <el-switch v-model="formData.badge.show" /> + </el-form-item> + <el-form-item label="角标" prop="badge.imgUrl" v-if="formData.badge.show"> + <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px"> + <template #tip> 建议尺寸:36 * 22 </template> + </UploadImg> + </el-form-item> + </el-card> + <el-card header="商品样式" class="property-group" shadow="never"> + <el-form-item label="上圆角" prop="borderRadiusTop"> + <el-slider + v-model="formData.borderRadiusTop" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="下圆角" prop="borderRadiusBottom"> + <el-slider + v-model="formData.borderRadiusBottom" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="间隔" prop="space"> + <el-slider + v-model="formData.space" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { ProductListProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue' + +// 商品栏属性面板 +defineOptions({ name: 'ProductListProperty' }) + +const props = defineProps<{ modelValue: ProductListProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/PromotionArticle/config.ts b/src/components/DiyEditor/components/mobile/PromotionArticle/config.ts new file mode 100644 index 0000000..c6270c2 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PromotionArticle/config.ts @@ -0,0 +1,25 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 营销文章属性 */ +export interface PromotionArticleProperty { + // 文章编号 + id: number + // 组件样式 + style: ComponentStyle +} + +// 定义组件 +export const component = { + id: 'PromotionArticle', + name: '营销文章', + icon: 'ph:article-medium', + property: { + style: { + bgType: 'color', + bgColor: '', + marginLeft: 8, + marginRight: 8, + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<PromotionArticleProperty> diff --git a/src/components/DiyEditor/components/mobile/PromotionArticle/index.vue b/src/components/DiyEditor/components/mobile/PromotionArticle/index.vue new file mode 100644 index 0000000..e003b08 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PromotionArticle/index.vue @@ -0,0 +1,27 @@ +<template> + <div class="min-h-30px" v-html="article?.content"></div> +</template> +<script setup lang="ts"> +import { PromotionArticleProperty } from './config' +import * as ArticleApi from '@/api/mall/promotion/article/index' + +/** 营销文章 */ +defineOptions({ name: 'PromotionArticle' }) +// 定义属性 +const props = defineProps<{ property: PromotionArticleProperty }>() +// 商品列表 +const article = ref<ArticleApi.ArticleVO>() +watch( + () => props.property.id, + async () => { + if (props.property.id) { + article.value = await ArticleApi.getArticle(props.property.id) + } + }, + { + immediate: true + } +) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/PromotionArticle/property.vue b/src/components/DiyEditor/components/mobile/PromotionArticle/property.vue new file mode 100644 index 0000000..c3bcb21 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PromotionArticle/property.vue @@ -0,0 +1,56 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="40px" :model="formData"> + <el-form-item label="文章" prop="id"> + <el-select + v-model="formData.id" + placeholder="请选择文章" + class="w-full" + filterable + remote + :remote-method="queryArticleList" + :loading="loading" + > + <el-option + v-for="article in articles" + :key="article.id" + :label="article.title" + :value="article.id" + /> + </el-select> + </el-form-item> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { PromotionArticleProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +import * as ArticleApi from '@/api/mall/promotion/article/index' + +// 营销文章属性面板 +defineOptions({ name: 'PromotionArticleProperty' }) + +const props = defineProps<{ modelValue: PromotionArticleProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +// 文章列表 +const articles = ref<ArticleApi.ArticleVO>([]) + +// 加载中 +const loading = ref(false) +// 查询文章列表 +const queryArticleList = async (title?: string) => { + loading.value = true + const { list } = await ArticleApi.getArticlePage({ title, pageSize: 10 }) + articles.value = list + loading.value = false +} + +// 初始化 +onMounted(() => { + queryArticleList() +}) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/PromotionCombination/config.ts b/src/components/DiyEditor/components/mobile/PromotionCombination/config.ts new file mode 100644 index 0000000..0c7e9ff --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PromotionCombination/config.ts @@ -0,0 +1,64 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 拼团属性 */ +export interface PromotionCombinationProperty { + // 布局类型:单列 | 三列 + layoutType: 'oneCol' | 'threeCol' + // 商品字段 + fields: { + // 商品名称 + name: PromotionCombinationFieldProperty + // 商品价格 + price: PromotionCombinationFieldProperty + } + // 角标 + badge: { + // 是否显示 + show: boolean + // 角标图片 + imgUrl: string + } + // 上圆角 + borderRadiusTop: number + // 下圆角 + borderRadiusBottom: number + // 间距 + space: number + // 拼团活动编号 + activityId: number + // 组件样式 + style: ComponentStyle +} + +// 商品字段 +export interface PromotionCombinationFieldProperty { + // 是否显示 + show: boolean + // 颜色 + color: string +} + +// 定义组件 +export const component = { + id: 'PromotionCombination', + name: '拼团', + icon: 'mdi:account-group', + property: { + layoutType: 'oneCol', + fields: { + name: { show: true, color: '#000' }, + price: { show: true, color: '#ff3000' } + }, + badge: { show: false, imgUrl: '' }, + borderRadiusTop: 8, + borderRadiusBottom: 8, + space: 8, + style: { + bgType: 'color', + bgColor: '', + marginLeft: 8, + marginRight: 8, + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<PromotionCombinationProperty> diff --git a/src/components/DiyEditor/components/mobile/PromotionCombination/index.vue b/src/components/DiyEditor/components/mobile/PromotionCombination/index.vue new file mode 100644 index 0000000..fe6f3a8 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PromotionCombination/index.vue @@ -0,0 +1,125 @@ +<template> + <el-scrollbar class="z-1 min-h-30px" wrap-class="w-full" ref="containerRef"> + <!-- 商品网格 --> + <div + class="grid overflow-x-auto" + :style="{ + gridGap: `${property.space}px`, + gridTemplateColumns, + width: scrollbarWidth + }" + > + <!-- 商品 --> + <div + class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white" + :style="{ + borderTopLeftRadius: `${property.borderRadiusTop}px`, + borderTopRightRadius: `${property.borderRadiusTop}px`, + borderBottomLeftRadius: `${property.borderRadiusBottom}px`, + borderBottomRightRadius: `${property.borderRadiusBottom}px` + }" + v-for="(spu, index) in spuList" + :key="index" + > + <!-- 角标 --> + <div + v-if="property.badge.show" + class="absolute left-0 top-0 z-1 items-center justify-center" + > + <el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" /> + </div> + <!-- 商品封面图 --> + <el-image fit="cover" :src="spu.picUrl" :style="{ width: imageSize, height: imageSize }" /> + <div + :class="[ + 'flex flex-col gap-8px p-8px box-border', + { + 'w-[calc(100%-64px)]': columns === 2, + 'w-full': columns === 3 + } + ]" + > + <!-- 商品名称 --> + <div + v-if="property.fields.name.show" + class="truncate text-12px" + :style="{ color: property.fields.name.color }" + > + {{ spu.name }} + </div> + <div> + <!-- 商品价格 --> + <span + v-if="property.fields.price.show" + class="text-12px" + :style="{ color: property.fields.price.color }" + > + ¥{{ spu.price }} + </span> + </div> + </div> + </div> + </div> + </el-scrollbar> +</template> +<script setup lang="ts"> +import { PromotionCombinationProperty } from './config' +import * as ProductSpuApi from '@/api/mall/product/spu' +import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity' + +/** 拼团 */ +defineOptions({ name: 'PromotionCombination' }) +// 定义属性 +const props = defineProps<{ property: PromotionCombinationProperty }>() +// 商品列表 +const spuList = ref<ProductSpuApi.Spu[]>([]) +watch( + () => props.property.activityId, + async () => { + if (!props.property.activityId) return + const activity = await CombinationActivityApi.getCombinationActivity(props.property.activityId) + if (!activity?.spuId) return + spuList.value = [await ProductSpuApi.getSpu(activity.spuId)] + }, + { + immediate: true, + deep: true + } +) +// 手机宽度 +const phoneWidth = ref(375) +// 容器 +const containerRef = ref() +// 商品的列数 +const columns = ref(2) +// 滚动条宽度 +const scrollbarWidth = ref('100%') +// 商品图大小 +const imageSize = ref('0') +// 商品网络列数 +const gridTemplateColumns = ref('') +// 计算布局参数 +watch( + () => [props.property, phoneWidth, spuList.value.length], + () => { + // 计算列数 + columns.value = props.property.layoutType === 'oneCol' ? 1 : 3 + // 每列的宽度为:(总宽度 - 间距 * (列数 - 1))/ 列数 + const productWidth = + (phoneWidth.value - props.property.space * (columns.value - 1)) / columns.value + // 商品图布局:2列时,左右布局 3列时,上下布局 + imageSize.value = columns.value === 2 ? '64px' : `${productWidth}px` + // 指定列数 + gridTemplateColumns.value = `repeat(${columns.value}, auto)` + // 不滚动 + scrollbarWidth.value = '100%' + }, + { immediate: true, deep: true } +) +onMounted(() => { + // 提取手机宽度 + phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375 +}) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/PromotionCombination/property.vue b/src/components/DiyEditor/components/mobile/PromotionCombination/property.vue new file mode 100644 index 0000000..ec09dc4 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PromotionCombination/property.vue @@ -0,0 +1,112 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="80px" :model="formData"> + <el-card header="拼团活动" class="property-group" shadow="never"> + <el-form-item label="拼团活动" prop="activityId"> + <el-select v-model="formData.activityId"> + <el-option + v-for="activity in activityList" + :key="activity.id" + :label="activity.name" + :value="activity.id" + /> + </el-select> + </el-form-item> + </el-card> + <el-card header="商品样式" class="property-group" shadow="never"> + <el-form-item label="布局" prop="type"> + <el-radio-group v-model="formData.layoutType"> + <el-tooltip class="item" content="单列" placement="bottom"> + <el-radio-button label="oneCol"> + <Icon icon="fluent:text-column-one-24-filled" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="三列" placement="bottom"> + <el-radio-button label="threeCol"> + <Icon icon="fluent:text-column-three-24-filled" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="商品名称" prop="fields.name.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.name.color" /> + <el-checkbox v-model="formData.fields.name.show" /> + </div> + </el-form-item> + <el-form-item label="商品价格" prop="fields.price.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.price.color" /> + <el-checkbox v-model="formData.fields.price.show" /> + </div> + </el-form-item> + </el-card> + <el-card header="角标" class="property-group" shadow="never"> + <el-form-item label="角标" prop="badge.show"> + <el-switch v-model="formData.badge.show" /> + </el-form-item> + <el-form-item label="角标" prop="badge.imgUrl" v-if="formData.badge.show"> + <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px"> + <template #tip> 建议尺寸:36 * 22 </template> + </UploadImg> + </el-form-item> + </el-card> + <el-card header="商品样式" class="property-group" shadow="never"> + <el-form-item label="上圆角" prop="borderRadiusTop"> + <el-slider + v-model="formData.borderRadiusTop" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="下圆角" prop="borderRadiusBottom"> + <el-slider + v-model="formData.borderRadiusBottom" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="间隔" prop="space"> + <el-slider + v-model="formData.space" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { PromotionCombinationProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity' +import { CommonStatusEnum } from '@/utils/constants' + +// 拼团属性面板 +defineOptions({ name: 'PromotionCombinationProperty' }) + +const props = defineProps<{ modelValue: PromotionCombinationProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +// 活动列表 +const activityList = ref<CombinationActivityApi.CombinationActivityVO>([]) +onMounted(async () => { + const { list } = await CombinationActivityApi.getCombinationActivityPage({ + status: CommonStatusEnum.ENABLE + }) + activityList.value = list +}) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts b/src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts new file mode 100644 index 0000000..800398b --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PromotionSeckill/config.ts @@ -0,0 +1,64 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 秒杀属性 */ +export interface PromotionSeckillProperty { + // 布局类型:单列 | 三列 + layoutType: 'oneCol' | 'threeCol' + // 商品字段 + fields: { + // 商品名称 + name: PromotionSeckillFieldProperty + // 商品价格 + price: PromotionSeckillFieldProperty + } + // 角标 + badge: { + // 是否显示 + show: boolean + // 角标图片 + imgUrl: string + } + // 上圆角 + borderRadiusTop: number + // 下圆角 + borderRadiusBottom: number + // 间距 + space: number + // 秒杀活动编号 + activityId: number + // 组件样式 + style: ComponentStyle +} +// 商品字段 +export interface PromotionSeckillFieldProperty { + // 是否显示 + show: boolean + // 颜色 + color: string +} + +// 定义组件 +export const component = { + id: 'PromotionSeckill', + name: '秒杀', + icon: 'mdi:calendar-time', + property: { + activityId: undefined, + layoutType: 'oneCol', + fields: { + name: { show: true, color: '#000' }, + price: { show: true, color: '#ff3000' } + }, + badge: { show: false, imgUrl: '' }, + borderRadiusTop: 8, + borderRadiusBottom: 8, + space: 8, + style: { + bgType: 'color', + bgColor: '', + marginLeft: 8, + marginRight: 8, + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<PromotionSeckillProperty> diff --git a/src/components/DiyEditor/components/mobile/PromotionSeckill/index.vue b/src/components/DiyEditor/components/mobile/PromotionSeckill/index.vue new file mode 100644 index 0000000..1b4113b --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PromotionSeckill/index.vue @@ -0,0 +1,125 @@ +<template> + <el-scrollbar class="z-1 min-h-30px" wrap-class="w-full" ref="containerRef"> + <!-- 商品网格 --> + <div + class="grid overflow-x-auto" + :style="{ + gridGap: `${property.space}px`, + gridTemplateColumns, + width: scrollbarWidth + }" + > + <!-- 商品 --> + <div + class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white" + :style="{ + borderTopLeftRadius: `${property.borderRadiusTop}px`, + borderTopRightRadius: `${property.borderRadiusTop}px`, + borderBottomLeftRadius: `${property.borderRadiusBottom}px`, + borderBottomRightRadius: `${property.borderRadiusBottom}px` + }" + v-for="(spu, index) in spuList" + :key="index" + > + <!-- 角标 --> + <div + v-if="property.badge.show" + class="absolute left-0 top-0 z-1 items-center justify-center" + > + <el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" /> + </div> + <!-- 商品封面图 --> + <el-image fit="cover" :src="spu.picUrl" :style="{ width: imageSize, height: imageSize }" /> + <div + :class="[ + 'flex flex-col gap-8px p-8px box-border', + { + 'w-[calc(100%-64px)]': columns === 2, + 'w-full': columns === 3 + } + ]" + > + <!-- 商品名称 --> + <div + v-if="property.fields.name.show" + class="truncate text-12px" + :style="{ color: property.fields.name.color }" + > + {{ spu.name }} + </div> + <div> + <!-- 商品价格 --> + <span + v-if="property.fields.price.show" + class="text-12px" + :style="{ color: property.fields.price.color }" + > + ¥{{ spu.price }} + </span> + </div> + </div> + </div> + </div> + </el-scrollbar> +</template> +<script setup lang="ts"> +import { PromotionSeckillProperty } from './config' +import * as ProductSpuApi from '@/api/mall/product/spu' +import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity' + +/** 秒杀 */ +defineOptions({ name: 'PromotionSeckill' }) +// 定义属性 +const props = defineProps<{ property: PromotionSeckillProperty }>() +// 商品列表 +const spuList = ref<ProductSpuApi.Spu[]>([]) +watch( + () => props.property.activityId, + async () => { + if (!props.property.activityId) return + const activity = await SeckillActivityApi.getSeckillActivity(props.property.activityId) + if (!activity?.spuId) return + spuList.value = [await ProductSpuApi.getSpu(activity.spuId)] + }, + { + immediate: true, + deep: true + } +) +// 手机宽度 +const phoneWidth = ref(375) +// 容器 +const containerRef = ref() +// 商品的列数 +const columns = ref(2) +// 滚动条宽度 +const scrollbarWidth = ref('100%') +// 商品图大小 +const imageSize = ref('0') +// 商品网络列数 +const gridTemplateColumns = ref('') +// 计算布局参数 +watch( + () => [props.property, phoneWidth, spuList.value.length], + () => { + // 计算列数 + columns.value = props.property.layoutType === 'oneCol' ? 1 : 3 + // 每列的宽度为:(总宽度 - 间距 * (列数 - 1))/ 列数 + const productWidth = + (phoneWidth.value - props.property.space * (columns.value - 1)) / columns.value + // 商品图布局:2列时,左右布局 3列时,上下布局 + imageSize.value = columns.value === 2 ? '64px' : `${productWidth}px` + // 指定列数 + gridTemplateColumns.value = `repeat(${columns.value}, auto)` + // 不滚动 + scrollbarWidth.value = '100%' + }, + { immediate: true, deep: true } +) +onMounted(() => { + // 提取手机宽度 + phoneWidth.value = containerRef.value?.wrapRef?.offsetWidth || 375 +}) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/PromotionSeckill/property.vue b/src/components/DiyEditor/components/mobile/PromotionSeckill/property.vue new file mode 100644 index 0000000..8753782 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/PromotionSeckill/property.vue @@ -0,0 +1,112 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="80px" :model="formData"> + <el-card header="秒杀活动" class="property-group" shadow="never"> + <el-form-item label="秒杀活动" prop="activityId"> + <el-select v-model="formData.activityId"> + <el-option + v-for="activity in activityList" + :key="activity.id" + :label="activity.name" + :value="activity.id" + /> + </el-select> + </el-form-item> + </el-card> + <el-card header="商品样式" class="property-group" shadow="never"> + <el-form-item label="布局" prop="type"> + <el-radio-group v-model="formData.layoutType"> + <el-tooltip class="item" content="单列" placement="bottom"> + <el-radio-button label="oneCol"> + <Icon icon="fluent:text-column-one-24-filled" /> + </el-radio-button> + </el-tooltip> + <el-tooltip class="item" content="三列" placement="bottom"> + <el-radio-button label="threeCol"> + <Icon icon="fluent:text-column-three-24-filled" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="商品名称" prop="fields.name.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.name.color" /> + <el-checkbox v-model="formData.fields.name.show" /> + </div> + </el-form-item> + <el-form-item label="商品价格" prop="fields.price.show"> + <div class="flex gap-8px"> + <ColorInput v-model="formData.fields.price.color" /> + <el-checkbox v-model="formData.fields.price.show" /> + </div> + </el-form-item> + </el-card> + <el-card header="角标" class="property-group" shadow="never"> + <el-form-item label="角标" prop="badge.show"> + <el-switch v-model="formData.badge.show" /> + </el-form-item> + <el-form-item label="角标" prop="badge.imgUrl" v-if="formData.badge.show"> + <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px"> + <template #tip> 建议尺寸:36 * 22 </template> + </UploadImg> + </el-form-item> + </el-card> + <el-card header="商品样式" class="property-group" shadow="never"> + <el-form-item label="上圆角" prop="borderRadiusTop"> + <el-slider + v-model="formData.borderRadiusTop" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="下圆角" prop="borderRadiusBottom"> + <el-slider + v-model="formData.borderRadiusBottom" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + <el-form-item label="间隔" prop="space"> + <el-slider + v-model="formData.space" + :max="100" + :min="0" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { PromotionSeckillProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity' +import { CommonStatusEnum } from '@/utils/constants' + +// 秒杀属性面板 +defineOptions({ name: 'PromotionSeckillProperty' }) + +const props = defineProps<{ modelValue: PromotionSeckillProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +// 活动列表 +const activityList = ref<SeckillActivityApi.SeckillActivityVO>([]) +onMounted(async () => { + const { list } = await SeckillActivityApi.getSeckillActivityPage({ + status: CommonStatusEnum.ENABLE + }) + activityList.value = list +}) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/SearchBar/config.ts b/src/components/DiyEditor/components/mobile/SearchBar/config.ts new file mode 100644 index 0000000..ef47b27 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/SearchBar/config.ts @@ -0,0 +1,43 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 搜索框属性 */ +export interface SearchProperty { + height: number // 搜索栏高度 + showScan: boolean // 显示扫一扫 + borderRadius: number // 框体样式 + placeholder: string // 占位文字 + placeholderPosition: PlaceholderPosition // 占位文字位置 + backgroundColor: string // 框体颜色 + textColor: string // 字体颜色 + hotKeywords: string[] // 热词 + style: ComponentStyle +} + +// 文字位置 +export type PlaceholderPosition = 'left' | 'center' + +// 定义组件 +export const component = { + id: 'SearchBar', + name: '搜索框', + icon: 'ep:search', + property: { + height: 28, + showScan: false, + borderRadius: 0, + placeholder: '搜索商品', + placeholderPosition: 'left', + backgroundColor: 'rgb(238, 238, 238)', + textColor: 'rgb(150, 151, 153)', + hotKeywords: [], + style: { + bgType: 'color', + bgColor: '#fff', + marginBottom: 8, + paddingTop: 8, + paddingRight: 8, + paddingBottom: 8, + paddingLeft: 8 + } as ComponentStyle + } +} as DiyComponent<SearchProperty> diff --git a/src/components/DiyEditor/components/mobile/SearchBar/index.vue b/src/components/DiyEditor/components/mobile/SearchBar/index.vue new file mode 100644 index 0000000..9de261a --- /dev/null +++ b/src/components/DiyEditor/components/mobile/SearchBar/index.vue @@ -0,0 +1,75 @@ +<template> + <div + class="search-bar" + :style="{ + color: property.textColor + }" + > + <!-- 搜索框 --> + <div + class="inner" + :style="{ + height: `${property.height}px`, + background: property.backgroundColor, + borderRadius: `${property.borderRadius}px` + }" + > + <div + class="placeholder" + :style="{ + justifyContent: property.placeholderPosition + }" + > + <Icon icon="ep:search" /> + <span>{{ property.placeholder || '搜索商品' }}</span> + </div> + <div class="right"> + <!-- 搜索热词 --> + <span v-for="(keyword, index) in property.hotKeywords" :key="index">{{ keyword }}</span> + <!-- 扫一扫 --> + <Icon icon="ant-design:scan-outlined" v-show="property.showScan" /> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { SearchProperty } from './config' +/** 搜索框 */ +defineOptions({ name: 'SearchBar' }) +defineProps<{ property: SearchProperty }>() +</script> + +<style scoped lang="scss"> +.search-bar { + /* 搜索框 */ + .inner { + position: relative; + display: flex; + min-height: 28px; + font-size: 14px; + align-items: center; + + .placeholder { + display: flex; + width: 100%; + padding: 0 8px; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + white-space: nowrap; + align-items: center; + gap: 2px; + } + + .right { + position: absolute; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/SearchBar/property.vue b/src/components/DiyEditor/components/mobile/SearchBar/property.vue new file mode 100644 index 0000000..9002702 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/SearchBar/property.vue @@ -0,0 +1,73 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <!-- 表单 --> + <el-form label-width="80px" :model="formData" class="m-t-8px"> + <el-card header="搜索热词" class="property-group" shadow="never"> + <Draggable v-model="formData.hotKeywords" :empty-item="''"> + <template #default="{ index }"> + <el-input v-model="formData.hotKeywords[index]" placeholder="请输入热词" /> + </template> + </Draggable> + </el-card> + <el-card header="搜索样式" class="property-group" shadow="never"> + <el-form-item label="框体样式"> + <el-radio-group v-model="formData!.borderRadius"> + <el-tooltip content="方形" placement="top"> + <el-radio-button :label="0"> + <Icon icon="tabler:input-search" /> + </el-radio-button> + </el-tooltip> + <el-tooltip content="圆形" placement="top"> + <el-radio-button :label="10"> + <Icon icon="iconoir:input-search" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="提示文字" prop="placeholder"> + <el-input v-model="formData.placeholder" /> + </el-form-item> + <el-form-item label="文本位置" prop="placeholderPosition"> + <el-radio-group v-model="formData!.placeholderPosition"> + <el-tooltip content="居左" placement="top"> + <el-radio-button label="left"> + <Icon icon="ant-design:align-left-outlined" /> + </el-radio-button> + </el-tooltip> + <el-tooltip content="居中" placement="top"> + <el-radio-button label="center"> + <Icon icon="ant-design:align-center-outlined" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + <el-form-item label="扫一扫" prop="showScan"> + <el-switch v-model="formData!.showScan" /> + </el-form-item> + <el-form-item label="框体高度" prop="height"> + <el-slider v-model="formData!.height" :max="50" :min="28" show-input input-size="small" /> + </el-form-item> + <el-form-item label="框体颜色" prop="backgroundColor"> + <ColorInput v-model="formData.backgroundColor" /> + </el-form-item> + <el-form-item class="lef" label="文本颜色" prop="textColor"> + <ColorInput v-model="formData.textColor" /> + </el-form-item> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { usePropertyForm } from '@/components/DiyEditor/util' +import { SearchProperty } from '@/components/DiyEditor/components/mobile/SearchBar/config' + +/** 搜索框属性面板 */ +defineOptions({ name: 'SearchProperty' }) + +const props = defineProps<{ modelValue: SearchProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/TabBar/config.ts b/src/components/DiyEditor/components/mobile/TabBar/config.ts new file mode 100644 index 0000000..88d706f --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TabBar/config.ts @@ -0,0 +1,97 @@ +import { DiyComponent } from '@/components/DiyEditor/util' + +/** 底部导航菜单属性 */ +export interface TabBarProperty { + // 选项列表 + items: TabBarItemProperty[] + // 主题 + theme: string + // 样式 + style: TabBarStyle +} + +// 选项属性 +export interface TabBarItemProperty { + // 标签文字 + text: string + // 链接 + url: string + // 默认图标链接 + iconUrl: string + // 选中的图标链接 + activeIconUrl: string +} + +// 样式 +export interface TabBarStyle { + // 背景类型 + bgType: 'color' | 'img' + // 背景颜色 + bgColor: string + // 图片链接 + bgImg: string + // 默认颜色 + color: string + // 选中的颜色 + activeColor: string +} + +// 定义组件 +export const component = { + id: 'TabBar', + name: '底部导航', + icon: 'fluent:table-bottom-row-16-filled', + property: { + theme: 'red', + style: { + bgType: 'color', + bgColor: '#fff', + color: '#282828', + activeColor: '#fc4141' + }, + items: [ + { + text: '首页', + url: '/pages/index/index', + iconUrl: 'http://mall.yudao.iocoder.cn/static/images/1-001.png', + activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/1-002.png' + }, + { + text: '分类', + url: '/pages/index/category?id=3', + iconUrl: 'http://mall.yudao.iocoder.cn/static/images/2-001.png', + activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/2-002.png' + }, + { + text: '购物车', + url: '/pages/index/cart', + iconUrl: 'http://mall.yudao.iocoder.cn/static/images/3-001.png', + activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/3-002.png' + }, + { + text: '我的', + url: '/pages/index/user', + iconUrl: 'http://mall.yudao.iocoder.cn/static/images/4-001.png', + activeIconUrl: 'http://mall.yudao.iocoder.cn/static/images/4-002.png' + } + ] + } +} as DiyComponent<TabBarProperty> + +export const THEME_LIST = [ + { id: 'red', name: '中国红', icon: 'icon-park-twotone:theme', color: '#d10019' }, + { id: 'orange', name: '桔橙', icon: 'icon-park-twotone:theme', color: '#f37b1d' }, + { id: 'gold', name: '明黄', icon: 'icon-park-twotone:theme', color: '#fbbd08' }, + { id: 'green', name: '橄榄绿', icon: 'icon-park-twotone:theme', color: '#8dc63f' }, + { id: 'cyan', name: '天青', icon: 'icon-park-twotone:theme', color: '#1cbbb4' }, + { id: 'blue', name: '海蓝', icon: 'icon-park-twotone:theme', color: '#0081ff' }, + { id: 'purple', name: '姹紫', icon: 'icon-park-twotone:theme', color: '#6739b6' }, + { id: 'brightRed', name: '嫣红', icon: 'icon-park-twotone:theme', color: '#e54d42' }, + { id: 'forestGreen', name: '森绿', icon: 'icon-park-twotone:theme', color: '#39b54a' }, + { id: 'mauve', name: '木槿', icon: 'icon-park-twotone:theme', color: '#9c26b0' }, + { id: 'pink', name: '桃粉', icon: 'icon-park-twotone:theme', color: '#e03997' }, + { id: 'brown', name: '棕褐', icon: 'icon-park-twotone:theme', color: '#a5673f' }, + { id: 'grey', name: '玄灰', icon: 'icon-park-twotone:theme', color: '#8799a3' }, + { id: 'gray', name: '草灰', icon: 'icon-park-twotone:theme', color: '#aaaaaa' }, + { id: 'black', name: '墨黑', icon: 'icon-park-twotone:theme', color: '#333333' } +] diff --git a/src/components/DiyEditor/components/mobile/TabBar/index.vue b/src/components/DiyEditor/components/mobile/TabBar/index.vue new file mode 100644 index 0000000..44ba43c --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TabBar/index.vue @@ -0,0 +1,66 @@ +<template> + <div class="tab-bar"> + <div + class="tab-bar-bg" + :style="{ + background: + property.style.bgType === 'color' + ? property.style.bgColor + : `url(${property.style.bgImg})`, + backgroundSize: '100% 100%', + backgroundRepeat: 'no-repeat' + }" + > + <div v-for="(item, index) in property.items" :key="index" class="tab-bar-item"> + <el-image :src="index === 0 ? item.activeIconUrl : item.iconUrl"> + <template #error> + <div class="h-full w-full flex items-center justify-center"> + <Icon icon="ep:picture" /> + </div> + </template> + </el-image> + <span :style="{ color: index === 0 ? property.style.activeColor : property.style.color }"> + {{ item.text }} + </span> + </div> + </div> + </div> +</template> +<script setup lang="ts"> +import { TabBarProperty } from './config' + +/** 页面底部导航栏 */ +defineOptions({ name: 'TabBar' }) + +defineProps<{ property: TabBarProperty }>() +</script> +<style lang="scss" scoped> +.tab-bar { + z-index: 2; + width: 100%; + + .tab-bar-bg { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-around; + padding: 8px 0; + + .tab-bar-item { + display: flex; + width: 100%; + font-size: 12px; + flex-direction: column; + align-items: center; + justify-content: center; + + :deep(img), + .el-icon { + width: 26px; + height: 26px; + border-radius: 4px; + } + } + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/TabBar/property.vue b/src/components/DiyEditor/components/mobile/TabBar/property.vue new file mode 100644 index 0000000..6ace5af --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TabBar/property.vue @@ -0,0 +1,100 @@ +<template> + <div class="tab-bar"> + <!-- 表单 --> + <el-form :model="formData" label-width="80px"> + <el-form-item label="主题" prop="theme"> + <el-select v-model="formData!.theme" @change="handleThemeChange"> + <el-option + v-for="(theme, index) in THEME_LIST" + :key="index" + :label="theme.name" + :value="theme.id" + > + <template #default> + <div class="flex items-center justify-between"> + <Icon :icon="theme.icon" :color="theme.color" /> + <span>{{ theme.name }}</span> + </div> + </template> + </el-option> + </el-select> + </el-form-item> + <el-form-item label="默认颜色"> + <ColorInput v-model="formData!.style.color" /> + </el-form-item> + <el-form-item label="选中颜色"> + <ColorInput v-model="formData!.style.activeColor" /> + </el-form-item> + <el-form-item label="导航背景"> + <el-radio-group v-model="formData!.style.bgType"> + <el-radio-button label="color">纯色</el-radio-button> + <el-radio-button label="img">图片</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item label="选择颜色" v-if="formData!.style.bgType === 'color'"> + <ColorInput v-model="formData!.style.bgColor" /> + </el-form-item> + <el-form-item label="选择图片" v-if="formData!.style.bgType === 'img'"> + <UploadImg v-model="formData!.style.bgImg" width="100%" height="50px" class="min-w-200px"> + <template #tip> 建议尺寸 375 * 50 </template> + </UploadImg> + </el-form-item> + + <el-text tag="p">图标设置</el-text> + <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序, 图标建议尺寸 44*44 </el-text> + <Draggable v-model="formData.items" :limit="5"> + <template #default="{ element }"> + <div class="m-b-8px flex items-center justify-around"> + <div class="flex flex-col items-center justify-between"> + <UploadImg + v-model="element.iconUrl" + width="40px" + height="40px" + :show-delete="false" + :show-btn-text="false" + /> + <el-text size="small">未选中</el-text> + </div> + <div> + <UploadImg + v-model="element.activeIconUrl" + width="40px" + height="40px" + :show-delete="false" + :show-btn-text="false" + /> + <el-text>已选中</el-text> + </div> + </div> + <el-form-item prop="text" label="文字" label-width="48px" class="m-b-8px!"> + <el-input v-model="element.text" placeholder="请输入文字" /> + </el-form-item> + <el-form-item prop="url" label="链接" label-width="48px" class="m-b-0!"> + <AppLinkInput v-model="element.url" /> + </el-form-item> + </template> + </Draggable> + </el-form> + </div> +</template> + +<script setup lang="ts"> +import { TabBarProperty, THEME_LIST } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +// 底部导航栏 +defineOptions({ name: 'TabBarProperty' }) + +const props = defineProps<{ modelValue: TabBarProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +// 要的主题 +const handleThemeChange = () => { + const theme = THEME_LIST.find((theme) => theme.id === formData.value.theme) + if (theme?.color) { + formData.value.style.activeColor = theme.color + } +} +</script> + +<style lang="scss" scoped></style> diff --git a/src/components/DiyEditor/components/mobile/TitleBar/config.ts b/src/components/DiyEditor/components/mobile/TitleBar/config.ts new file mode 100644 index 0000000..d9f0672 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TitleBar/config.ts @@ -0,0 +1,69 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 标题栏属性 */ +export interface TitleBarProperty { + // 背景图 + bgImgUrl: string + // 偏移 + marginLeft: number + // 显示位置 + textAlign: 'left' | 'center' + // 主标题 + title: string + // 副标题 + description: string + // 标题大小 + titleSize: number + // 描述大小 + descriptionSize: number + // 标题粗细 + titleWeight: number + // 描述粗细 + descriptionWeight: number + // 标题颜色 + titleColor: string + // 描述颜色 + descriptionColor: string + // 查看更多 + more: { + // 是否显示查看更多 + show: false + // 样式选择 + type: 'text' | 'icon' | 'all' + // 自定义文字 + text: string + // 链接 + url: string + } + // 组件样式 + style: ComponentStyle +} + +// 定义组件 +export const component = { + id: 'TitleBar', + name: '标题栏', + icon: 'material-symbols:line-start', + property: { + title: '主标题', + description: '副标题', + titleSize: 16, + descriptionSize: 12, + titleWeight: 400, + textAlign: 'left', + descriptionWeight: 200, + titleColor: 'rgba(50, 50, 51, 10)', + descriptionColor: 'rgba(150, 151, 153, 10)', + more: { + //查看更多 + show: false, + type: 'icon', + text: '查看更多', + url: '' + }, + style: { + bgType: 'color', + bgColor: '#fff' + } as ComponentStyle + } +} as DiyComponent<TitleBarProperty> diff --git a/src/components/DiyEditor/components/mobile/TitleBar/index.vue b/src/components/DiyEditor/components/mobile/TitleBar/index.vue new file mode 100644 index 0000000..b75d224 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TitleBar/index.vue @@ -0,0 +1,73 @@ +<template> + <div class="title-bar"> + <el-image v-if="property.bgImgUrl" :src="property.bgImgUrl" fit="cover" class="w-full" /> + <div class="absolute left-0 top-0 w-full"> + <!-- 标题 --> + <div + :style="{ + fontSize: `${property.titleSize}px`, + fontWeight: property.titleWeight, + color: property.titleColor, + textAlign: property.textAlign + }" + v-if="property.title" + > + {{ property.title }} + </div> + <!-- 副标题 --> + <div + :style="{ + fontSize: `${property.descriptionSize}px`, + fontWeight: property.descriptionWeight, + color: property.descriptionColor, + textAlign: property.textAlign + }" + class="m-t-8px" + v-if="property.description" + > + {{ property.description }} + </div> + </div> + <!-- 更多 --> + <div + class="more" + v-show="property.more.show" + :style="{ + color: property.descriptionColor + }" + > + <span v-if="property.more.type !== 'icon'"> {{ property.more.text }} </span> + <Icon icon="ep:arrow-right" v-if="property.more.type !== 'text'" /> + </div> + </div> +</template> +<script setup lang="ts"> +import { TitleBarProperty } from './config' + +/** 标题栏 */ +defineOptions({ name: 'TitleBar' }) + +defineProps<{ property: TitleBarProperty }>() +</script> +<style scoped lang="scss"> +.title-bar { + position: relative; + width: 100%; + min-height: 20px; + box-sizing: border-box; + + /* 更多 */ + .more { + position: absolute; + top: 0; + right: 8px; + bottom: 0; + display: flex; + margin: auto; + font-size: 10px; + color: #969799; + align-items: center; + justify-content: center; + } +} +</style> diff --git a/src/components/DiyEditor/components/mobile/TitleBar/property.vue b/src/components/DiyEditor/components/mobile/TitleBar/property.vue new file mode 100644 index 0000000..4eb3259 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/TitleBar/property.vue @@ -0,0 +1,121 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <el-form label-width="85px" :model="formData" :rules="rules"> + <el-card header="风格" class="property-group" shadow="never"> + <el-form-item label="背景图片" prop="bgImgUrl"> + <UploadImg v-model="formData.bgImgUrl" width="100%" height="40px"> + <template #tip>建议尺寸 750*80</template> + </UploadImg> + </el-form-item> + <el-form-item label="标题位置" prop="textAlign"> + <el-radio-group v-model="formData!.textAlign"> + <el-tooltip content="居左" placement="top"> + <el-radio-button label="left"> + <Icon icon="ant-design:align-left-outlined" /> + </el-radio-button> + </el-tooltip> + <el-tooltip content="居中" placement="top"> + <el-radio-button label="center"> + <Icon icon="ant-design:align-center-outlined" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </el-form-item> + </el-card> + <el-card header="主标题" class="property-group" shadow="never"> + <el-form-item label="文字" prop="title" label-width="40px"> + <InputWithColor + v-model="formData.title" + v-model:color="formData.titleColor" + show-word-limit + maxlength="20" + /> + </el-form-item> + <el-form-item label="大小" prop="titleSize" label-width="40px"> + <el-slider + v-model="formData.titleSize" + :max="60" + :min="10" + show-input + input-size="small" + /> + </el-form-item> + <el-form-item label="粗细" prop="titleWeight" label-width="40px"> + <el-slider + v-model="formData.titleWeight" + :min="100" + :max="900" + :step="100" + show-input + input-size="small" + /> + </el-form-item> + </el-card> + <el-card header="副标题" class="property-group" shadow="never"> + <el-form-item label="文字" prop="description" label-width="40px"> + <InputWithColor + v-model="formData.description" + v-model:color="formData.descriptionColor" + show-word-limit + maxlength="50" + /> + </el-form-item> + <el-form-item label="大小" prop="descriptionSize" label-width="40px"> + <el-slider + v-model="formData.descriptionSize" + :max="60" + :min="10" + show-input + input-size="small" + /> + </el-form-item> + <el-form-item label="粗细" prop="descriptionWeight" label-width="40px"> + <el-slider + v-model="formData.descriptionWeight" + :min="100" + :max="900" + :step="100" + show-input + input-size="small" + /> + </el-form-item> + </el-card> + <el-card header="查看更多" class="property-group" shadow="never"> + <el-form-item label="是否显示" prop="more.show"> + <el-checkbox v-model="formData.more.show" /> + </el-form-item> + <!-- 更多按钮的 样式选择 --> + <template v-if="formData.more.show"> + <el-form-item label="样式" prop="more.type"> + <el-radio-group v-model="formData.more.type"> + <el-radio label="text">文字</el-radio> + <el-radio label="icon">图标</el-radio> + <el-radio label="all">文字+图标</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="更多文字" prop="more.text" v-show="formData.more.type !== 'icon'"> + <el-input v-model="formData.more.text" /> + </el-form-item> + <el-form-item label="跳转链接" prop="more.url"> + <AppLinkInput v-model="formData.more.url" /> + </el-form-item> + </template> + </el-card> + </el-form> + </ComponentContainerProperty> +</template> +<script setup lang="ts"> +import { TitleBarProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' +// 导航栏属性面板 +defineOptions({ name: 'TitleBarProperty' }) + +const props = defineProps<{ modelValue: TitleBarProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +// 表单校验 +const rules = {} +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/UserCard/config.ts b/src/components/DiyEditor/components/mobile/UserCard/config.ts new file mode 100644 index 0000000..7b33776 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserCard/config.ts @@ -0,0 +1,21 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 用户卡片属性 */ +export interface UserCardProperty { + // 组件样式 + style: ComponentStyle +} + +// 定义组件 +export const component = { + id: 'UserCard', + name: '用户卡片', + icon: 'mdi:user-card-details', + property: { + style: { + bgType: 'color', + bgColor: '', + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<UserCardProperty> diff --git a/src/components/DiyEditor/components/mobile/UserCard/index.vue b/src/components/DiyEditor/components/mobile/UserCard/index.vue new file mode 100644 index 0000000..14b447c --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserCard/index.vue @@ -0,0 +1,29 @@ +<template> + <div class="flex flex-col"> + <div class="flex items-center justify-between p-x-18px p-y-24px"> + <div class="flex flex-1 items-center gap-16px"> + <el-avatar :size="60"> + <Icon icon="ep:avatar" :size="60" /> + </el-avatar> + <span class="text-18px font-bold">芋道源码</span> + </div> + <Icon icon="tdesign:qrcode" :size="20" /> + </div> + <div + class="flex items-center justify-between justify-between bg-white p-x-20px p-y-8px text-12px" + > + <span class="color-#ff690d">点击绑定手机号</span> + <span class="rounded-26px bg-#ff6100 p-x-8px p-y-5px color-white">去绑定</span> + </div> + </div> +</template> +<script setup lang="ts"> +import { UserCardProperty } from './config' + +/** 用户卡片 */ +defineOptions({ name: 'UserCard' }) +// 定义属性 +defineProps<{ property: UserCardProperty }>() +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/UserCard/property.vue b/src/components/DiyEditor/components/mobile/UserCard/property.vue new file mode 100644 index 0000000..43dfad2 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserCard/property.vue @@ -0,0 +1,17 @@ +<template> + <ComponentContainerProperty v-model="formData.style" /> +</template> + +<script setup lang="ts"> +import { UserCardProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' + +// 用户卡片属性面板 +defineOptions({ name: 'UserCardProperty' }) + +const props = defineProps<{ modelValue: UserCardProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/UserCoupon/config.ts b/src/components/DiyEditor/components/mobile/UserCoupon/config.ts new file mode 100644 index 0000000..92eba9b --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserCoupon/config.ts @@ -0,0 +1,23 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 用户卡券属性 */ +export interface UserCouponProperty { + // 组件样式 + style: ComponentStyle +} + +// 定义组件 +export const component = { + id: 'UserCoupon', + name: '用户卡券', + icon: 'ep:ticket', + property: { + style: { + bgType: 'color', + bgColor: '', + marginLeft: 8, + marginRight: 8, + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<UserCouponProperty> diff --git a/src/components/DiyEditor/components/mobile/UserCoupon/index.vue b/src/components/DiyEditor/components/mobile/UserCoupon/index.vue new file mode 100644 index 0000000..27ad310 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserCoupon/index.vue @@ -0,0 +1,15 @@ +<template> + <el-image + src="https://shopro.sheepjs.com/admin/static/images/shop/decorate/couponCardStyle.png" + /> +</template> +<script setup lang="ts"> +import { UserCouponProperty } from './config' + +/** 用户卡券 */ +defineOptions({ name: 'UserCoupon' }) +// 定义属性 +defineProps<{ property: UserCouponProperty }>() +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/UserCoupon/property.vue b/src/components/DiyEditor/components/mobile/UserCoupon/property.vue new file mode 100644 index 0000000..f902e04 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserCoupon/property.vue @@ -0,0 +1,17 @@ +<template> + <ComponentContainerProperty v-model="formData.style" /> +</template> + +<script setup lang="ts"> +import { UserCouponProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' + +// 用户卡券属性面板 +defineOptions({ name: 'UserCouponProperty' }) + +const props = defineProps<{ modelValue: UserCouponProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/UserOrder/config.ts b/src/components/DiyEditor/components/mobile/UserOrder/config.ts new file mode 100644 index 0000000..f9c5a6d --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserOrder/config.ts @@ -0,0 +1,23 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 用户订单属性 */ +export interface UserOrderProperty { + // 组件样式 + style: ComponentStyle +} + +// 定义组件 +export const component = { + id: 'UserOrder', + name: '用户订单', + icon: 'ep:list', + property: { + style: { + bgType: 'color', + bgColor: '', + marginLeft: 8, + marginRight: 8, + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<UserOrderProperty> diff --git a/src/components/DiyEditor/components/mobile/UserOrder/index.vue b/src/components/DiyEditor/components/mobile/UserOrder/index.vue new file mode 100644 index 0000000..450ae54 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserOrder/index.vue @@ -0,0 +1,13 @@ +<template> + <el-image src="https://shopro.sheepjs.com/admin/static/images/shop/decorate/orderCardStyle.png" /> +</template> +<script setup lang="ts"> +import { UserOrderProperty } from './config' + +/** 用户订单 */ +defineOptions({ name: 'UserOrder' }) +// 定义属性 +defineProps<{ property: UserOrderProperty }>() +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/UserOrder/property.vue b/src/components/DiyEditor/components/mobile/UserOrder/property.vue new file mode 100644 index 0000000..42df741 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserOrder/property.vue @@ -0,0 +1,17 @@ +<template> + <ComponentContainerProperty v-model="formData.style" /> +</template> + +<script setup lang="ts"> +import { UserOrderProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' + +// 用户订单属性面板 +defineOptions({ name: 'UserOrderProperty' }) + +const props = defineProps<{ modelValue: UserOrderProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/UserWallet/config.ts b/src/components/DiyEditor/components/mobile/UserWallet/config.ts new file mode 100644 index 0000000..4e0955f --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserWallet/config.ts @@ -0,0 +1,23 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 用户资产属性 */ +export interface UserWalletProperty { + // 组件样式 + style: ComponentStyle +} + +// 定义组件 +export const component = { + id: 'UserWallet', + name: '用户资产', + icon: 'ep:wallet-filled', + property: { + style: { + bgType: 'color', + bgColor: '', + marginLeft: 8, + marginRight: 8, + marginBottom: 8 + } as ComponentStyle + } +} as DiyComponent<UserWalletProperty> diff --git a/src/components/DiyEditor/components/mobile/UserWallet/index.vue b/src/components/DiyEditor/components/mobile/UserWallet/index.vue new file mode 100644 index 0000000..0efc937 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserWallet/index.vue @@ -0,0 +1,15 @@ +<template> + <el-image + src="https://shopro.sheepjs.com/admin/static/images/shop/decorate/walletCardStyle.png" + /> +</template> +<script setup lang="ts"> +import { UserWalletProperty } from './config' + +/** 用户资产 */ +defineOptions({ name: 'UserWallet' }) +// 定义属性 +defineProps<{ property: UserWalletProperty }>() +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/UserWallet/property.vue b/src/components/DiyEditor/components/mobile/UserWallet/property.vue new file mode 100644 index 0000000..549367e --- /dev/null +++ b/src/components/DiyEditor/components/mobile/UserWallet/property.vue @@ -0,0 +1,17 @@ +<template> + <ComponentContainerProperty v-model="formData.style" /> +</template> + +<script setup lang="ts"> +import { UserWalletProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' + +// 用户资产属性面板 +defineOptions({ name: 'UserWalletProperty' }) + +const props = defineProps<{ modelValue: UserWalletProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/VideoPlayer/config.ts b/src/components/DiyEditor/components/mobile/VideoPlayer/config.ts new file mode 100644 index 0000000..02f0374 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/VideoPlayer/config.ts @@ -0,0 +1,37 @@ +import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util' + +/** 视频播放属性 */ +export interface VideoPlayerProperty { + // 视频链接 + videoUrl: string + // 封面链接 + posterUrl: string + // 是否自动播放 + autoplay: boolean + // 组件样式 + style: VideoPlayerStyle +} + +// 视频播放样式 +export interface VideoPlayerStyle extends ComponentStyle { + // 视频高度 + height: number +} + +// 定义组件 +export const component = { + id: 'VideoPlayer', + name: '视频播放', + icon: 'ep:video-play', + property: { + videoUrl: '', + posterUrl: '', + autoplay: false, + style: { + bgType: 'color', + bgColor: '#fff', + marginBottom: 8, + height: 300 + } as VideoPlayerStyle + } +} as DiyComponent<VideoPlayerProperty> diff --git a/src/components/DiyEditor/components/mobile/VideoPlayer/index.vue b/src/components/DiyEditor/components/mobile/VideoPlayer/index.vue new file mode 100644 index 0000000..fa9a914 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/VideoPlayer/index.vue @@ -0,0 +1,30 @@ +<template> + <div class="w-full" :style="{ height: `${property.style.height}px` }"> + <el-image class="w-full w-full" :src="property.posterUrl" v-if="property.posterUrl" /> + <video + v-else + class="w-full w-full" + :src="property.videoUrl" + :poster="property.posterUrl" + :autoplay="property.autoplay" + controls + ></video> + </div> +</template> +<script setup lang="ts"> +import { VideoPlayerProperty } from './config' + +/** 视频播放 */ +defineOptions({ name: 'VideoPlayer' }) + +defineProps<{ property: VideoPlayerProperty }>() +</script> + +<style scoped lang="scss"> +/* 图片 */ +img { + display: block; + width: 100%; + height: 100%; +} +</style> diff --git a/src/components/DiyEditor/components/mobile/VideoPlayer/property.vue b/src/components/DiyEditor/components/mobile/VideoPlayer/property.vue new file mode 100644 index 0000000..7598543 --- /dev/null +++ b/src/components/DiyEditor/components/mobile/VideoPlayer/property.vue @@ -0,0 +1,55 @@ +<template> + <ComponentContainerProperty v-model="formData.style"> + <template #style> + <el-form-item label="高度" prop="height"> + <el-slider + v-model="formData.style.height" + :max="500" + :min="100" + show-input + input-size="small" + :show-input-controls="false" + /> + </el-form-item> + </template> + <el-form label-width="80px" :model="formData"> + <el-form-item label="上传视频" prop="videoUrl"> + <UploadFile + v-model="formData.videoUrl" + :file-type="['mp4']" + :limit="1" + :file-size="100" + class="min-w-80px" + /> + </el-form-item> + <el-form-item label="上传封面" prop="posterUrl"> + <UploadImg + v-model="formData.posterUrl" + draggable="false" + height="80px" + width="100%" + class="min-w-80px" + > + <template #tip> 建议宽度750 </template> + </UploadImg> + </el-form-item> + <el-form-item label="自动播放" prop="autoplay"> + <el-switch v-model="formData.autoplay" /> + </el-form-item> + </el-form> + </ComponentContainerProperty> +</template> + +<script setup lang="ts"> +import { VideoPlayerProperty } from './config' +import { usePropertyForm } from '@/components/DiyEditor/util' + +// 视频播放属性面板 +defineOptions({ name: 'VideoPlayerProperty' }) + +const props = defineProps<{ modelValue: VideoPlayerProperty }>() +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/DiyEditor/components/mobile/index.ts b/src/components/DiyEditor/components/mobile/index.ts new file mode 100644 index 0000000..c0dc67d --- /dev/null +++ b/src/components/DiyEditor/components/mobile/index.ts @@ -0,0 +1,61 @@ +/* + * 组件注册 + * + * 组件规范: + * 1. 每个子目录就是一个独立的组件,每个目录包括以下三个文件: + * 2. config.ts:组件配置,必选,用于定义组件、组件默认的属性、定义属性的类型 + * 3. index.vue:组件展示,用于展示组件的渲染效果。可以不提供,如 Page(页面设置),只需要属性配置表单即可 + * 4. property.vue:组件属性表单,用于配置组件,必选, + * + * 注: + * 组件ID以config.ts中配置的id为准,与组件目录的名称无关,但还是建议组件目录的名称与组件ID保持一致 + */ + +// 导入组件界面模块 +const viewModules: Record<string, any> = import.meta.glob('./*/*.vue') +// 导入配置模块 +const configModules: Record<string, any> = import.meta.glob('./*/config.ts', { eager: true }) + +// 界面模块 +const components = {} +// 组件配置模块 +const componentConfigs = {} + +// 组件界面的类型 +type ViewType = 'index' | 'property' + +/** + * 注册组件的界面模块 + * + * @param componentId 组件ID + * @param configPath 配置模块的文件路径 + * @param viewType 组件界面的类型 + */ +const registerComponentViewModule = ( + componentId: string, + configPath: string, + viewType: ViewType +) => { + const viewPath = configPath.replace('config.ts', `${viewType}.vue`) + const viewModule = viewModules[viewPath] + if (viewModule) { + // 定义异步组件 + components[componentId] = defineAsyncComponent(viewModule) + } +} + +// 注册 +Object.keys(configModules).forEach((modulePath: string) => { + const component = configModules[modulePath].component + const componentId = component?.id + if (componentId) { + // 注册组件 + componentConfigs[componentId] = component + // 注册预览界面 + registerComponentViewModule(componentId, modulePath, 'index') + // 注册属性配置表单 + registerComponentViewModule(`${componentId}Property`, modulePath, 'property') + } +}) + +export { components, componentConfigs } diff --git a/src/components/DiyEditor/index.vue b/src/components/DiyEditor/index.vue new file mode 100644 index 0000000..700d32b --- /dev/null +++ b/src/components/DiyEditor/index.vue @@ -0,0 +1,565 @@ +<template> + <el-container class="editor"> + <!-- 顶部:工具栏 --> + <el-header class="editor-header"> + <!-- 左侧操作区 --> + <slot name="toolBarLeft"></slot> + <!-- 中心操作区 --> + <div class="header-center flex flex-1 items-center justify-center"> + <span>{{ title }}</span> + </div> + <!-- 右侧操作区 --> + <el-button-group class="header-right"> + <el-tooltip content="重置"> + <el-button @click="handleReset"> + <Icon icon="system-uicons:reset-alt" :size="24" /> + </el-button> + </el-tooltip> + <el-tooltip content="预览" v-if="previewUrl"> + <el-button @click="handlePreview"> + <Icon icon="ep:view" :size="24" /> + </el-button> + </el-tooltip> + <el-tooltip content="保存"> + <el-button @click="handleSave"> + <Icon icon="ep:check" :size="24" /> + </el-button> + </el-tooltip> + </el-button-group> + </el-header> + + <!-- 中心区域 --> + <el-container class="editor-container"> + <!-- 左侧:组件库(ComponentLibrary) --> + <ComponentLibrary ref="componentLibrary" :list="libs" v-if="libs && libs.length > 0" /> + <!-- 中心:设计区域(ComponentContainer) --> + <div class="editor-center page-prop-area" @click="handlePageSelected"> + <!-- 手机顶部 --> + <div class="editor-design-top"> + <!-- 手机顶部状态栏 --> + <img src="@/assets/imgs/diy/statusBar.png" alt="" class="status-bar" /> + <!-- 手机顶部导航栏 --> + <ComponentContainer + v-if="showNavigationBar" + :component="navigationBarComponent" + :show-toolbar="false" + :active="selectedComponent?.id === navigationBarComponent.id" + @click="handleNavigationBarSelected" + class="cursor-pointer!" + /> + </div> + <!-- 绝对定位的组件:例如 弹窗、浮动按钮等 --> + <div + v-for="(component, index) in pageComponents" + :key="index" + @click="handleComponentSelected(component, index)" + > + <component + v-if="component.position === 'fixed' && selectedComponent?.uid === component.uid" + :is="component.id" + :property="component.property" + /> + </div> + <!-- 手机页面编辑区域 --> + <el-scrollbar + height="100%" + wrap-class="editor-design-center page-prop-area" + view-class="phone-container" + :view-style="{ + backgroundColor: pageConfigComponent.property.backgroundColor, + backgroundImage: `url(${pageConfigComponent.property.backgroundImage})` + }" + > + <draggable + class="page-prop-area drag-area" + v-model="pageComponents" + item-key="index" + :animation="200" + filter=".component-toolbar" + ghost-class="draggable-ghost" + :force-fallback="true" + group="component" + @change="handleComponentChange" + > + <template #item="{ element, index }"> + <ComponentContainer + v-if="!element.position || element.position === 'center'" + :component="element" + :active="selectedComponentIndex === index" + :can-move-up="index > 0" + :can-move-down="index < pageComponents.length - 1" + @move="(direction) => handleMoveComponent(index, direction)" + @copy="handleCopyComponent(index)" + @delete="handleDeleteComponent(index)" + @click="handleComponentSelected(element, index)" + /> + </template> + </draggable> + </el-scrollbar> + <!-- 手机底部导航 --> + <div v-if="showTabBar" :class="['editor-design-bottom', 'component', 'cursor-pointer!']"> + <ComponentContainer + :component="tabBarComponent" + :show-toolbar="false" + :active="selectedComponent?.id === tabBarComponent.id" + @click="handleTabBarSelected" + /> + </div> + <!-- 固定布局的组件 操作按钮区 --> + <div class="fixed-component-action-group"> + <el-tag + v-if="showPageConfig" + size="large" + :effect="selectedComponent?.uid === pageConfigComponent.uid ? 'dark' : 'plain'" + :type="selectedComponent?.uid === pageConfigComponent.uid ? '' : 'info'" + @click="handleComponentSelected(pageConfigComponent)" + > + <Icon :icon="pageConfigComponent.icon" :size="12" /> + <span>{{ pageConfigComponent.name }}</span> + </el-tag> + <template v-for="(component, index) in pageComponents" :key="index"> + <el-tag + v-if="component.position === 'fixed'" + size="large" + closable + :effect="selectedComponent?.uid === component.uid ? 'dark' : 'plain'" + :type="selectedComponent?.uid === component.uid ? '' : 'info'" + @click="handleComponentSelected(component)" + @close="handleDeleteComponent(index)" + > + <Icon :icon="component.icon" :size="12" /> + <span>{{ component.name }}</span> + </el-tag> + </template> + </div> + </div> + <!-- 右侧:属性面板(ComponentContainerProperty) --> + <el-aside class="editor-right" width="350px" v-if="selectedComponent?.property"> + <el-card + shadow="never" + body-class="h-[calc(100%-var(--el-card-padding)-var(--el-card-padding))]" + class="h-full" + > + <!-- 组件名称 --> + <template #header> + <div class="flex items-center gap-8px"> + <Icon :icon="selectedComponent?.icon" color="gray" /> + <span>{{ selectedComponent?.name }}</span> + </div> + </template> + <el-scrollbar + class="m-[calc(0px-var(--el-card-padding))]" + view-class="p-[var(--el-card-padding)] p-b-[calc(var(--el-card-padding)+var(--el-card-padding))] property" + > + <component + :key="selectedComponent?.uid || selectedComponent?.id" + :is="selectedComponent?.id + 'Property'" + v-model="selectedComponent.property" + /> + </el-scrollbar> + </el-card> + </el-aside> + </el-container> + </el-container> + + <!-- 预览弹框 --> + <Dialog v-model="previewDialogVisible" title="预览" width="700"> + <div class="flex justify-around"> + <IFrame + class="w-375px border-4px border-rounded-8px border-solid p-2px h-667px!" + :src="previewUrl" + /> + <div class="flex flex-col"> + <el-text>手机扫码预览</el-text> + <Qrcode :text="previewUrl" logo="/logo.gif" /> + </div> + </div> + </Dialog> +</template> +<script lang="ts"> +// 注册所有的组件 +import { components } from './components/mobile/index' +export default { + components: { ...components } +} +</script> +<script lang="ts" setup> +import draggable from 'vuedraggable' +import ComponentLibrary from './components/ComponentLibrary.vue' +import { cloneDeep, includes } from 'lodash-es' +import { component as PAGE_CONFIG_COMPONENT } from '@/components/DiyEditor/components/mobile/PageConfig/config' +import { component as NAVIGATION_BAR_COMPONENT } from './components/mobile/NavigationBar/config' +import { component as TAB_BAR_COMPONENT } from './components/mobile/TabBar/config' +import { isString } from '@/utils/is' +import { DiyComponent, DiyComponentLibrary, PageConfig } from '@/components/DiyEditor/util' +import { componentConfigs } from '@/components/DiyEditor/components/mobile' +import { array, oneOfType } from 'vue-types' +import { propTypes } from '@/utils/propTypes' + +/** 页面装修详情页 */ +defineOptions({ name: 'DiyPageDetail' }) + +// 左侧组件库 +const componentLibrary = ref() +// 页面设置组件 +const pageConfigComponent = ref<DiyComponent<any>>(cloneDeep(PAGE_CONFIG_COMPONENT)) +// 顶部导航栏 +const navigationBarComponent = ref<DiyComponent<any>>(cloneDeep(NAVIGATION_BAR_COMPONENT)) +// 底部导航菜单 +const tabBarComponent = ref<DiyComponent<any>>(cloneDeep(TAB_BAR_COMPONENT)) + +// 选中的组件,默认选中顶部导航栏 +const selectedComponent = ref<DiyComponent<any>>() +// 选中的组件索引 +const selectedComponentIndex = ref<number>(-1) +// 组件列表 +const pageComponents = ref<DiyComponent<any>[]>([]) +// 定义属性 +const props = defineProps({ + // 页面配置,支持Json字符串 + modelValue: oneOfType<string | PageConfig>([String, Object]).isRequired, + // 标题 + title: propTypes.string.def(''), + // 组件库 + libs: array<DiyComponentLibrary>(), + // 是否显示顶部导航栏 + showNavigationBar: propTypes.bool.def(true), + // 是否显示底部导航菜单 + showTabBar: propTypes.bool.def(false), + // 是否显示页面配置 + showPageConfig: propTypes.bool.def(true), + // 预览地址:提供了预览地址,才会显示预览按钮 + previewUrl: propTypes.string.def('') +}) + +// 监听传入的页面配置 +// 解析出 pageConfigComponent 页面整体的配置,navigationBarComponent、pageComponents、tabBarComponent 页面上、中、下的配置 +watch( + () => props.modelValue, + () => { + const modelValue = isString(props.modelValue) + ? (JSON.parse(props.modelValue) as PageConfig) + : props.modelValue + pageConfigComponent.value.property = modelValue?.page || PAGE_CONFIG_COMPONENT.property + navigationBarComponent.value.property = + modelValue?.navigationBar || NAVIGATION_BAR_COMPONENT.property + tabBarComponent.value.property = modelValue?.tabBar || TAB_BAR_COMPONENT.property + // 查找对应的页面组件 + pageComponents.value = (modelValue?.components || []).map((item) => { + const component = componentConfigs[item.id] + return { ...component, property: item.property } + }) + }, + { + immediate: true + } +) + +// 保存 +const handleSave = () => { + const pageConfig = { + page: pageConfigComponent.value.property, + navigationBar: navigationBarComponent.value.property, + tabBar: tabBarComponent.value.property, + components: pageComponents.value.map((component) => { + // 只保留APP有用的字段 + return { id: component.id, property: component.property } + }) + } as PageConfig + if (!props.showTabBar) { + delete pageConfig.tabBar + } + // 发送数据更新通知 + const modelValue = isString(props.modelValue) ? JSON.stringify(pageConfig) : pageConfig + emits('update:modelValue', modelValue) + // 发送保存通知 + emits('save', pageConfig) +} + +// 处理页面选中:显示属性表单 +const handlePageSelected = (event: any) => { + if (!props.showPageConfig) return + + // 配置了样式 page-prop-area 的元素,才显示页面设置 + if (includes(event?.target?.classList, 'page-prop-area')) { + handleComponentSelected(unref(pageConfigComponent)) + } +} + +/** + * 选中组件 + * + * @param component 组件 + * @param index 组件的索引 + */ +const handleComponentSelected = (component: DiyComponent<any>, index: number = -1) => { + selectedComponent.value = component + selectedComponentIndex.value = index +} + +// 选中顶部导航栏 +const handleNavigationBarSelected = () => { + handleComponentSelected(unref(navigationBarComponent)) +} + +// 选中底部导航菜单 +const handleTabBarSelected = () => { + handleComponentSelected(unref(tabBarComponent)) +} + +// 组件变动(拖拽) +const handleComponentChange = (dragEvent: any) => { + // 新增,即从组件库拖拽添加组件 + if (dragEvent.added) { + const { element, newIndex } = dragEvent.added + handleComponentSelected(element, newIndex) + } else if (dragEvent.moved) { + // 拖拽排序 + const { newIndex } = dragEvent.moved + // 保持选中 + selectedComponentIndex.value = newIndex + } +} + +// 交换组件 +const swapComponent = (oldIndex: number, newIndex: number) => { + ;[pageComponents.value[oldIndex], pageComponents.value[newIndex]] = [ + pageComponents.value[newIndex], + pageComponents.value[oldIndex] + ] + // 保持选中 + selectedComponentIndex.value = newIndex +} + +/** 移动组件(上移、下移) */ +const handleMoveComponent = (index: number, direction: number) => { + const newIndex = index + direction + if (newIndex < 0 || newIndex >= pageComponents.value.length) return + + swapComponent(index, newIndex) +} + +/** 复制组件 */ +const handleCopyComponent = (index: number) => { + const component = cloneDeep(pageComponents.value[index]) + component.uid = new Date().getTime() + pageComponents.value.splice(index + 1, 0, component) +} + +/** + * 删除组件 + * @param index 当前组件index + */ +const handleDeleteComponent = (index: number) => { + // 删除组件 + pageComponents.value.splice(index, 1) + if (index < pageComponents.value.length) { + // 1. 不是最后一个组件时,删除后选中下面的组件 + let bottomIndex = index + handleComponentSelected(pageComponents.value[bottomIndex], bottomIndex) + } else if (pageComponents.value.length > 0) { + // 2. 不是第一个组件时,删除后选中上面的组件 + let topIndex = index - 1 + handleComponentSelected(pageComponents.value[topIndex], topIndex) + } else { + // 3. 组件全部删除之后,显示页面设置 + handleComponentSelected(unref(pageConfigComponent)) + } +} + +// 工具栏操作 +const emits = defineEmits(['reset', 'preview', 'save', 'update:modelValue']) + +// 注入无感刷新页面函数 +const reload = inject<() => void>('reload') +// 重置 +const handleReset = () => { + if (reload) reload() + emits('reset') +} + +// 预览 +const previewDialogVisible = ref(false) +const handlePreview = () => { + previewDialogVisible.value = true + emits('preview') +} + +// 设置默认选中的组件 +const setDefaultSelectedComponent = () => { + if (props.showPageConfig) { + selectedComponent.value = unref(pageConfigComponent) + } else if (props.showNavigationBar) { + selectedComponent.value = unref(navigationBarComponent) + } else if (props.showTabBar) { + selectedComponent.value = unref(tabBarComponent) + } +} + +watch( + () => [props.showPageConfig, props.showNavigationBar, props.showTabBar], + () => setDefaultSelectedComponent() +) + +onMounted(() => setDefaultSelectedComponent()) +</script> +<style lang="scss" scoped> +/* 手机宽度 */ +$phone-width: 375px; +$toolbar-height: 42px; + +/* 根节点样式 */ +.editor { + display: flex; + height: 100%; + margin: calc(0px - var(--app-content-padding)); + flex-direction: column; + + /* 顶部:工具栏 */ + .editor-header { + display: flex; + height: $toolbar-height; + padding: 0; + background-color: var(--el-bg-color); + border-bottom: solid 1px var(--el-border-color); + align-items: center; + justify-content: space-between; + + /* 工具栏:右侧按钮 */ + .header-right { + height: 100%; + + .el-button { + height: 100%; + } + } + + /* 隐藏工具栏按钮的边框 */ + :deep(.el-radio-button__inner), + :deep(.el-button) { + border-top: none !important; + border-bottom: none !important; + border-radius: 0 !important; + } + } + + /* 中心操作区 */ + .editor-container { + height: calc( + 100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - + $toolbar-height + ); + + /* 右侧属性面板 */ + .editor-right { + overflow: hidden; + box-shadow: -8px 0 8px -8px rgb(0 0 0 / 12%); + flex-shrink: 0; + + /* 属性面板顶部:减少内边距 */ + :deep(.el-card__header) { + padding: 8px 16px; + } + + /* 属性面板分组 */ + :deep(.property-group) { + margin: 0 -20px; + + &.el-card { + border: none; + } + + /* 属性分组名称 */ + .el-card__header { + padding: 8px 32px; + background: var(--el-bg-color-page); + border: none; + } + + .el-card__body { + border: none; + } + } + } + + /* 中心区域 */ + .editor-center { + position: relative; + display: flex; + width: 100%; + margin: 16px 0 0; + overflow: hidden; + background-color: var(--app-content-bg-color); + flex: 1 1 0; + flex-direction: column; + justify-content: center; + + /* 手机顶部 */ + .editor-design-top { + display: flex; + width: $phone-width; + margin: 0 auto; + flex-direction: column; + + /* 手机顶部状态栏 */ + .status-bar { + width: $phone-width; + height: 20px; + background-color: #fff; + } + } + + /* 手机底部导航 */ + .editor-design-bottom { + width: $phone-width; + margin: 0 auto; + } + + /* 手机页面编辑区域 */ + :deep(.editor-design-center) { + width: 100%; + + /* 主体内容 */ + .phone-container { + position: relative; + width: $phone-width; + height: 100%; + margin: 0 auto; + background-repeat: no-repeat; + background-size: 100% 100%; + + .drag-area { + width: 100%; + height: 100%; + } + } + } + + /* 固定布局的组件 操作按钮区 */ + .fixed-component-action-group { + position: absolute; + top: 0; + right: 16px; + display: flex; + flex-direction: column; + gap: 8px; + + :deep(.el-tag) { + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.1); + border: none; + .el-tag__content { + width: 100%; + display: flex; + align-items: center; + justify-content: flex-start; + + .el-icon { + margin-right: 4px; + } + } + } + } + } + } +} +</style> diff --git a/src/components/DiyEditor/util.ts b/src/components/DiyEditor/util.ts new file mode 100644 index 0000000..fac26e7 --- /dev/null +++ b/src/components/DiyEditor/util.ts @@ -0,0 +1,154 @@ +import { ref, Ref } from 'vue' +import { PageConfigProperty } from '@/components/DiyEditor/components/mobile/PageConfig/config' +import { NavigationBarProperty } from '@/components/DiyEditor/components/mobile/NavigationBar/config' +import { TabBarProperty } from '@/components/DiyEditor/components/mobile/TabBar/config' + +// 页面装修组件 +export interface DiyComponent<T> { + // 用于区分同一种组件的不同实例 + uid?: number + // 组件唯一标识 + id: string + // 组件名称 + name: string + // 组件图标 + icon: string + /* + 组件位置: + top: 固定于手机顶部,例如 顶部的导航栏 + bottom: 固定于手机底部,例如 底部的菜单导航栏 + center: 位于手机中心,每个组件占一行,顺序向下排列 + 空:同center + fixed: 由组件自己决定位置,如弹窗位于手机中心、浮动按钮一般位于手机右下角 + */ + position?: 'top' | 'bottom' | 'center' | '' | 'fixed' + // 组件属性 + property: T +} + +// 页面装修组件库 +export interface DiyComponentLibrary { + // 组件库名称 + name: string + // 是否展开 + extended: boolean + // 组件列表 + components: string[] +} + +// 组件样式 +export interface ComponentStyle { + // 背景类型 + bgType: 'color' | 'img' + // 背景颜色 + bgColor: string + // 背景图片 + bgImg: string + // 外边距 + margin: number + marginTop: number + marginRight: number + marginBottom: number + marginLeft: number + // 内边距 + padding: number + paddingTop: number + paddingRight: number + paddingBottom: number + paddingLeft: number + // 边框圆角 + borderRadius: number + borderTopLeftRadius: number + borderTopRightRadius: number + borderBottomRightRadius: number + borderBottomLeftRadius: number +} + +// 页面配置 +export interface PageConfig { + // 页面属性 + page: PageConfigProperty + // 顶部导航栏属性 + navigationBar: NavigationBarProperty + // 底部导航菜单属性 + tabBar?: TabBarProperty + // 页面组件列表 + components: PageComponent[] +} +// 页面组件,只保留组件ID,组件属性 +export interface PageComponent extends Pick<DiyComponent<any>, 'id' | 'property'> {} + +// 属性表单监听 +export function usePropertyForm<T>(modelValue: T, emit: Function): { formData: Ref<T> } { + const formData = ref<T>() + // 监听属性数据变动 + watch( + () => modelValue, + () => { + formData.value = modelValue + }, + { + deep: true, + immediate: true + } + ) + // 监听表单数据变动 + watch( + () => formData.value, + () => { + emit('update:modelValue', formData.value) + }, + { + deep: true + } + ) + + return { formData } as { formData: Ref<T> } +} + +// 页面组件库 +export const PAGE_LIBS = [ + { + name: '基础组件', + extended: true, + components: [ + 'SearchBar', + 'NoticeBar', + 'MenuSwiper', + 'MenuGrid', + 'MenuList', + 'Popover', + 'FloatingActionButton' + ] + }, + { + name: '图文组件', + extended: true, + components: [ + 'ImageBar', + 'Carousel', + 'TitleBar', + 'VideoPlayer', + 'Divider', + 'MagicCube', + 'HotZone' + ] + }, + { name: '商品组件', extended: true, components: ['ProductCard', 'ProductList'] }, + { + name: '用户组件', + extended: true, + components: ['UserCard', 'UserOrder', 'UserWallet', 'UserCoupon'] + }, + { + name: '营销组件', + extended: true, + components: [ + 'PromotionCombination', + 'PromotionSeckill', + 'PromotionPoint', + 'CouponCard', + 'PromotionArticle' + ] + } +] as DiyComponentLibrary[] diff --git a/src/components/DocAlert/index.vue b/src/components/DocAlert/index.vue new file mode 100644 index 0000000..0073266 --- /dev/null +++ b/src/components/DocAlert/index.vue @@ -0,0 +1,34 @@ +<template> + <el-alert v-if="getEnable()" type="success" show-icon> + <template #title> + <div @click="goToUrl">{{ '【' + title + '】文档地址:' + url }}</div> + </template> + </el-alert> +</template> +<script setup lang="tsx"> +import { propTypes } from '@/utils/propTypes' + +defineOptions({ name: 'DocAlert' }) + +const props = defineProps({ + title: propTypes.string, + url: propTypes.string +}) + +/** 跳转 URL 链接 */ +const goToUrl = () => { + window.open(props.url) +} + +/** 是否开启 */ +const getEnable = () => { + return import.meta.env.VITE_APP_DOCALERT_ENABLE !== 'false' +} +</script> +<style scoped> +.el-alert--success.is-light { + margin-bottom: 10px; + cursor: pointer; + border: 1px solid green; +} +</style> diff --git a/src/components/Draggable/index.vue b/src/components/Draggable/index.vue new file mode 100644 index 0000000..2175946 --- /dev/null +++ b/src/components/Draggable/index.vue @@ -0,0 +1,77 @@ +<template> + <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text> + <VueDraggable + :list="formData" + :force-fallback="true" + :animation="200" + handle=".drag-icon" + class="m-t-8px" + item-key="index" + > + <template #item="{ element, index }"> + <div + class="mb-4px flex flex-col gap-4px border border-gray-2 border-rounded rounded border-solid p-8px" + > + <!-- 操作按钮区 --> + <div class="m--8px m-b-4px flex flex-row items-center justify-between bg-gray-1 p-8px"> + <el-tooltip content="拖动排序"> + <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" /> + </el-tooltip> + <el-tooltip content="删除"> + <Icon + icon="ep:delete" + class="cursor-pointer text-red-5" + v-if="formData.length > 1" + @click="handleDelete(index)" + /> + </el-tooltip> + </div> + <!-- 内容区 --> + <slot :element="element" :index="index"></slot> + </div> + </template> + </VueDraggable> + <el-tooltip :disabled="limit < 1" :content="`最多添加${limit}个`"> + <el-button + type="primary" + plain + class="m-t-4px w-full" + :disabled="limit > 0 && formData.length >= limit" + @click="handleAdd" + > + <Icon icon="ep:plus" /><span>添加</span> + </el-button> + </el-tooltip> +</template> + +<script setup lang="ts"> +// 拖拽组件 +import VueDraggable from 'vuedraggable' +import { usePropertyForm } from '@/components/DiyEditor/util' +import { any, array } from 'vue-types' +import { propTypes } from '@/utils/propTypes' +import { cloneDeep } from 'lodash-es' + +// 拖拽组件封装 +defineOptions({ name: 'Draggable' }) + +// 定义属性 +const props = defineProps({ + // 绑定值 + modelValue: array<any>().isRequired, + // 空的元素:点击添加按钮时,创建元素并添加到列表;默认为空对象 + emptyItem: any<unknown>().def({}), + // 数量限制:默认为0,表示不限制 + limit: propTypes.number.def(0) +}) +// 定义事件 +const emit = defineEmits(['update:modelValue']) +const { formData } = usePropertyForm(props.modelValue, emit) + +// 处理添加 +const handleAdd = () => formData.value.push(cloneDeep(props.emptyItem || {})) +// 处理删除 +const handleDelete = (index: number) => formData.value.splice(index, 1) +</script> + +<style scoped lang="scss"></style> diff --git a/src/components/Echart/index.ts b/src/components/Echart/index.ts new file mode 100644 index 0000000..4822092 --- /dev/null +++ b/src/components/Echart/index.ts @@ -0,0 +1,3 @@ +import Echart from './src/Echart.vue' + +export { Echart } diff --git a/src/components/Echart/src/Echart.vue b/src/components/Echart/src/Echart.vue new file mode 100644 index 0000000..fd3342d --- /dev/null +++ b/src/components/Echart/src/Echart.vue @@ -0,0 +1,115 @@ +<script lang="ts" setup> +import type { EChartsOption } from 'echarts' +import echarts from '@/plugins/echarts' +import { debounce } from 'lodash-es' +import 'echarts-wordcloud' +import { propTypes } from '@/utils/propTypes' +import { PropType } from 'vue' +import { useAppStore } from '@/store/modules/app' +import { isString } from '@/utils/is' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'EChart' }) + +const { getPrefixCls, variables } = useDesign() + +const prefixCls = getPrefixCls('echart') + +const appStore = useAppStore() + +const props = defineProps({ + options: { + type: Object as PropType<EChartsOption>, + required: true + }, + width: propTypes.oneOfType([Number, String]).def(''), + height: propTypes.oneOfType([Number, String]).def('500px') +}) + +const isDark = computed(() => appStore.getIsDark) + +const theme = computed(() => { + const echartTheme: boolean | string = unref(isDark) ? true : 'auto' + + return echartTheme +}) + +const options = computed(() => { + return Object.assign(props.options, { + darkMode: unref(theme) + }) +}) + +const elRef = ref<ElRef>() + +let echartRef: Nullable<echarts.ECharts> = null + +const contentEl = ref<Element>() + +const styles = computed(() => { + const width = isString(props.width) ? props.width : `${props.width}px` + const height = isString(props.height) ? props.height : `${props.height}px` + + return { + width, + height + } +}) + +const initChart = () => { + if (unref(elRef) && props.options) { + echartRef = echarts.init(unref(elRef) as HTMLElement) + echartRef?.setOption(unref(options)) + } +} + +watch( + () => options.value, + (options) => { + if (echartRef) { + echartRef?.setOption(options) + } + }, + { + deep: true + } +) + +const resizeHandler = debounce(() => { + if (echartRef) { + echartRef.resize() + } +}, 100) + +const contentResizeHandler = async (e: TransitionEvent) => { + if (e.propertyName === 'width') { + resizeHandler() + } +} + +onMounted(() => { + initChart() + + window.addEventListener('resize', resizeHandler) + + contentEl.value = document.getElementsByClassName(`${variables.namespace}-layout-content`)[0] + unref(contentEl) && + (unref(contentEl) as Element).addEventListener('transitionend', contentResizeHandler) +}) + +onBeforeUnmount(() => { + window.removeEventListener('resize', resizeHandler) + unref(contentEl) && + (unref(contentEl) as Element).removeEventListener('transitionend', contentResizeHandler) +}) + +onActivated(() => { + if (echartRef) { + echartRef.resize() + } +}) +</script> + +<template> + <div ref="elRef" :class="[$attrs.class, prefixCls]" :style="styles"></div> +</template> diff --git a/src/components/Editor/index.ts b/src/components/Editor/index.ts new file mode 100644 index 0000000..3fbf0a9 --- /dev/null +++ b/src/components/Editor/index.ts @@ -0,0 +1,8 @@ +import Editor from './src/Editor.vue' +import { IDomEditor } from '@wangeditor/editor' + +export interface EditorExpose { + getEditorRef: () => Promise<IDomEditor> +} + +export { Editor } diff --git a/src/components/Editor/src/Editor.vue b/src/components/Editor/src/Editor.vue new file mode 100644 index 0000000..8dd0645 --- /dev/null +++ b/src/components/Editor/src/Editor.vue @@ -0,0 +1,242 @@ +<script lang="ts" setup> +import { PropType } from 'vue' +import { Editor, Toolbar } from '@wangeditor/editor-for-vue' +import { i18nChangeLanguage, IDomEditor, IEditorConfig } from '@wangeditor/editor' +import { propTypes } from '@/utils/propTypes' +import { isNumber } from '@/utils/is' +import { ElMessage } from 'element-plus' +import { useLocaleStore } from '@/store/modules/locale' +import { getAccessToken, getTenantId } from '@/utils/auth' + +defineOptions({ name: 'Editor' }) + +type InsertFnType = (url: string, alt: string, href: string) => void + +const localeStore = useLocaleStore() + +const currentLocale = computed(() => localeStore.getCurrentLocale) + +i18nChangeLanguage(unref(currentLocale).lang) + +const props = defineProps({ + editorId: propTypes.string.def('wangeEditor-1'), + height: propTypes.oneOfType([Number, String]).def('500px'), + editorConfig: { + type: Object as PropType<Partial<IEditorConfig>>, + default: () => undefined + }, + readonly: propTypes.bool.def(false), + modelValue: propTypes.string.def('') +}) + +const emit = defineEmits(['change', 'update:modelValue']) + +// 编辑器实例,必须用 shallowRef +const editorRef = shallowRef<IDomEditor>() + +const valueHtml = ref('') + +watch( + () => props.modelValue, + (val: string) => { + if (val === unref(valueHtml)) return + valueHtml.value = val + }, + { + immediate: true + } +) + +// 监听 +watch( + () => valueHtml.value, + (val: string) => { + emit('update:modelValue', val) + } +) + +const handleCreated = (editor: IDomEditor) => { + editorRef.value = editor +} + +// 编辑器配置 +const editorConfig = computed((): IEditorConfig => { + return Object.assign( + { + placeholder: '请输入内容...', + readOnly: props.readonly, + customAlert: (s: string, t: string) => { + switch (t) { + case 'success': + ElMessage.success(s) + break + case 'info': + ElMessage.info(s) + break + case 'warning': + ElMessage.warning(s) + break + case 'error': + ElMessage.error(s) + break + default: + ElMessage.info(s) + break + } + }, + autoFocus: false, + scroll: true, + MENU_CONF: { + ['uploadImage']: { + server: import.meta.env.VITE_UPLOAD_URL, + // 单个文件的最大体积限制,默认为 2M + maxFileSize: 5 * 1024 * 1024, + // 最多可上传几个文件,默认为 100 + maxNumberOfFiles: 10, + // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 [] + allowedFileTypes: ['image/*'], + + // 自定义增加 http header + headers: { + Accept: '*', + Authorization: 'Bearer ' + getAccessToken(), + 'tenant-id': getTenantId() + }, + + // 超时时间,默认为 10 秒 + timeout: 5 * 1000, // 5 秒 + + // form-data fieldName,后端接口参数名称,默认值wangeditor-uploaded-image + fieldName: 'file', + + // 上传之前触发 + onBeforeUpload(file: File) { + // console.log(file) + return file + }, + // 上传进度的回调函数 + onProgress(progress: number) { + // progress 是 0-100 的数字 + console.log('progress', progress) + }, + onSuccess(file: File, res: any) { + console.log('onSuccess', file, res) + }, + onFailed(file: File, res: any) { + alert(res.message) + console.log('onFailed', file, res) + }, + onError(file: File, err: any, res: any) { + alert(err.message) + console.error('onError', file, err, res) + }, + // 自定义插入图片 + customInsert(res: any, insertFn: InsertFnType) { + insertFn(res.data, 'image', res.data) + } + }, + ['uploadVideo']: { + server: import.meta.env.VITE_UPLOAD_URL, + // 单个文件的最大体积限制,默认为 10M + maxFileSize: 10 * 1024 * 1024, + // 最多可上传几个文件,默认为 100 + maxNumberOfFiles: 10, + // 选择文件时的类型限制,默认为 ['video/*'] 。如不想限制,则设置为 [] + allowedFileTypes: ['video/*'], + + // 自定义增加 http header + headers: { + Accept: '*', + Authorization: 'Bearer ' + getAccessToken(), + 'tenant-id': getTenantId() + }, + + // 超时时间,默认为 30 秒 + timeout: 15 * 1000, // 15 秒 + + // form-data fieldName,后端接口参数名称,默认值wangeditor-uploaded-image + fieldName: 'file', + + // 上传之前触发 + onBeforeUpload(file: File) { + // console.log(file) + return file + }, + // 上传进度的回调函数 + onProgress(progress: number) { + // progress 是 0-100 的数字 + console.log('progress', progress) + }, + onSuccess(file: File, res: any) { + console.log('onSuccess', file, res) + }, + onFailed(file: File, res: any) { + alert(res.message) + console.log('onFailed', file, res) + }, + onError(file: File, err: any, res: any) { + alert(err.message) + console.error('onError', file, err, res) + }, + // 自定义插入图片 + customInsert(res: any, insertFn: InsertFnType) { + insertFn(res.data, 'mp4', res.data) + } + } + }, + uploadImgShowBase64: true + }, + props.editorConfig || {} + ) +}) + +const editorStyle = computed(() => { + return { + height: isNumber(props.height) ? `${props.height}px` : props.height + } +}) + +// 回调函数 +const handleChange = (editor: IDomEditor) => { + emit('change', editor) +} + +// 组件销毁时,及时销毁编辑器 +onBeforeUnmount(() => { + const editor = unref(editorRef.value) + + // 销毁,并移除 editor + editor?.destroy() +}) + +const getEditorRef = async (): Promise<IDomEditor> => { + await nextTick() + return unref(editorRef.value) as IDomEditor +} + +defineExpose({ + getEditorRef +}) +</script> + +<template> + <div class="border-1 border-solid border-[var(--tags-view-border-color)] z-10"> + <!-- 工具栏 --> + <Toolbar + :editor="editorRef" + :editorId="editorId" + class="border-0 b-b-1 border-solid border-[var(--tags-view-border-color)]" + /> + <!-- 编辑器 --> + <Editor + v-model="valueHtml" + :defaultConfig="editorConfig" + :editorId="editorId" + :style="editorStyle" + @on-change="handleChange" + @on-created="handleCreated" + /> + </div> +</template> + +<style src="@wangeditor/editor/dist/css/style.css"></style> diff --git a/src/components/Error/index.ts b/src/components/Error/index.ts new file mode 100644 index 0000000..a52c6f9 --- /dev/null +++ b/src/components/Error/index.ts @@ -0,0 +1,3 @@ +import Error from './src/Error.vue' + +export { Error } diff --git a/src/components/Error/src/Error.vue b/src/components/Error/src/Error.vue new file mode 100644 index 0000000..3fd7a17 --- /dev/null +++ b/src/components/Error/src/Error.vue @@ -0,0 +1,58 @@ +<script lang="ts" setup> +import pageError from '@/assets/svgs/404.svg' +import networkError from '@/assets/svgs/500.svg' +import noPermission from '@/assets/svgs/403.svg' +import { propTypes } from '@/utils/propTypes' + +defineOptions({ name: 'Error' }) + +interface ErrorMap { + url: string + message: string + buttonText: string +} + +const { t } = useI18n() + +const errorMap: { + [key: string]: ErrorMap +} = { + '404': { + url: pageError, + message: t('error.pageError'), + buttonText: t('error.returnToHome') + }, + '500': { + url: networkError, + message: t('error.networkError'), + buttonText: t('error.returnToHome') + }, + '403': { + url: noPermission, + message: t('error.noPermission'), + buttonText: t('error.returnToHome') + } +} + +const props = defineProps({ + type: propTypes.string.validate((v: string) => ['404', '500', '403'].includes(v)).def('404') +}) + +const emit = defineEmits(['errorClick']) + +const btnClick = () => { + emit('errorClick', props.type) +} +</script> + +<template> + <div class="flex justify-center"> + <div class="text-center"> + <img :src="errorMap[type].url" alt="" width="350" /> + <div class="text-14px text-[var(--el-color-info)]">{{ errorMap[type].message }}</div> + <div class="mt-20px"> + <ElButton type="primary" @click="btnClick">{{ errorMap[type].buttonText }}</ElButton> + </div> + </div> + </div> +</template> diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts new file mode 100644 index 0000000..484c7a2 --- /dev/null +++ b/src/components/Form/index.ts @@ -0,0 +1,15 @@ +import Form from './src/Form.vue' +import { ElForm } from 'element-plus' +import { FormSchema, FormSetPropsType } from '@/types/form' + +export interface FormExpose { + setValues: (data: Recordable) => void + setProps: (props: Recordable) => void + delSchema: (field: string) => void + addSchema: (formSchema: FormSchema, index?: number) => void + setSchema: (schemaProps: FormSetPropsType[]) => void + formModel: Recordable + getElFormRef: () => ComponentRef<typeof ElForm> +} + +export { Form } diff --git a/src/components/Form/src/Form.vue b/src/components/Form/src/Form.vue new file mode 100644 index 0000000..3acc10a --- /dev/null +++ b/src/components/Form/src/Form.vue @@ -0,0 +1,307 @@ +<script lang="tsx"> +import { computed, defineComponent, onMounted, PropType, ref, unref, watch } from 'vue' +import { ElCol, ElForm, ElFormItem, ElRow, ElTooltip } from 'element-plus' +import { componentMap } from './componentMap' +import { propTypes } from '@/utils/propTypes' +import { getSlot } from '@/utils/tsxHelper' +import { + initModel, + setComponentProps, + setFormItemSlots, + setGridProp, + setItemComponentSlots, + setTextPlaceholder +} from './helper' +import { useRenderSelect } from './components/useRenderSelect' +import { useRenderRadio } from './components/useRenderRadio' +import { useRenderCheckbox } from './components/useRenderCheckbox' +import { useDesign } from '@/hooks/web/useDesign' +import { findIndex } from '@/utils' +import { set } from 'lodash-es' +import { FormProps } from './types' +import { Icon } from '@/components/Icon' +import { FormSchema, FormSetPropsType } from '@/types/form' + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('form') + +export default defineComponent({ + // eslint-disable-next-line vue/no-reserved-component-names + name: 'Form', + props: { + // 生成Form的布局结构数组 + schema: { + type: Array as PropType<FormSchema[]>, + default: () => [] + }, + // 是否需要栅格布局 + // update by 芋艿:将 true 改成 false,因为项目更常用这种方式 + isCol: propTypes.bool.def(false), + // 表单数据对象 + model: { + type: Object as PropType<Recordable>, + default: () => ({}) + }, + // 是否自动设置placeholder + autoSetPlaceholder: propTypes.bool.def(true), + // 是否自定义内容 + isCustom: propTypes.bool.def(false), + // 表单label宽度 + labelWidth: propTypes.oneOfType([String, Number]).def('auto'), + // 是否 loading 数据中 add by 芋艿 + vLoading: propTypes.bool.def(false) + }, + emits: ['register'], + setup(props, { slots, expose, emit }) { + // element form 实例 + const elFormRef = ref<ComponentRef<typeof ElForm>>() + + // useForm传入的props + const outsideProps = ref<FormProps>({}) + + const mergeProps = ref<FormProps>({}) + + const getProps = computed(() => { + const propsObj = { ...props } + Object.assign(propsObj, unref(mergeProps)) + return propsObj + }) + + // 表单数据 + const formModel = ref<Recordable>({}) + + onMounted(() => { + emit('register', unref(elFormRef)?.$parent, unref(elFormRef)) + }) + + // 对表单赋值 + const setValues = (data: Recordable = {}) => { + formModel.value = Object.assign(unref(formModel), data) + } + + const setProps = (props: FormProps = {}) => { + mergeProps.value = Object.assign(unref(mergeProps), props) + outsideProps.value = props + } + + const delSchema = (field: string) => { + const { schema } = unref(getProps) + + const index = findIndex(schema, (v: FormSchema) => v.field === field) + if (index > -1) { + schema.splice(index, 1) + } + } + + const addSchema = (formSchema: FormSchema, index?: number) => { + const { schema } = unref(getProps) + if (index !== void 0) { + schema.splice(index, 0, formSchema) + return + } + schema.push(formSchema) + } + + const setSchema = (schemaProps: FormSetPropsType[]) => { + const { schema } = unref(getProps) + for (const v of schema) { + for (const item of schemaProps) { + if (v.field === item.field) { + set(v, item.path, item.value) + } + } + } + } + + const getElFormRef = (): ComponentRef<typeof ElForm> => { + return unref(elFormRef) as ComponentRef<typeof ElForm> + } + + expose({ + setValues, + formModel, + setProps, + delSchema, + addSchema, + setSchema, + getElFormRef + }) + + // 监听表单结构化数组,重新生成formModel + watch( + () => unref(getProps).schema, + (schema = []) => { + formModel.value = initModel(schema, unref(formModel)) + }, + { + immediate: true, + deep: true + } + ) + + // 渲染包裹标签,是否使用栅格布局 + const renderWrap = () => { + const { isCol } = unref(getProps) + const content = isCol ? ( + <ElRow gutter={20}>{renderFormItemWrap()}</ElRow> + ) : ( + renderFormItemWrap() + ) + return content + } + + // 是否要渲染el-col + const renderFormItemWrap = () => { + // hidden属性表示隐藏,不做渲染 + const { schema = [], isCol } = unref(getProps) + + return schema + .filter((v) => !v.hidden) + .map((item) => { + // 如果是 Divider 组件,需要自己占用一行 + const isDivider = item.component === 'Divider' + const Com = componentMap['Divider'] as ReturnType<typeof defineComponent> + return isDivider ? ( + <Com {...{ contentPosition: 'left', ...item.componentProps }}>{item?.label}</Com> + ) : isCol ? ( + // 如果需要栅格,需要包裹 ElCol + <ElCol {...setGridProp(item.colProps)}>{renderFormItem(item)}</ElCol> + ) : ( + renderFormItem(item) + ) + }) + } + + // 渲染formItem + const renderFormItem = (item: FormSchema) => { + // 单独给只有options属性的组件做判断 + const notRenderOptions = ['SelectV2', 'Cascader', 'Transfer'] + const slotsMap: Recordable = { + ...setItemComponentSlots(slots, item?.componentProps?.slots, item.field) + } + if ( + item?.component !== 'SelectV2' && + item?.component !== 'Cascader' && + item?.componentProps?.options + ) { + slotsMap.default = () => renderOptions(item) + } + + const formItemSlots: Recordable = setFormItemSlots(slots, item.field) + // 如果有 labelMessage,自动使用插槽渲染 + if (item?.labelMessage) { + formItemSlots.label = () => { + return ( + <> + <span>{item.label}</span> + <ElTooltip placement="right" raw-content> + {{ + content: () => <span v-dompurify-html={item.labelMessage}></span>, + default: () => ( + <Icon + icon="ep:warning" + size={16} + color="var(--el-color-primary)" + class="relative top-1px ml-2px" + ></Icon> + ) + }} + </ElTooltip> + </> + ) + } + } + return ( + <ElFormItem {...(item.formItemProps || {})} prop={item.field} label={item.label || ''}> + {{ + ...formItemSlots, + default: () => { + const Com = componentMap[item.component as string] as ReturnType< + typeof defineComponent + > + + const { autoSetPlaceholder } = unref(getProps) + + return slots[item.field] ? ( + getSlot(slots, item.field, formModel.value) + ) : ( + <Com + vModel={formModel.value[item.field]} + {...(autoSetPlaceholder && setTextPlaceholder(item))} + {...setComponentProps(item)} + style={item.componentProps?.style} + {...(notRenderOptions.includes(item?.component as string) && + item?.componentProps?.options + ? { options: item?.componentProps?.options || [] } + : {})} + > + {{ ...slotsMap }} + </Com> + ) + } + }} + </ElFormItem> + ) + } + + // 渲染options + const renderOptions = (item: FormSchema) => { + switch (item.component) { + case 'Select': + case 'SelectV2': + const { renderSelectOptions } = useRenderSelect(slots) + return renderSelectOptions(item) + case 'Radio': + case 'RadioButton': + const { renderRadioOptions } = useRenderRadio() + return renderRadioOptions(item) + case 'Checkbox': + case 'CheckboxButton': + const { renderCheckboxOptions } = useRenderCheckbox() + return renderCheckboxOptions(item) + default: + break + } + } + + // 过滤传入Form组件的属性 + const getFormBindValue = () => { + // 避免在标签上出现多余的属性 + const delKeys = ['schema', 'isCol', 'autoSetPlaceholder', 'isCustom', 'model'] + const props = { ...unref(getProps) } + for (const key in props) { + if (delKeys.indexOf(key) !== -1) { + delete props[key] + } + } + return props + } + + return () => ( + <ElForm + ref={elFormRef} + {...getFormBindValue()} + model={props.isCustom ? props.model : formModel} + class={prefixCls} + v-loading={props.vLoading} + > + {{ + // 如果需要自定义,就什么都不渲染,而是提供默认插槽 + default: () => { + const { isCustom } = unref(getProps) + return isCustom ? getSlot(slots, 'default') : renderWrap() + } + }} + </ElForm> + ) + } +}) +</script> + +<style lang="scss" scoped> +.#{$elNamespace}-form.#{$namespace}-form .#{$elNamespace}-row { + margin-right: 0 !important; + margin-left: 0 !important; +} +</style> diff --git a/src/components/Form/src/componentMap.ts b/src/components/Form/src/componentMap.ts new file mode 100644 index 0000000..5af9b40 --- /dev/null +++ b/src/components/Form/src/componentMap.ts @@ -0,0 +1,55 @@ +import type { Component } from 'vue' +import { + ElCascader, + ElCheckboxGroup, + ElColorPicker, + ElDatePicker, + ElInput, + ElInputNumber, + ElRadioGroup, + ElRate, + ElSelect, + ElSelectV2, + ElTreeSelect, + ElSlider, + ElSwitch, + ElTimePicker, + ElTimeSelect, + ElTransfer, + ElAutocomplete, + ElDivider +} from 'element-plus' +import { InputPassword } from '@/components/InputPassword' +import { Editor } from '@/components/Editor' +import { UploadImg, UploadImgs, UploadFile } from '@/components/UploadFile' +import { ComponentName } from '@/types/components' + +const componentMap: Recordable<Component, ComponentName> = { + Radio: ElRadioGroup, + Checkbox: ElCheckboxGroup, + CheckboxButton: ElCheckboxGroup, + Input: ElInput, + Autocomplete: ElAutocomplete, + InputNumber: ElInputNumber, + Select: ElSelect, + Cascader: ElCascader, + Switch: ElSwitch, + Slider: ElSlider, + TimePicker: ElTimePicker, + DatePicker: ElDatePicker, + Rate: ElRate, + ColorPicker: ElColorPicker, + Transfer: ElTransfer, + Divider: ElDivider, + TimeSelect: ElTimeSelect, + SelectV2: ElSelectV2, + TreeSelect: ElTreeSelect, + RadioButton: ElRadioGroup, + InputPassword: InputPassword, + Editor: Editor, + UploadImg: UploadImg, + UploadImgs: UploadImgs, + UploadFile: UploadFile +} + +export { componentMap } diff --git a/src/components/Form/src/components/useRenderCheckbox.tsx b/src/components/Form/src/components/useRenderCheckbox.tsx new file mode 100644 index 0000000..e151839 --- /dev/null +++ b/src/components/Form/src/components/useRenderCheckbox.tsx @@ -0,0 +1,26 @@ +import { FormSchema } from '@/types/form' +import { ElCheckbox, ElCheckboxButton } from 'element-plus' +import { defineComponent } from 'vue' + +export const useRenderCheckbox = () => { + const renderCheckboxOptions = (item: FormSchema) => { + // 如果有别名,就取别名 + const labelAlias = item?.componentProps?.optionsAlias?.labelField + const valueAlias = item?.componentProps?.optionsAlias?.valueField + const Com = (item.component === 'Checkbox' ? ElCheckbox : ElCheckboxButton) as ReturnType< + typeof defineComponent + > + return item?.componentProps?.options?.map((option) => { + const { ...other } = option + return ( + <Com {...other} label={option[valueAlias || 'value']}> + {option[labelAlias || 'label']} + </Com> + ) + }) + } + + return { + renderCheckboxOptions + } +} diff --git a/src/components/Form/src/components/useRenderRadio.tsx b/src/components/Form/src/components/useRenderRadio.tsx new file mode 100644 index 0000000..d1005ca --- /dev/null +++ b/src/components/Form/src/components/useRenderRadio.tsx @@ -0,0 +1,26 @@ +import { FormSchema } from '@/types/form' +import { ElRadio, ElRadioButton } from 'element-plus' +import { defineComponent } from 'vue' + +export const useRenderRadio = () => { + const renderRadioOptions = (item: FormSchema) => { + // 如果有别名,就取别名 + const labelAlias = item?.componentProps?.optionsAlias?.labelField + const valueAlias = item?.componentProps?.optionsAlias?.valueField + const Com = (item.component === 'Radio' ? ElRadio : ElRadioButton) as ReturnType< + typeof defineComponent + > + return item?.componentProps?.options?.map((option) => { + const { ...other } = option + return ( + <Com {...other} label={option[valueAlias || 'value']}> + {option[labelAlias || 'label']} + </Com> + ) + }) + } + + return { + renderRadioOptions + } +} diff --git a/src/components/Form/src/components/useRenderSelect.tsx b/src/components/Form/src/components/useRenderSelect.tsx new file mode 100644 index 0000000..59b72e6 --- /dev/null +++ b/src/components/Form/src/components/useRenderSelect.tsx @@ -0,0 +1,57 @@ +import { FormSchema } from '@/types/form' +import { ComponentOptions } from '@/types/components' +import { ElOption, ElOptionGroup } from 'element-plus' +import { getSlot } from '@/utils/tsxHelper' +import { Slots } from 'vue' + +export const useRenderSelect = (slots: Slots) => { + // 渲染 select options + const renderSelectOptions = (item: FormSchema) => { + // 如果有别名,就取别名 + const labelAlias = item?.componentProps?.optionsAlias?.labelField + return item?.componentProps?.options?.map((option) => { + if (option?.options?.length) { + return ( + <ElOptionGroup label={option[labelAlias || 'label']}> + {() => { + return option?.options?.map((v) => { + return renderSelectOptionItem(item, v) + }) + }} + </ElOptionGroup> + ) + } else { + return renderSelectOptionItem(item, option) + } + }) + } + + // 渲染 select option item + const renderSelectOptionItem = (item: FormSchema, option: ComponentOptions) => { + // 如果有别名,就取别名 + const labelAlias = item?.componentProps?.optionsAlias?.labelField + const valueAlias = item?.componentProps?.optionsAlias?.valueField + + const { label, value, ...other } = option + + return ( + <ElOption + {...other} + label={labelAlias ? option[labelAlias] : label} + value={valueAlias ? option[valueAlias] : value} + > + {{ + default: () => + // option 插槽名规则,{field}-option + item?.componentProps?.optionsSlot + ? getSlot(slots, `${item.field}-option`, { item: option }) + : undefined + }} + </ElOption> + ) + } + + return { + renderSelectOptions + } +} diff --git a/src/components/Form/src/helper.ts b/src/components/Form/src/helper.ts new file mode 100644 index 0000000..cdfc8ca --- /dev/null +++ b/src/components/Form/src/helper.ts @@ -0,0 +1,148 @@ +import type { Slots } from 'vue' +import { getSlot } from '@/utils/tsxHelper' +import { PlaceholderModel } from './types' +import { FormSchema } from '@/types/form' +import { ColProps } from '@/types/components' + +/** + * + * @param schema 对应组件数据 + * @returns 返回提示信息对象 + * @description 用于自动设置placeholder + */ +export const setTextPlaceholder = (schema: FormSchema): PlaceholderModel => { + const { t } = useI18n() + const textMap = ['Input', 'Autocomplete', 'InputNumber', 'InputPassword'] + const selectMap = ['Select', 'SelectV2', 'TimePicker', 'DatePicker', 'TimeSelect', 'TimeSelect'] + if (textMap.includes(schema?.component as string)) { + return { + placeholder: t('common.inputText') + schema.label + } + } + if (selectMap.includes(schema?.component as string)) { + // 一些范围选择器 + const twoTextMap = ['datetimerange', 'daterange', 'monthrange', 'datetimerange', 'daterange'] + if ( + twoTextMap.includes( + (schema?.componentProps?.type || schema?.componentProps?.isRange) as string + ) + ) { + return { + startPlaceholder: t('common.startTimeText'), + endPlaceholder: t('common.endTimeText'), + rangeSeparator: '-' + } + } else { + return { + placeholder: t('common.selectText') + schema.label + } + } + } + return {} +} + +/** + * + * @param col 内置栅格 + * @returns 返回栅格属性 + * @description 合并传入进来的栅格属性 + */ +export const setGridProp = (col: ColProps = {}): ColProps => { + const colProps: ColProps = { + // 如果有span,代表用户优先级更高,所以不需要默认栅格 + ...(col.span + ? {} + : { + xs: 24, + sm: 12, + md: 12, + lg: 12, + xl: 12 + }), + ...col + } + return colProps +} + +/** + * + * @param item 传入的组件属性 + * @returns 默认添加 clearable 属性 + */ +export const setComponentProps = (item: FormSchema): Recordable => { + const notNeedClearable = ['ColorPicker'] + const componentProps: Recordable = notNeedClearable.includes(item.component as string) + ? { ...item.componentProps } + : { + clearable: true, + ...item.componentProps + } + // 需要删除额外的属性 + delete componentProps?.slots + return componentProps +} + +/** + * + * @param slots 插槽 + * @param slotsProps 插槽属性 + * @param field 字段名 + */ +export const setItemComponentSlots = ( + slots: Slots, + slotsProps: Recordable = {}, + field: string +): Recordable => { + const slotObj: Recordable = {} + for (const key in slotsProps) { + if (slotsProps[key]) { + // 由于组件有可能重复,需要有一个唯一的前缀 + slotObj[key] = (data: Recordable) => { + return getSlot(slots, `${field}-${key}`, data) + } + } + } + return slotObj +} + +/** + * + * @param schema Form表单结构化数组 + * @param formModel FormModel + * @returns FormModel + * @description 生成对应的formModel + */ +export const initModel = (schema: FormSchema[], formModel: Recordable) => { + const model: Recordable = { ...formModel } + schema.map((v) => { + // 如果是hidden,就删除对应的值 + if (v.hidden) { + delete model[v.field] + } else if (v.component && v.component !== 'Divider') { + const hasField = Reflect.has(model, v.field) + // 如果先前已经有值存在,则不进行重新赋值,而是采用现有的值 + model[v.field] = hasField ? model[v.field] : v.value !== void 0 ? v.value : '' + } + }) + return model +} + +/** + * @param slots 插槽 + * @param field 字段名 + * @returns 返回FormIiem插槽 + */ +export const setFormItemSlots = (slots: Slots, field: string): Recordable => { + const slotObj: Recordable = {} + if (slots[`${field}-error`]) { + slotObj['error'] = (data: Recordable) => { + return getSlot(slots, `${field}-error`, data) + } + } + if (slots[`${field}-label`]) { + slotObj['label'] = (data: Recordable) => { + return getSlot(slots, `${field}-label`, data) + } + } + return slotObj +} diff --git a/src/components/Form/src/types.ts b/src/components/Form/src/types.ts new file mode 100644 index 0000000..dcd01e7 --- /dev/null +++ b/src/components/Form/src/types.ts @@ -0,0 +1,17 @@ +import { FormSchema } from '@/types/form' + +export interface PlaceholderModel { + placeholder?: string + startPlaceholder?: string + endPlaceholder?: string + rangeSeparator?: string +} + +export type FormProps = { + schema?: FormSchema[] + isCol?: boolean + model?: Recordable + autoSetPlaceholder?: boolean + isCustom?: boolean + labelWidth?: string | number +} & Recordable diff --git a/src/components/FormCreate/index.ts b/src/components/FormCreate/index.ts new file mode 100644 index 0000000..9d32778 --- /dev/null +++ b/src/components/FormCreate/index.ts @@ -0,0 +1,4 @@ +import { useFormCreateDesigner } from './src/useFormCreateDesigner' +import { useApiSelect } from './src/components/useApiSelect' + +export { useFormCreateDesigner, useApiSelect } diff --git a/src/components/FormCreate/src/components/DictSelect.vue b/src/components/FormCreate/src/components/DictSelect.vue new file mode 100644 index 0000000..204746d --- /dev/null +++ b/src/components/FormCreate/src/components/DictSelect.vue @@ -0,0 +1,59 @@ +<!-- 数据字典 Select 选择器 --> +<template> + <el-select v-if="selectType === 'select'" class="w-1/1" v-bind="attrs"> + <el-option + v-for="(dict, index) in getDictOptions" + :key="index" + :label="dict.label" + :value="dict.value" + /> + </el-select> + <el-radio-group v-if="selectType === 'radio'" class="w-1/1" v-bind="attrs"> + <el-radio v-for="(dict, index) in getDictOptions" :key="index" :value="dict.value"> + {{ dict.label }} + </el-radio> + </el-radio-group> + <el-checkbox-group v-if="selectType === 'checkbox'" class="w-1/1" v-bind="attrs"> + <el-checkbox + v-for="(dict, index) in getDictOptions" + :key="index" + :label="dict.label" + :value="dict.value" + /> + </el-checkbox-group> +</template> + +<script lang="ts" setup> +import { getBoolDictOptions, getIntDictOptions, getStrDictOptions } from '@/utils/dict' + +defineOptions({ name: 'DictSelect' }) + +const attrs = useAttrs() + +// 接受父组件参数 +interface Props { + dictType: string // 字典类型 + valueType?: 'str' | 'int' | 'bool' // 字典值类型 + selectType?: 'select' | 'radio' | 'checkbox' // 选择器类型,下拉框 select、多选框 checkbox、单选框 radio + formCreateInject?: any +} + +const props = withDefaults(defineProps<Props>(), { + valueType: 'str', + selectType: 'select' +}) + +// 获得字典配置 +const getDictOptions = computed(() => { + switch (props.valueType) { + case 'str': + return getStrDictOptions(props.dictType) + case 'int': + return getIntDictOptions(props.dictType) + case 'bool': + return getBoolDictOptions(props.dictType) + default: + return [] + } +}) +</script> diff --git a/src/components/FormCreate/src/components/useApiSelect.tsx b/src/components/FormCreate/src/components/useApiSelect.tsx new file mode 100644 index 0000000..29cd302 --- /dev/null +++ b/src/components/FormCreate/src/components/useApiSelect.tsx @@ -0,0 +1,248 @@ +import request from '@/config/axios' +import { isEmpty } from '@/utils/is' +import { ApiSelectProps } from '@/components/FormCreate/src/type' +import { jsonParse } from '@/utils' + +export const useApiSelect = (option: ApiSelectProps) => { + return defineComponent({ + name: option.name, + props: { + // 选项标签 + labelField: { + type: String, + default: () => option.labelField ?? 'label' + }, + // 选项的值 + valueField: { + type: String, + default: () => option.valueField ?? 'value' + }, + // api 接口 + url: { + type: String, + default: () => option.url ?? '' + }, + // 请求类型 + method: { + type: String, + default: 'GET' + }, + // 选项解析函数 + parseFunc: { + type: String, + default: '' + }, + // 请求参数 + data: { + type: String, + default: '' + }, + // 选择器类型,下拉框 select、多选框 checkbox、单选框 radio + selectType: { + type: String, + default: 'select' + }, + // 是否多选 + multiple: { + type: Boolean, + default: false + }, + // 是否远程搜索 + remote: { + type: Boolean, + default: false + }, + // 远程搜索时携带的参数 + remoteField: { + type: String, + default: 'label' + } + }, + setup(props) { + const attrs = useAttrs() + const options = ref<any[]>([]) // 下拉数据 + const loading = ref(false) // 是否正在从远程获取数据 + const queryParam = ref<any>() // 当前输入的值 + const getOptions = async () => { + options.value = [] + // 接口选择器 + if (isEmpty(props.url)) { + return + } + switch (props.method) { + case 'GET': + let url: string = props.url + if (props.remote) { + url = `${url}?${props.remoteField}=${queryParam.value}` + } + parseOptions(await request.get({ url: url })) + break + case 'POST': + const data: any = jsonParse(props.data) + if (props.remote) { + data[props.remoteField] = queryParam.value + } + parseOptions(await request.post({ url: props.url, data: data })) + break + } + } + + function parseOptions(data: any) { + // 情况一:如果有自定义解析函数优先使用自定义解析 + if (!isEmpty(props.parseFunc)) { + options.value = parseFunc()?.(data) + return + } + // 情况二:返回的直接是一个列表 + if (Array.isArray(data)) { + parseOptions0(data) + return + } + // 情况二:返回的是分页数据,尝试读取 list + data = data.list + if (!!data && Array.isArray(data)) { + parseOptions0(data) + return + } + // 情况三:不是 yudao-vue-pro 标准返回 + console.warn( + `接口[${props.url}] 返回结果不是 yudao-vue-pro 标准返回建议采用自定义解析函数处理` + ) + } + + function parseOptions0(data: any[]) { + if (Array.isArray(data)) { + options.value = data.map((item: any) => ({ + label: parseExpression(item, props.labelField), + value: parseExpression(item, props.valueField) + })) + return + } + console.warn(`接口[${props.url}] 返回结果不是一个数组`) + } + + function parseFunc() { + let parse: any = null + if (!!props.parseFunc) { + // 解析字符串函数 + parse = new Function(`return ${props.parseFunc}`)() + } + return parse + } + + function parseExpression(data: any, template: string) { + // 检测是否使用了表达式 + if (template.indexOf('${') === -1) { + return data[template] + } + // 正则表达式匹配模板字符串中的 ${...} + const pattern = /\$\{([^}]*)}/g + // 使用replace函数配合正则表达式和回调函数来进行替换 + return template.replace(pattern, (_, expr) => { + // expr 是匹配到的 ${} 内的表达式(这里是属性名),从 data 中获取对应的值 + const result = data[expr.trim()] // 去除前后空白,以防用户输入带空格的属性名 + if (!result) { + console.warn( + `接口选择器选项模版[${template}][${expr.trim()}] 解析值失败结果为[${result}], 请检查属性名称是否存在于接口返回值中,存在则忽略此条!!!` + ) + } + return result + }) + } + + const remoteMethod = async (query: any) => { + if (!query) { + return + } + loading.value = true + try { + queryParam.value = query + await getOptions() + } finally { + loading.value = false + } + } + + onMounted(async () => { + await getOptions() + }) + + const buildSelect = () => { + if (props.multiple) { + // fix:多写此步是为了解决 multiple 属性问题 + return ( + <el-select + class="w-1/1" + multiple + loading={loading.value} + {...attrs} + remote={props.remote} + {...(props.remote && { remoteMethod: remoteMethod })} + > + {options.value.map((item, index) => ( + <el-option key={index} label={item.label} value={item.value} /> + ))} + </el-select> + ) + } + debugger + return ( + <el-select + class="w-1/1" + loading={loading.value} + {...attrs} + remote={props.remote} + {...(props.remote && { remoteMethod: remoteMethod })} + > + {options.value.map((item, index) => ( + <el-option key={index} label={item.label} value={item.value} /> + ))} + </el-select> + ) + } + const buildCheckbox = () => { + if (isEmpty(options.value)) { + options.value = [ + { label: '选项1', value: '选项1' }, + { label: '选项2', value: '选项2' } + ] + } + return ( + <el-checkbox-group class="w-1/1" {...attrs}> + {options.value.map((item, index) => ( + <el-checkbox key={index} label={item.label} value={item.value} /> + ))} + </el-checkbox-group> + ) + } + const buildRadio = () => { + if (isEmpty(options.value)) { + options.value = [ + { label: '选项1', value: '选项1' }, + { label: '选项2', value: '选项2' } + ] + } + return ( + <el-radio-group class="w-1/1" {...attrs}> + {options.value.map((item, index) => ( + <el-radio key={index} value={item.value}> + {item.label} + </el-radio> + ))} + </el-radio-group> + ) + } + return () => ( + <> + {props.selectType === 'select' + ? buildSelect() + : props.selectType === 'radio' + ? buildRadio() + : props.selectType === 'checkbox' + ? buildCheckbox() + : buildSelect()} + </> + ) + } + }) +} diff --git a/src/components/FormCreate/src/config/index.ts b/src/components/FormCreate/src/config/index.ts new file mode 100644 index 0000000..b1e2dde --- /dev/null +++ b/src/components/FormCreate/src/config/index.ts @@ -0,0 +1,15 @@ +import { useUploadFileRule } from './useUploadFileRule' +import { useUploadImgRule } from './useUploadImgRule' +import { useUploadImgsRule } from './useUploadImgsRule' +import { useDictSelectRule } from './useDictSelectRule' +import { useEditorRule } from './useEditorRule' +import { useSelectRule } from './useSelectRule' + +export { + useUploadFileRule, + useUploadImgRule, + useUploadImgsRule, + useDictSelectRule, + useEditorRule, + useSelectRule +} diff --git a/src/components/FormCreate/src/config/selectRule.ts b/src/components/FormCreate/src/config/selectRule.ts new file mode 100644 index 0000000..a6f3841 --- /dev/null +++ b/src/components/FormCreate/src/config/selectRule.ts @@ -0,0 +1,181 @@ +const selectRule = [ + { + type: 'select', + field: 'selectType', + title: '选择器类型', + value: 'select', + options: [ + { label: '下拉框', value: 'select' }, + { label: '单选框', value: 'radio' }, + { label: '多选框', value: 'checkbox' } + ], + // 参考 https://www.form-create.com/v3/guide/control 组件联动,单选框和多选框不需要多选属性 + control: [ + { + value: 'select', + condition: '==', + method: 'hidden', + rule: [ + 'multiple', + 'clearable', + 'collapseTags', + 'multipleLimit', + 'allowCreate', + 'filterable', + 'noMatchText', + 'remote', + 'remoteMethod', + 'reserveKeyword', + 'defaultFirstOption', + 'automaticDropdown' + ] + } + ] + }, + { + type: 'switch', + field: 'filterable', + title: '是否可搜索' + }, + { type: 'switch', field: 'multiple', title: '是否多选' }, + { + type: 'switch', + field: 'disabled', + title: '是否禁用' + }, + { type: 'switch', field: 'clearable', title: '是否可以清空选项' }, + { + type: 'switch', + field: 'collapseTags', + title: '多选时是否将选中值按文字的形式展示' + }, + { + type: 'inputNumber', + field: 'multipleLimit', + title: '多选时用户最多可以选择的项目数,为 0 则不限制', + props: { min: 0 } + }, + { + type: 'input', + field: 'autocomplete', + title: 'autocomplete 属性' + }, + { type: 'input', field: 'placeholder', title: '占位符' }, + { type: 'switch', field: 'allowCreate', title: '是否允许用户创建新条目' }, + { + type: 'input', + field: 'noMatchText', + title: '搜索条件无匹配时显示的文字' + }, + { type: 'input', field: 'noDataText', title: '选项为空时显示的文字' }, + { + type: 'switch', + field: 'reserveKeyword', + title: '多选且可搜索时,是否在选中一个选项后保留当前的搜索关键词' + }, + { + type: 'switch', + field: 'defaultFirstOption', + title: '在输入框按下回车,选择第一个匹配项' + }, + { + type: 'switch', + field: 'popperAppendToBody', + title: '是否将弹出框插入至 body 元素', + value: true + }, + { + type: 'switch', + field: 'automaticDropdown', + title: '对于不可搜索的 Select,是否在输入框获得焦点后自动弹出选项菜单' + } +] + +const apiSelectRule = [ + { + type: 'input', + field: 'url', + title: 'url 地址', + props: { + placeholder: '/system/user/simple-list' + } + }, + { + type: 'select', + field: 'method', + title: '请求类型', + value: 'GET', + options: [ + { label: 'GET', value: 'GET' }, + { label: 'POST', value: 'POST' } + ], + control: [ + { + value: 'GET', + condition: '!=', + method: 'hidden', + rule: [ + { + type: 'input', + field: 'data', + title: '请求参数 JSON 格式', + props: { + autosize: true, + type: 'textarea', + placeholder: '{"type": 1}' + } + } + ] + } + ] + }, + { + type: 'input', + field: 'labelField', + title: 'label 属性', + info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}', + props: { + placeholder: 'nickname' + } + }, + { + type: 'input', + field: 'valueField', + title: 'value 属性', + info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}', + props: { + placeholder: 'id' + } + }, + { + type: 'input', + field: 'parseFunc', + title: '选项解析函数', + info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表 + (data: any)=>{ label: string; value: any }[]`, + props: { + autosize: true, + rows: { minRows: 2, maxRows: 6 }, + type: 'textarea', + placeholder: ` + function (data) { + console.log(data) + return data.list.map(item=> ({label: item.nickname,value: item.id})) + }` + } + }, + { + type: 'switch', + field: 'remote', + info: '是否可搜索', + title: '其中的选项是否从服务器远程加载' + }, + { + type: 'input', + field: 'remoteField', + title: '请求参数', + info: '远程请求时请求携带的参数名称,如:name' + } +] + +export { selectRule, apiSelectRule } diff --git a/src/components/FormCreate/src/config/useDictSelectRule.ts b/src/components/FormCreate/src/config/useDictSelectRule.ts new file mode 100644 index 0000000..5c5e8ca --- /dev/null +++ b/src/components/FormCreate/src/config/useDictSelectRule.ts @@ -0,0 +1,64 @@ +import { generateUUID } from '@/utils' +import * as DictDataApi from '@/api/system/dict/dict.type' +import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils' +import { selectRule } from '@/components/FormCreate/src/config/selectRule' +import { cloneDeep } from 'lodash-es' + +/** + * 字典选择器规则,如果规则使用到动态数据则需要单独配置不能使用 useSelectRule + */ +export const useDictSelectRule = () => { + const label = '字典选择器' + const name = 'DictSelect' + const rules = cloneDeep(selectRule) + const dictOptions = ref<{ label: string; value: string }[]>([]) // 字典类型下拉数据 + onMounted(async () => { + const data = await DictDataApi.getSimpleDictTypeList() + if (!data || data.length === 0) { + return + } + dictOptions.value = + data?.map((item: DictDataApi.DictTypeVO) => ({ + label: item.name, + value: item.type + })) ?? [] + }) + return { + icon: 'icon-doc-text', + label, + name, + rule() { + return { + type: name, + field: generateUUID(), + title: label, + info: '', + $required: false + } + }, + props(_, { t }) { + return localeProps(t, name + '.props', [ + makeRequiredRule(), + { + type: 'select', + field: 'dictType', + title: '字典类型', + value: '', + options: dictOptions.value + }, + { + type: 'select', + field: 'dictValueType', + title: '字典值类型', + value: 'str', + options: [ + { label: '数字', value: 'int' }, + { label: '字符串', value: 'str' }, + { label: '布尔值', value: 'bool' } + ] + }, + ...rules + ]) + } + } +} diff --git a/src/components/FormCreate/src/config/useEditorRule.ts b/src/components/FormCreate/src/config/useEditorRule.ts new file mode 100644 index 0000000..ac6d9ac --- /dev/null +++ b/src/components/FormCreate/src/config/useEditorRule.ts @@ -0,0 +1,32 @@ +import { generateUUID } from '@/utils' +import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils' + +export const useEditorRule = () => { + const label = '富文本' + const name = 'Editor' + return { + icon: 'icon-editor', + label, + name, + rule() { + return { + type: name, + field: generateUUID(), + title: label, + info: '', + $required: false + } + }, + props(_, { t }) { + return localeProps(t, name + '.props', [ + makeRequiredRule(), + { + type: 'input', + field: 'height', + title: '高度' + }, + { type: 'switch', field: 'readonly', title: '是否只读' } + ]) + } + } +} diff --git a/src/components/FormCreate/src/config/useSelectRule.ts b/src/components/FormCreate/src/config/useSelectRule.ts new file mode 100644 index 0000000..ff21a22 --- /dev/null +++ b/src/components/FormCreate/src/config/useSelectRule.ts @@ -0,0 +1,36 @@ +import { generateUUID } from '@/utils' +import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils' +import { selectRule } from '@/components/FormCreate/src/config/selectRule' +import { SelectRuleOption } from '@/components/FormCreate/src/type' +import { cloneDeep } from 'lodash-es' + +/** + * 通用选择器规则 hook + * + * @param option 规则配置 + */ +export const useSelectRule = (option: SelectRuleOption) => { + const label = option.label + const name = option.name + const rules = cloneDeep(selectRule) + return { + icon: option.icon, + label, + name, + rule() { + return { + type: name, + field: generateUUID(), + title: label, + info: '', + $required: false + } + }, + props(_, { t }) { + if (!option.props) { + option.props = [] + } + return localeProps(t, name + '.props', [makeRequiredRule(), ...option.props, ...rules]) + } + } +} diff --git a/src/components/FormCreate/src/config/useUploadFileRule.ts b/src/components/FormCreate/src/config/useUploadFileRule.ts new file mode 100644 index 0000000..a1ea85e --- /dev/null +++ b/src/components/FormCreate/src/config/useUploadFileRule.ts @@ -0,0 +1,80 @@ +import { generateUUID } from '@/utils' +import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils' + +export const useUploadFileRule = () => { + const label = '文件上传' + const name = 'UploadFile' + return { + icon: 'icon-upload', + label, + name, + rule() { + return { + type: name, + field: generateUUID(), + title: label, + info: '', + $required: false + } + }, + props(_, { t }) { + return localeProps(t, name + '.props', [ + makeRequiredRule(), + { + type: 'select', + field: 'fileType', + title: '文件类型', + value: ['doc', 'xls', 'ppt', 'txt', 'pdf'], + options: [ + { label: 'doc', value: 'doc' }, + { label: 'xls', value: 'xls' }, + { label: 'ppt', value: 'ppt' }, + { label: 'txt', value: 'txt' }, + { label: 'pdf', value: 'pdf' } + ], + props: { + multiple: true + } + }, + { + type: 'switch', + field: 'autoUpload', + title: '是否在选取文件后立即进行上传', + value: true + }, + { + type: 'switch', + field: 'drag', + title: '拖拽上传', + value: false + }, + { + type: 'switch', + field: 'isShowTip', + title: '是否显示提示', + value: true + }, + { + type: 'inputNumber', + field: 'fileSize', + title: '大小限制(MB)', + value: 5, + props: { min: 0 } + }, + { + type: 'inputNumber', + field: 'limit', + title: '数量限制', + value: 5, + props: { min: 0 } + }, + { + type: 'switch', + field: 'disabled', + title: '是否禁用', + value: false + } + ]) + } + } +} diff --git a/src/components/FormCreate/src/config/useUploadImgRule.ts b/src/components/FormCreate/src/config/useUploadImgRule.ts new file mode 100644 index 0000000..546cf9d --- /dev/null +++ b/src/components/FormCreate/src/config/useUploadImgRule.ts @@ -0,0 +1,89 @@ +import { generateUUID } from '@/utils' +import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils' + +export const useUploadImgRule = () => { + const label = '单图上传' + const name = 'UploadImg' + return { + icon: 'icon-upload', + label, + name, + rule() { + return { + type: name, + field: generateUUID(), + title: label, + info: '', + $required: false + } + }, + props(_, { t }) { + return localeProps(t, name + '.props', [ + makeRequiredRule(), + { + type: 'switch', + field: 'drag', + title: '拖拽上传', + value: false + }, + { + type: 'select', + field: 'fileType', + title: '图片类型限制', + value: ['image/jpeg', 'image/png', 'image/gif'], + options: [ + { label: 'image/apng', value: 'image/apng' }, + { label: 'image/bmp', value: 'image/bmp' }, + { label: 'image/gif', value: 'image/gif' }, + { label: 'image/jpeg', value: 'image/jpeg' }, + { label: 'image/pjpeg', value: 'image/pjpeg' }, + { label: 'image/svg+xml', value: 'image/svg+xml' }, + { label: 'image/tiff', value: 'image/tiff' }, + { label: 'image/webp', value: 'image/webp' }, + { label: 'image/x-icon', value: 'image/x-icon' } + ], + props: { + multiple: true + } + }, + { + type: 'inputNumber', + field: 'fileSize', + title: '大小限制(MB)', + value: 5, + props: { min: 0 } + }, + { + type: 'input', + field: 'height', + title: '组件高度', + value: '150px' + }, + { + type: 'input', + field: 'width', + title: '组件宽度', + value: '150px' + }, + { + type: 'input', + field: 'borderradius', + title: '组件边框圆角', + value: '8px' + }, + { + type: 'switch', + field: 'disabled', + title: '是否显示删除按钮', + value: true + }, + { + type: 'switch', + field: 'showBtnText', + title: '是否显示按钮文字', + value: true + } + ]) + } + } +} diff --git a/src/components/FormCreate/src/config/useUploadImgsRule.ts b/src/components/FormCreate/src/config/useUploadImgsRule.ts new file mode 100644 index 0000000..0bf2378 --- /dev/null +++ b/src/components/FormCreate/src/config/useUploadImgsRule.ts @@ -0,0 +1,84 @@ +import { generateUUID } from '@/utils' +import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils' + +export const useUploadImgsRule = () => { + const label = '多图上传' + const name = 'UploadImgs' + return { + icon: 'icon-upload', + label, + name, + rule() { + return { + type: name, + field: generateUUID(), + title: label, + info: '', + $required: false + } + }, + props(_, { t }) { + return localeProps(t, name + '.props', [ + makeRequiredRule(), + { + type: 'switch', + field: 'drag', + title: '拖拽上传', + value: false + }, + { + type: 'select', + field: 'fileType', + title: '图片类型限制', + value: ['image/jpeg', 'image/png', 'image/gif'], + options: [ + { label: 'image/apng', value: 'image/apng' }, + { label: 'image/bmp', value: 'image/bmp' }, + { label: 'image/gif', value: 'image/gif' }, + { label: 'image/jpeg', value: 'image/jpeg' }, + { label: 'image/pjpeg', value: 'image/pjpeg' }, + { label: 'image/svg+xml', value: 'image/svg+xml' }, + { label: 'image/tiff', value: 'image/tiff' }, + { label: 'image/webp', value: 'image/webp' }, + { label: 'image/x-icon', value: 'image/x-icon' } + ], + props: { + multiple: true + } + }, + { + type: 'inputNumber', + field: 'fileSize', + title: '大小限制(MB)', + value: 5, + props: { min: 0 } + }, + { + type: 'inputNumber', + field: 'limit', + title: '数量限制', + value: 5, + props: { min: 0 } + }, + { + type: 'input', + field: 'height', + title: '组件高度', + value: '150px' + }, + { + type: 'input', + field: 'width', + title: '组件宽度', + value: '150px' + }, + { + type: 'input', + field: 'borderradius', + title: '组件边框圆角', + value: '8px' + } + ]) + } + } +} diff --git a/src/components/FormCreate/src/type/index.ts b/src/components/FormCreate/src/type/index.ts new file mode 100644 index 0000000..42dccc7 --- /dev/null +++ b/src/components/FormCreate/src/type/index.ts @@ -0,0 +1,50 @@ +import { Rule } from '@form-create/element-ui' //左侧拖拽按钮 + +// 左侧拖拽按钮 +export interface MenuItem { + label: string + name: string + icon: string +} + +// 左侧拖拽按钮分类 +export interface Menu { + title: string + name: string + list: MenuItem[] +} + +export interface MenuList extends Array<Menu> {} + +// 拖拽组件的规则 +export interface DragRule { + icon: string + name: string + label: string + children?: string + inside?: true + drag?: true | String + dragBtn?: false + mask?: false + + rule(): Rule + + props(v: any, v1: any): Rule[] +} + +// 通用下拉组件 Props 类型 +export interface ApiSelectProps { + name: string // 组件名称 + labelField?: string // 选项标签 + valueField?: string // 选项的值 + url?: string // url 接口 + isDict?: boolean // 是否字典选择器 +} + +// 选择组件规则配置类型 +export interface SelectRuleOption { + label: string // label 名称 + name: string // 组件名称 + icon: string // 组件图标 + props?: any[] // 组件规则 +} diff --git a/src/components/FormCreate/src/useFormCreateDesigner.ts b/src/components/FormCreate/src/useFormCreateDesigner.ts new file mode 100644 index 0000000..53fee78 --- /dev/null +++ b/src/components/FormCreate/src/useFormCreateDesigner.ts @@ -0,0 +1,100 @@ +import { + useDictSelectRule, + useEditorRule, + useSelectRule, + useUploadFileRule, + useUploadImgRule, + useUploadImgsRule +} from './config' +import { Ref } from 'vue' +import { Menu } from '@/components/FormCreate/src/type' +import { apiSelectRule } from '@/components/FormCreate/src/config/selectRule' + +/** + * 表单设计器增强 hook + * 新增 + * - 文件上传 + * - 单图上传 + * - 多图上传 + * - 字典选择器 + * - 用户选择器 + * - 部门选择器 + * - 富文本 + */ +export const useFormCreateDesigner = async (designer: Ref) => { + const editorRule = useEditorRule() + const uploadFileRule = useUploadFileRule() + const uploadImgRule = useUploadImgRule() + const uploadImgsRule = useUploadImgsRule() + + /** + * 构建表单组件 + */ + const buildFormComponents = () => { + // 移除自带的上传组件规则,使用 uploadFileRule、uploadImgRule、uploadImgsRule 替代 + designer.value?.removeMenuItem('upload') + // 移除自带的富文本组件规则,使用 editorRule 替代 + designer.value?.removeMenuItem('fc-editor') + const components = [editorRule, uploadFileRule, uploadImgRule, uploadImgsRule] + components.forEach((component) => { + // 插入组件规则 + designer.value?.addComponent(component) + // 插入拖拽按钮到 `main` 分类下 + designer.value?.appendMenuItem('main', { + icon: component.icon, + name: component.name, + label: component.label + }) + }) + } + + const userSelectRule = useSelectRule({ + name: 'UserSelect', + label: '用户选择器', + icon: 'icon-user-o' + }) + const deptSelectRule = useSelectRule({ + name: 'DeptSelect', + label: '部门选择器', + icon: 'icon-address-card-o' + }) + const dictSelectRule = useDictSelectRule() + const apiSelectRule0 = useSelectRule({ + name: 'ApiSelect', + label: '接口选择器', + icon: 'icon-server', + props: [...apiSelectRule] + }) + + /** + * 构建系统字段菜单 + */ + const buildSystemMenu = () => { + // 移除自带的下拉选择器组件,使用 currencySelectRule 替代 + // designer.value?.removeMenuItem('select') + // designer.value?.removeMenuItem('radio') + // designer.value?.removeMenuItem('checkbox') + const components = [userSelectRule, deptSelectRule, dictSelectRule, apiSelectRule0] + const menu: Menu = { + name: 'system', + title: '系统字段', + list: components.map((component) => { + // 插入组件规则 + designer.value?.addComponent(component) + // 插入拖拽按钮到 `system` 分类下 + return { + icon: component.icon, + name: component.name, + label: component.label + } + }) + } + designer.value?.addMenu(menu) + } + + onMounted(async () => { + await nextTick() + buildFormComponents() + buildSystemMenu() + }) +} diff --git a/src/components/FormCreate/src/utils/index.ts b/src/components/FormCreate/src/utils/index.ts new file mode 100644 index 0000000..2d4a6fd --- /dev/null +++ b/src/components/FormCreate/src/utils/index.ts @@ -0,0 +1,18 @@ +export function makeRequiredRule() { + return { + type: 'Required', + field: 'formCreate$required', + title: '是否必填' + } +} + +export const localeProps = (t, prefix, rules) => { + return rules.map((rule) => { + if (rule.field === 'formCreate$required') { + rule.title = t('props.required') || rule.title + } else if (rule.field && rule.field !== '_optionType') { + rule.title = t('components.' + prefix + '.' + rule.field) || rule.title + } + return rule + }) +} diff --git a/src/components/Highlight/index.ts b/src/components/Highlight/index.ts new file mode 100644 index 0000000..3e2d9ed --- /dev/null +++ b/src/components/Highlight/index.ts @@ -0,0 +1,3 @@ +import Highlight from './src/Highlight.vue' + +export { Highlight } diff --git a/src/components/Highlight/src/Highlight.vue b/src/components/Highlight/src/Highlight.vue new file mode 100644 index 0000000..ef923a9 --- /dev/null +++ b/src/components/Highlight/src/Highlight.vue @@ -0,0 +1,65 @@ +<script lang="tsx"> +import { defineComponent, PropType, computed, h, unref } from 'vue' +import { propTypes } from '@/utils/propTypes' + +export default defineComponent({ + name: 'Highlight', + props: { + tag: propTypes.string.def('span'), + keys: { + type: Array as PropType<string[]>, + default: () => [] + }, + color: propTypes.string.def('var(--el-color-primary)') + }, + emits: ['click'], + setup(props, { emit, slots }) { + const keyNodes = computed(() => { + return props.keys.map((key) => { + return h( + 'span', + { + onClick: () => { + emit('click', key) + }, + style: { + color: props.color, + cursor: 'pointer' + } + }, + key + ) + }) + }) + + const parseText = (text: string) => { + props.keys.forEach((key, index) => { + const regexp = new RegExp(key, 'g') + text = text.replace(regexp, `{{${index}}}`) + }) + return text.split(/{{|}}/) + } + + const renderText = () => { + if (!slots?.default) return null + const node = slots?.default()[0].children + + if (!node) { + return slots?.default()[0] + } + + const textArray = parseText(node as string) + const regexp = /^[0-9]*$/ + const nodes = textArray.map((t) => { + if (regexp.test(t)) { + return unref(keyNodes)[t] || t + } + return t + }) + return h(props.tag, nodes) + } + + return () => renderText() + } +}) +</script> diff --git a/src/components/IFrame/index.ts b/src/components/IFrame/index.ts new file mode 100644 index 0000000..9f8cf24 --- /dev/null +++ b/src/components/IFrame/index.ts @@ -0,0 +1,3 @@ +import IFrame from './src/IFrame.vue' + +export { IFrame } diff --git a/src/components/IFrame/src/IFrame.vue b/src/components/IFrame/src/IFrame.vue new file mode 100644 index 0000000..19de51a --- /dev/null +++ b/src/components/IFrame/src/IFrame.vue @@ -0,0 +1,32 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' + +defineOptions({ name: 'IFrame' }) + +const props = defineProps({ + src: propTypes.string.def('') +}) +const loading = ref(true) +const height = ref('') +const frameRef = ref<HTMLElement | null>(null) +const init = () => { + height.value = document.documentElement.clientHeight - 94.5 + 'px' + loading.value = false +} +onMounted(() => { + setTimeout(() => { + init() + }, 300) +}) +</script> +<template> + <div v-loading="loading" :style="'height:' + height"> + <iframe + ref="frameRef" + :src="props.src" + frameborder="no" + scrolling="auto" + style="width: 100%; height: 100%" + ></iframe> + </div> +</template> diff --git a/src/components/Icon/index.ts b/src/components/Icon/index.ts new file mode 100644 index 0000000..33d1de3 --- /dev/null +++ b/src/components/Icon/index.ts @@ -0,0 +1,4 @@ +import Icon from './src/Icon.vue' +import IconSelect from './src/IconSelect.vue' + +export { Icon, IconSelect } diff --git a/src/components/Icon/src/Icon.vue b/src/components/Icon/src/Icon.vue new file mode 100644 index 0000000..a90bb37 --- /dev/null +++ b/src/components/Icon/src/Icon.vue @@ -0,0 +1,86 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import Iconify from '@purge-icons/generated' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'Icon' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('icon') + +const props = defineProps({ + // icon name + icon: propTypes.string, + // icon color + color: propTypes.string, + // icon size + size: propTypes.number.def(16), + // icon svg class + svgClass: propTypes.string.def('') +}) + +const elRef = ref<ElRef>(null) + +const isLocal = computed(() => props.icon?.startsWith('svg-icon:')) + +const symbolId = computed(() => { + return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : props.icon +}) + +const getIconifyStyle = computed(() => { + const { color, size } = props + return { + fontSize: `${size}px`, + height: '1em', + color + } +}) + +const getSvgClass = computed(() => { + const { svgClass } = props + return `iconify ${svgClass}` +}) + +const updateIcon = async (icon: string) => { + if (unref(isLocal)) return + + const el = unref(elRef) + if (!el) return + + await nextTick() + + if (!icon) return + + const svg = Iconify.renderSVG(icon, {}) + if (svg) { + el.textContent = '' + el.appendChild(svg) + } else { + const span = document.createElement('span') + span.className = 'iconify' + span.dataset.icon = icon + el.textContent = '' + el.appendChild(span) + } +} + +watch( + () => props.icon, + (icon: string) => { + updateIcon(icon) + } +) +</script> + +<template> + <ElIcon :class="prefixCls" :color="color" :size="size"> + <svg v-if="isLocal" :class="getSvgClass" aria-hidden="true"> + <use :xlink:href="symbolId" /> + </svg> + + <span v-else ref="elRef" :class="$attrs.class" :style="getIconifyStyle"> + <span :class="getSvgClass" :data-icon="symbolId"></span> + </span> + </ElIcon> +</template> diff --git a/src/components/Icon/src/IconSelect.vue b/src/components/Icon/src/IconSelect.vue new file mode 100644 index 0000000..d4a5b07 --- /dev/null +++ b/src/components/Icon/src/IconSelect.vue @@ -0,0 +1,229 @@ +<script lang="ts" setup> +import { CSSProperties } from 'vue' +import { cloneDeep } from 'lodash-es' +import { IconJson } from '@/components/Icon/src/data' + +defineOptions({ name: 'IconSelect' }) + +type ParameterCSSProperties = (item?: string) => CSSProperties | undefined + +const props = defineProps({ + modelValue: { + require: false, + type: String + } +}) +const emit = defineEmits<{ (e: 'update:modelValue', v: string) }>() + +const visible = ref(false) +const inputValue = toRef(props, 'modelValue') +const iconList = ref(IconJson) +const icon = ref('add-location') +const currentActiveType = ref('ep:') +// 深拷贝图标数据,前端做搜索 +const copyIconList = cloneDeep(iconList.value) + +const pageSize = ref(96) +const currentPage = ref(1) + +// 搜索条件 +const filterValue = ref('') + +const tabsList = [ + { + label: 'Element Plus', + name: 'ep:' + }, + { + label: 'Font Awesome 4', + name: 'fa:' + }, + { + label: 'Font Awesome 5 Solid', + name: 'fa-solid:' + } +] + +const pageList = computed(() => { + if (currentPage.value === 1) { + return copyIconList[currentActiveType.value] + ?.filter((v) => v.includes(filterValue.value)) + .slice(currentPage.value - 1, pageSize.value) + } else { + return copyIconList[currentActiveType.value] + ?.filter((v) => v.includes(filterValue.value)) + .slice( + pageSize.value * (currentPage.value - 1), + pageSize.value * (currentPage.value - 1) + pageSize.value + ) + } +}) +const iconCount = computed(() => { + return copyIconList[currentActiveType.value] == undefined + ? 0 + : copyIconList[currentActiveType.value].length +}) + +const iconItemStyle = computed((): ParameterCSSProperties => { + return (item) => { + if (inputValue.value === currentActiveType.value + item) { + return { + borderColor: 'var(--el-color-primary)', + color: 'var(--el-color-primary)' + } + } + } +}) + +function handleClick({ props }) { + currentPage.value = 1 + currentActiveType.value = props.name + emit('update:modelValue', currentActiveType.value + iconList.value[currentActiveType.value][0]) + icon.value = iconList.value[currentActiveType.value][0] +} + +function onChangeIcon(item) { + icon.value = item + emit('update:modelValue', currentActiveType.value + item) + visible.value = false +} + +function onCurrentChange(page) { + currentPage.value = page +} + +watch( + () => { + return props.modelValue + }, + () => { + if (props.modelValue && props.modelValue.indexOf(':') >= 0) { + currentActiveType.value = props.modelValue.substring(0, props.modelValue.indexOf(':') + 1) + icon.value = props.modelValue.substring(props.modelValue.indexOf(':') + 1) + } + } +) +watch( + () => { + return filterValue.value + }, + () => { + currentPage.value = 1 + } +) +</script> + +<template> + <div class="selector"> + <ElInput v-model="inputValue" @click="visible = !visible"> + <template #append> + <ElPopover + :popper-options="{ + placement: 'auto' + }" + :visible="visible" + :width="350" + popper-class="pure-popper" + trigger="click" + > + <template #reference> + <div + class="h-32px w-40px flex cursor-pointer items-center justify-center" + @click="visible = !visible" + > + <Icon :icon="currentActiveType + icon" /> + </div> + </template> + + <ElInput v-model="filterValue" class="p-2" clearable placeholder="搜索图标" /> + <ElDivider border-style="dashed" /> + + <ElTabs v-model="currentActiveType" @tab-click="handleClick"> + <ElTabPane + v-for="(pane, index) in tabsList" + :key="index" + :label="pane.label" + :name="pane.name" + > + <ElDivider border-style="dashed" class="tab-divider" /> + <ElScrollbar height="220px"> + <ul class="ml-2 flex flex-wrap px-2"> + <li + v-for="(item, key) in pageList" + :key="key" + :style="iconItemStyle(item)" + :title="item" + class="icon-item mr-2 mt-1 w-1/10 flex cursor-pointer items-center justify-center border border-solid p-2" + @click="onChangeIcon(item)" + > + <Icon :icon="currentActiveType + item" /> + </li> + </ul> + </ElScrollbar> + </ElTabPane> + </ElTabs> + <ElDivider border-style="dashed" /> + + <ElPagination + :current-page="currentPage" + :page-size="pageSize" + :total="iconCount" + background + class="h-10 flex items-center justify-center" + layout="prev, pager, next" + small + @current-change="onCurrentChange" + /> + </ElPopover> + </template> + </ElInput> + </div> +</template> + +<style lang="scss" scoped> +.el-divider--horizontal { + margin: 1px auto !important; +} + +.tab-divider.el-divider--horizontal { + margin: 0 !important; +} + +.icon-item { + &:hover { + color: var(--el-color-primary); + border-color: var(--el-color-primary); + transform: scaleX(1.05); + transition: all 0.4s; + } +} + +:deep(.el-tabs__nav-next) { + font-size: 15px; + line-height: 32px; + box-shadow: -5px 0 5px -6px #ccc; +} + +:deep(.el-tabs__nav-prev) { + font-size: 15px; + line-height: 32px; + box-shadow: 5px 0 5px -6px #ccc; +} + +:deep(.el-input-group__append) { + padding: 0; +} + +:deep(.el-tabs__item) { + height: 30px; + font-size: 12px; + font-weight: normal; + line-height: 30px; +} + +:deep(.el-tabs__header), +:deep(.el-tabs__nav-wrap) { + position: static; + margin: 0; +} +</style> diff --git a/src/components/Icon/src/data.ts b/src/components/Icon/src/data.ts new file mode 100644 index 0000000..2a4ed5a --- /dev/null +++ b/src/components/Icon/src/data.ts @@ -0,0 +1,1961 @@ +export const IconJson = { + 'ep:': [ + 'add-location', + 'aim', + 'alarm-clock', + 'apple', + 'arrow-down', + 'arrow-down-bold', + 'arrow-left', + 'arrow-left-bold', + 'arrow-right', + 'arrow-right-bold', + 'arrow-up', + 'arrow-up-bold', + 'avatar', + 'back', + 'baseball', + 'basketball', + 'bell', + 'bell-filled', + 'bicycle', + 'bottom', + 'bottom-left', + 'bottom-right', + 'bowl', + 'box', + 'briefcase', + 'brush', + 'brush-filled', + 'burger', + 'calendar', + 'camera', + 'camera-filled', + 'caret-bottom', + 'caret-left', + 'caret-right', + 'caret-top', + 'cellphone', + 'chat-dot-round', + 'chat-dot-square', + 'chat-line-round', + 'chat-line-square', + 'chat-round', + 'chat-square', + 'check', + 'checked', + 'cherry', + 'chicken', + 'circle-check', + 'circle-check-filled', + 'circle-close', + 'circle-close-filled', + 'circle-plus', + 'circle-plus-filled', + 'clock', + 'close', + 'close-bold', + 'cloudy', + 'coffee', + 'coffee-cup', + 'coin', + 'cold-drink', + 'collection', + 'collection-tag', + 'comment', + 'compass', + 'connection', + 'coordinate', + 'copy-document', + 'cpu', + 'credit-card', + 'crop', + 'd-arrow-left', + 'd-arrow-right', + 'd-caret', + 'data-analysis', + 'data-board', + 'data-line', + 'delete', + 'delete-filled', + 'delete-location', + 'dessert', + 'discount', + 'dish', + 'dish-dot', + 'document', + 'document-add', + 'document-checked', + 'document-copy', + 'document-delete', + 'document-remove', + 'download', + 'drizzling', + 'edit', + 'edit-pen', + 'eleme', + 'eleme-filled', + 'expand', + 'failed', + 'female', + 'files', + 'film', + 'filter', + 'finished', + 'first-aid-kit', + 'flag', + 'fold', + 'folder', + 'folder-add', + 'folder-checked', + 'folder-delete', + 'folder-opened', + 'folder-remove', + 'food', + 'football', + 'fork-spoon', + 'fries', + 'full-screen', + 'goblet', + 'goblet-full', + 'goblet-square', + 'goblet-square-full', + 'goods', + 'goods-filled', + 'grape', + 'grid', + 'guide', + 'headset', + 'help', + 'help-filled', + 'histogram', + 'home-filled', + 'hot-water', + 'house', + 'ice-cream', + 'ice-cream-round', + 'ice-cream-square', + 'ice-drink', + 'ice-tea', + 'info-filled', + 'iphone', + 'key', + 'knife-fork', + 'lightning', + 'link', + 'list', + 'loading', + 'location', + 'location-filled', + 'location-information', + 'lock', + 'lollipop', + 'magic-stick', + 'magnet', + 'male', + 'management', + 'map-location', + 'medal', + 'menu', + 'message', + 'message-box', + 'mic', + 'microphone', + 'milk-tea', + 'minus', + 'money', + 'monitor', + 'moon', + 'moon-night', + 'more', + 'more-filled', + 'mostly-cloudy', + 'mouse', + 'mug', + 'mute', + 'mute-notification', + 'no-smoking', + 'notebook', + 'notification', + 'odometer', + 'office-building', + 'open', + 'operation', + 'opportunity', + 'orange', + 'paperclip', + 'partly-cloudy', + 'pear', + 'phone', + 'phone-filled', + 'picture', + 'picture-filled', + 'picture-rounded', + 'pie-chart', + 'place', + 'platform', + 'plus', + 'pointer', + 'position', + 'postcard', + 'pouring', + 'present', + 'price-tag', + 'printer', + 'promotion', + 'question-filled', + 'rank', + 'reading', + 'reading-lamp', + 'refresh', + 'refresh-left', + 'refresh-right', + 'refrigerator', + 'remove', + 'remove-filled', + 'right', + 'scale-to-original', + 'school', + 'scissor', + 'search', + 'select', + 'sell', + 'semi-select', + 'service', + 'set-up', + 'setting', + 'share', + 'ship', + 'shop', + 'shopping-bag', + 'shopping-cart', + 'shopping-cart-full', + 'smoking', + 'soccer', + 'sold-out', + 'sort', + 'sort-down', + 'sort-up', + 'stamp', + 'star', + 'star-filled', + 'stopwatch', + 'success-filled', + 'sugar', + 'suitcase', + 'sunny', + 'sunrise', + 'sunset', + 'switch', + 'switch-button', + 'takeaway-box', + 'ticket', + 'tickets', + 'timer', + 'toilet-paper', + 'tools', + 'top', + 'top-left', + 'top-right', + 'trend-charts', + 'trophy', + 'turn-off', + 'umbrella', + 'unlock', + 'upload', + 'upload-filled', + 'user', + 'user-filled', + 'van', + 'video-camera', + 'video-camera-filled', + 'video-pause', + 'video-play', + 'view', + 'wallet', + 'wallet-filled', + 'warning', + 'warning-filled', + 'watch', + 'watermelon', + 'wind-power', + 'zoom-in', + 'zoom-out' + ], + 'fa:': [ + '500px', + 'address-book', + 'address-book-o', + 'address-card', + 'address-card-o', + 'adjust', + 'adn', + 'align-center', + 'align-justify', + 'align-left', + 'amazon', + 'ambulance', + 'american-sign-language-interpreting', + 'anchor', + 'android', + 'angellist', + 'angle-double-left', + 'angle-double-up', + 'angle-down', + 'angle-left', + 'angle-up', + 'apple', + 'archive', + 'area-chart', + 'arrow-circle-left', + 'arrow-circle-o-left', + 'arrow-circle-o-up', + 'arrow-circle-up', + 'arrow-left', + 'arrow-up', + 'arrows', + 'arrows-alt', + 'arrows-h', + 'arrows-v', + 'assistive-listening-systems', + 'asterisk', + 'at', + 'audio-description', + 'automobile', + 'backward', + 'balance-scale', + 'ban', + 'bandcamp', + 'bank', + 'bar-chart', + 'barcode', + 'bars', + 'bath', + 'battery', + 'battery-0', + 'battery-1', + 'battery-2', + 'battery-3', + 'bed', + 'beer', + 'behance', + 'behance-square', + 'bell', + 'bell-o', + 'bell-slash', + 'bell-slash-o', + 'bicycle', + 'binoculars', + 'birthday-cake', + 'bitbucket', + 'bitbucket-square', + 'bitcoin', + 'black-tie', + 'blind', + 'bluetooth', + 'bluetooth-b', + 'bold', + 'bolt', + 'bomb', + 'book', + 'bookmark', + 'bookmark-o', + 'braille', + 'briefcase', + 'bug', + 'building', + 'building-o', + 'bullhorn', + 'bullseye', + 'bus', + 'buysellads', + 'cab', + 'calculator', + 'calendar', + 'calendar-check-o', + 'calendar-minus-o', + 'calendar-o', + 'calendar-plus-o', + 'calendar-times-o', + 'camera', + 'camera-retro', + 'caret-down', + 'caret-left', + 'caret-square-o-left', + 'caret-square-o-up', + 'caret-up', + 'cart-arrow-down', + 'cart-plus', + 'cc', + 'cc-amex', + 'cc-diners-club', + 'cc-discover', + 'cc-jcb', + 'cc-mastercard', + 'cc-paypal', + 'cc-stripe', + 'cc-visa', + 'certificate', + 'chain', + 'chain-broken', + 'check', + 'check-circle', + 'check-circle-o', + 'check-square', + 'check-square-o', + 'chevron-circle-left', + 'chevron-circle-up', + 'chevron-down', + 'chevron-left', + 'chevron-up', + 'child', + 'chrome', + 'circle', + 'circle-o', + 'circle-o-notch', + 'circle-thin', + 'clipboard', + 'clock-o', + 'clone', + 'close', + 'cloud', + 'cloud-download', + 'cloud-upload', + 'cny', + 'code', + 'code-fork', + 'codepen', + 'codiepie', + 'coffee', + 'cog', + 'cogs', + 'columns', + 'comment', + 'comment-o', + 'commenting', + 'commenting-o', + 'comments', + 'comments-o', + 'compass', + 'compress', + 'connectdevelop', + 'contao', + 'copy', + 'copyright', + 'creative-commons', + 'credit-card', + 'credit-card-alt', + 'crop', + 'crosshairs', + 'css3', + 'cube', + 'cubes', + 'cut', + 'cutlery', + 'dashboard', + 'dashcube', + 'database', + 'deaf', + 'dedent', + 'delicious', + 'desktop', + 'deviantart', + 'diamond', + 'digg', + 'dollar', + 'dot-circle-o', + 'download', + 'dribbble', + 'drivers-license', + 'drivers-license-o', + 'dropbox', + 'drupal', + 'edge', + 'edit', + 'eercast', + 'eject', + 'ellipsis-h', + 'ellipsis-v', + 'empire', + 'envelope', + 'envelope-o', + 'envelope-open', + 'envelope-open-o', + 'envelope-square', + 'envira', + 'eraser', + 'etsy', + 'eur', + 'exchange', + 'exclamation', + 'exclamation-circle', + 'exclamation-triangle', + 'expand', + 'expeditedssl', + 'external-link', + 'external-link-square', + 'eye', + 'eye-slash', + 'eyedropper', + 'fa', + 'facebook', + 'facebook-official', + 'facebook-square', + 'fast-backward', + 'fax', + 'feed', + 'female', + 'fighter-jet', + 'file', + 'file-archive-o', + 'file-audio-o', + 'file-code-o', + 'file-excel-o', + 'file-image-o', + 'file-movie-o', + 'file-o', + 'file-pdf-o', + 'file-powerpoint-o', + 'file-text', + 'file-text-o', + 'file-word-o', + 'film', + 'filter', + 'fire', + 'fire-extinguisher', + 'firefox', + 'first-order', + 'flag', + 'flag-checkered', + 'flag-o', + 'flask', + 'flickr', + 'floppy-o', + 'folder', + 'folder-o', + 'folder-open', + 'folder-open-o', + 'font', + 'fonticons', + 'fort-awesome', + 'forumbee', + 'foursquare', + 'free-code-camp', + 'frown-o', + 'futbol-o', + 'gamepad', + 'gavel', + 'gbp', + 'genderless', + 'get-pocket', + 'gg', + 'gg-circle', + 'gift', + 'git', + 'git-square', + 'github', + 'github-alt', + 'github-square', + 'gitlab', + 'gittip', + 'glass', + 'glide', + 'glide-g', + 'globe', + 'google', + 'google-plus', + 'google-plus-circle', + 'google-plus-square', + 'google-wallet', + 'graduation-cap', + 'grav', + 'group', + 'h-square', + 'hacker-news', + 'hand-grab-o', + 'hand-lizard-o', + 'hand-o-left', + 'hand-o-up', + 'hand-paper-o', + 'hand-peace-o', + 'hand-pointer-o', + 'hand-scissors-o', + 'hand-spock-o', + 'handshake-o', + 'hashtag', + 'hdd-o', + 'header', + 'headphones', + 'heart', + 'heart-o', + 'heartbeat', + 'history', + 'home', + 'hospital-o', + 'hourglass', + 'hourglass-1', + 'hourglass-2', + 'hourglass-3', + 'hourglass-o', + 'houzz', + 'html5', + 'i-cursor', + 'id-badge', + 'ils', + 'image', + 'imdb', + 'inbox', + 'indent', + 'industry', + 'info', + 'info-circle', + 'inr', + 'instagram', + 'internet-explorer', + 'intersex', + 'ioxhost', + 'italic', + 'joomla', + 'jsfiddle', + 'key', + 'keyboard-o', + 'krw', + 'language', + 'laptop', + 'lastfm', + 'lastfm-square', + 'leaf', + 'leanpub', + 'lemon-o', + 'level-up', + 'life-bouy', + 'lightbulb-o', + 'line-chart', + 'linkedin', + 'linkedin-square', + 'linode', + 'linux', + 'list', + 'list-alt', + 'list-ol', + 'list-ul', + 'location-arrow', + 'lock', + 'long-arrow-left', + 'long-arrow-up', + 'low-vision', + 'magic', + 'magnet', + 'mail-forward', + 'mail-reply', + 'mail-reply-all', + 'male', + 'map', + 'map-marker', + 'map-o', + 'map-pin', + 'map-signs', + 'mars', + 'mars-double', + 'mars-stroke', + 'mars-stroke-h', + 'mars-stroke-v', + 'maxcdn', + 'meanpath', + 'medium', + 'medkit', + 'meetup', + 'meh-o', + 'mercury', + 'microchip', + 'microphone', + 'microphone-slash', + 'minus', + 'minus-circle', + 'minus-square', + 'minus-square-o', + 'mixcloud', + 'mobile', + 'modx', + 'money', + 'moon-o', + 'motorcycle', + 'mouse-pointer', + 'music', + 'neuter', + 'newspaper-o', + 'object-group', + 'object-ungroup', + 'odnoklassniki', + 'odnoklassniki-square', + 'opencart', + 'openid', + 'opera', + 'optin-monster', + 'pagelines', + 'paint-brush', + 'paper-plane', + 'paper-plane-o', + 'paperclip', + 'paragraph', + 'pause', + 'pause-circle', + 'pause-circle-o', + 'paw', + 'paypal', + 'pencil', + 'pencil-square', + 'percent', + 'phone', + 'phone-square', + 'pie-chart', + 'pied-piper', + 'pied-piper-alt', + 'pied-piper-pp', + 'pinterest', + 'pinterest-p', + 'pinterest-square', + 'plane', + 'play', + 'play-circle', + 'play-circle-o', + 'plug', + 'plus', + 'plus-circle', + 'plus-square', + 'plus-square-o', + 'podcast', + 'power-off', + 'print', + 'product-hunt', + 'puzzle-piece', + 'qq', + 'qrcode', + 'question', + 'question-circle', + 'question-circle-o', + 'quora', + 'quote-left', + 'quote-right', + 'ra', + 'random', + 'ravelry', + 'recycle', + 'reddit', + 'reddit-alien', + 'reddit-square', + 'refresh', + 'registered', + 'renren', + 'repeat', + 'retweet', + 'road', + 'rocket', + 'rotate-left', + 'rouble', + 'rss-square', + 'safari', + 'scribd', + 'search', + 'search-minus', + 'search-plus', + 'sellsy', + 'server', + 'share-alt', + 'share-alt-square', + 'share-square', + 'share-square-o', + 'shield', + 'ship', + 'shirtsinbulk', + 'shopping-bag', + 'shopping-basket', + 'shopping-cart', + 'shower', + 'sign-in', + 'sign-language', + 'sign-out', + 'signal', + 'simplybuilt', + 'sitemap', + 'skyatlas', + 'skype', + 'slack', + 'sliders', + 'slideshare', + 'smile-o', + 'snapchat', + 'snapchat-ghost', + 'snapchat-square', + 'snowflake-o', + 'sort', + 'sort-alpha-asc', + 'sort-alpha-desc', + 'sort-amount-asc', + 'sort-amount-desc', + 'sort-asc', + 'sort-numeric-asc', + 'sort-numeric-desc', + 'soundcloud', + 'space-shuttle', + 'spinner', + 'spoon', + 'spotify', + 'square', + 'square-o', + 'stack-exchange', + 'stack-overflow', + 'star', + 'star-half', + 'star-half-empty', + 'star-o', + 'steam', + 'steam-square', + 'step-backward', + 'stethoscope', + 'sticky-note', + 'sticky-note-o', + 'stop', + 'stop-circle', + 'stop-circle-o', + 'street-view', + 'strikethrough', + 'stumbleupon', + 'stumbleupon-circle', + 'subscript', + 'subway', + 'suitcase', + 'sun-o', + 'superpowers', + 'superscript', + 'table', + 'tablet', + 'tag', + 'tags', + 'tasks', + 'telegram', + 'television', + 'tencent-weibo', + 'terminal', + 'text-height', + 'text-width', + 'th', + 'th-large', + 'th-list', + 'themeisle', + 'thermometer', + 'thermometer-0', + 'thermometer-1', + 'thermometer-2', + 'thermometer-3', + 'thumb-tack', + 'thumbs-down', + 'thumbs-o-up', + 'thumbs-up', + 'ticket', + 'times-circle', + 'times-circle-o', + 'times-rectangle', + 'times-rectangle-o', + 'tint', + 'toggle-off', + 'toggle-on', + 'trademark', + 'train', + 'transgender-alt', + 'trash', + 'trash-o', + 'tree', + 'trello', + 'tripadvisor', + 'trophy', + 'truck', + 'try', + 'tty', + 'tumblr', + 'tumblr-square', + 'twitch', + 'twitter', + 'twitter-square', + 'umbrella', + 'underline', + 'universal-access', + 'unlock', + 'unlock-alt', + 'upload', + 'usb', + 'user', + 'user-circle', + 'user-circle-o', + 'user-md', + 'user-o', + 'user-plus', + 'user-secret', + 'user-times', + 'venus', + 'venus-double', + 'venus-mars', + 'viacoin', + 'viadeo', + 'viadeo-square', + 'video-camera', + 'vimeo', + 'vimeo-square', + 'vine', + 'vk', + 'volume-control-phone', + 'volume-down', + 'volume-off', + 'volume-up', + 'wechat', + 'weibo', + 'whatsapp', + 'wheelchair', + 'wheelchair-alt', + 'wifi', + 'wikipedia-w', + 'window-maximize', + 'window-minimize', + 'window-restore', + 'windows', + 'wordpress', + 'wpbeginner', + 'wpexplorer', + 'wpforms', + 'wrench', + 'xing', + 'xing-square', + 'y-combinator', + 'yahoo', + 'yelp', + 'yoast', + 'youtube', + 'youtube-play', + 'youtube-square' + ], + 'fa-solid:': [ + 'abacus', + 'ad', + 'address-book', + 'address-card', + 'adjust', + 'air-freshener', + 'align-center', + 'align-justify', + 'align-left', + 'align-right', + 'allergies', + 'ambulance', + 'american-sign-language-interpreting', + 'anchor', + 'angle-double-down', + 'angle-double-left', + 'angle-double-right', + 'angle-double-up', + 'angle-down', + 'angle-left', + 'angle-right', + 'angle-up', + 'angry', + 'ankh', + 'apple-alt', + 'archive', + 'archway', + 'arrow-alt-circle-down', + 'arrow-alt-circle-left', + 'arrow-alt-circle-right', + 'arrow-alt-circle-up', + 'arrow-circle-down', + 'arrow-circle-left', + 'arrow-circle-right', + 'arrow-circle-up', + 'arrow-down', + 'arrow-left', + 'arrow-right', + 'arrow-up', + 'arrows-alt', + 'arrows-alt-h', + 'arrows-alt-v', + 'assistive-listening-systems', + 'asterisk', + 'at', + 'atlas', + 'atom', + 'audio-description', + 'award', + 'baby', + 'baby-carriage', + 'backspace', + 'backward', + 'bacon', + 'bacteria', + 'bacterium', + 'bahai', + 'balance-scale', + 'balance-scale-left', + 'balance-scale-right', + 'ban', + 'band-aid', + 'barcode', + 'bars', + 'baseball-ball', + 'basketball-ball', + 'bath', + 'battery-empty', + 'battery-full', + 'battery-half', + 'battery-quarter', + 'battery-three-quarters', + 'bed', + 'beer', + 'bell', + 'bell-slash', + 'bezier-curve', + 'bible', + 'bicycle', + 'biking', + 'binoculars', + 'biohazard', + 'birthday-cake', + 'blender', + 'blender-phone', + 'blind', + 'blog', + 'bold', + 'bolt', + 'bomb', + 'bone', + 'bong', + 'book', + 'book-dead', + 'book-medical', + 'book-open', + 'book-reader', + 'bookmark', + 'border-all', + 'border-none', + 'border-style', + 'bowling-ball', + 'box', + 'box-open', + 'box-tissue', + 'boxes', + 'braille', + 'brain', + 'bread-slice', + 'briefcase', + 'briefcase-medical', + 'broadcast-tower', + 'broom', + 'brush', + 'bug', + 'building', + 'bullhorn', + 'bullseye', + 'burn', + 'bus', + 'bus-alt', + 'business-time', + 'calculator', + 'calculator-alt', + 'calendar', + 'calendar-alt', + 'calendar-check', + 'calendar-day', + 'calendar-minus', + 'calendar-plus', + 'calendar-times', + 'calendar-week', + 'camera', + 'camera-retro', + 'campground', + 'candy-cane', + 'cannabis', + 'capsules', + 'car', + 'car-alt', + 'car-battery', + 'car-crash', + 'car-side', + 'caravan', + 'caret-down', + 'caret-left', + 'caret-right', + 'caret-square-down', + 'caret-square-left', + 'caret-square-right', + 'caret-square-up', + 'caret-up', + 'carrot', + 'cart-arrow-down', + 'cart-plus', + 'cash-register', + 'cat', + 'certificate', + 'chair', + 'chalkboard', + 'chalkboard-teacher', + 'charging-station', + 'chart-area', + 'chart-bar', + 'chart-line', + 'chart-pie', + 'check', + 'check-circle', + 'check-double', + 'check-square', + 'cheese', + 'chess', + 'chess-bishop', + 'chess-board', + 'chess-king', + 'chess-knight', + 'chess-pawn', + 'chess-queen', + 'chess-rook', + 'chevron-circle-down', + 'chevron-circle-left', + 'chevron-circle-right', + 'chevron-circle-up', + 'chevron-down', + 'chevron-left', + 'chevron-right', + 'chevron-up', + 'child', + 'church', + 'circle', + 'circle-notch', + 'city', + 'clinic-medical', + 'clipboard', + 'clipboard-check', + 'clipboard-list', + 'clock', + 'clone', + 'closed-captioning', + 'cloud', + 'cloud-download-alt', + 'cloud-meatball', + 'cloud-moon', + 'cloud-moon-rain', + 'cloud-rain', + 'cloud-showers-heavy', + 'cloud-sun', + 'cloud-sun-rain', + 'cloud-upload-alt', + 'cocktail', + 'code', + 'code-branch', + 'coffee', + 'cog', + 'cogs', + 'coins', + 'columns', + 'comment', + 'comment-alt', + 'comment-dollar', + 'comment-dots', + 'comment-medical', + 'comment-slash', + 'comments', + 'comments-dollar', + 'compact-disc', + 'compass', + 'compress', + 'compress-alt', + 'compress-arrows-alt', + 'concierge-bell', + 'cookie', + 'cookie-bite', + 'copy', + 'copyright', + 'couch', + 'credit-card', + 'crop', + 'crop-alt', + 'cross', + 'crosshairs', + 'crow', + 'crown', + 'crutch', + 'cube', + 'cubes', + 'cut', + 'database', + 'deaf', + 'democrat', + 'desktop', + 'dharmachakra', + 'diagnoses', + 'dice', + 'dice-d20', + 'dice-d6', + 'dice-five', + 'dice-four', + 'dice-one', + 'dice-six', + 'dice-three', + 'dice-two', + 'digital-tachograph', + 'directions', + 'disease', + 'divide', + 'dizzy', + 'dna', + 'dog', + 'dollar-sign', + 'dolly', + 'dolly-flatbed', + 'donate', + 'door-closed', + 'door-open', + 'dot-circle', + 'dove', + 'download', + 'drafting-compass', + 'dragon', + 'draw-polygon', + 'drum', + 'drum-steelpan', + 'drumstick-bite', + 'dumbbell', + 'dumpster', + 'dumpster-fire', + 'dungeon', + 'edit', + 'egg', + 'eject', + 'ellipsis-h', + 'ellipsis-v', + 'empty-set', + 'envelope', + 'envelope-open', + 'envelope-open-text', + 'envelope-square', + 'equals', + 'eraser', + 'ethernet', + 'euro-sign', + 'exchange-alt', + 'exclamation', + 'exclamation-circle', + 'exclamation-triangle', + 'expand', + 'expand-alt', + 'expand-arrows-alt', + 'external-link-alt', + 'external-link-square-alt', + 'eye', + 'eye-dropper', + 'eye-slash', + 'fan', + 'fast-backward', + 'fast-forward', + 'faucet', + 'fax', + 'feather', + 'feather-alt', + 'female', + 'fighter-jet', + 'file', + 'file-alt', + 'file-archive', + 'file-audio', + 'file-code', + 'file-contract', + 'file-csv', + 'file-download', + 'file-excel', + 'file-export', + 'file-image', + 'file-import', + 'file-invoice', + 'file-invoice-dollar', + 'file-medical', + 'file-medical-alt', + 'file-pdf', + 'file-powerpoint', + 'file-prescription', + 'file-signature', + 'file-upload', + 'file-video', + 'file-word', + 'fill', + 'fill-drip', + 'film', + 'filter', + 'fingerprint', + 'fire', + 'fire-alt', + 'fire-extinguisher', + 'first-aid', + 'fish', + 'fist-raised', + 'flag', + 'flag-checkered', + 'flag-usa', + 'flask', + 'flushed', + 'folder', + 'folder-minus', + 'folder-open', + 'folder-plus', + 'font', + 'football-ball', + 'forward', + 'frog', + 'frown', + 'frown-open', + 'function', + 'funnel-dollar', + 'futbol', + 'gamepad', + 'gas-pump', + 'gavel', + 'gem', + 'genderless', + 'ghost', + 'gift', + 'gifts', + 'glass-cheers', + 'glass-martini', + 'glass-martini-alt', + 'glass-whiskey', + 'glasses', + 'globe', + 'globe-africa', + 'globe-americas', + 'globe-asia', + 'globe-europe', + 'golf-ball', + 'gopuram', + 'graduation-cap', + 'greater-than', + 'greater-than-equal', + 'grimace', + 'grin', + 'grin-alt', + 'grin-beam', + 'grin-beam-sweat', + 'grin-hearts', + 'grin-squint', + 'grin-squint-tears', + 'grin-stars', + 'grin-tears', + 'grin-tongue', + 'grin-tongue-squint', + 'grin-tongue-wink', + 'grin-wink', + 'grip-horizontal', + 'grip-lines', + 'grip-lines-vertical', + 'grip-vertical', + 'guitar', + 'h-square', + 'hamburger', + 'hammer', + 'hamsa', + 'hand-holding', + 'hand-holding-heart', + 'hand-holding-medical', + 'hand-holding-usd', + 'hand-holding-water', + 'hand-lizard', + 'hand-middle-finger', + 'hand-paper', + 'hand-peace', + 'hand-point-down', + 'hand-point-left', + 'hand-point-right', + 'hand-point-up', + 'hand-pointer', + 'hand-rock', + 'hand-scissors', + 'hand-sparkles', + 'hand-spock', + 'hands', + 'hands-helping', + 'hands-wash', + 'handshake', + 'handshake-alt-slash', + 'handshake-slash', + 'hanukiah', + 'hard-hat', + 'hashtag', + 'hat-cowboy', + 'hat-cowboy-side', + 'hat-wizard', + 'hdd', + 'head-side-cough', + 'head-side-cough-slash', + 'head-side-mask', + 'head-side-virus', + 'heading', + 'headphones', + 'headphones-alt', + 'headset', + 'heart', + 'heart-broken', + 'heartbeat', + 'helicopter', + 'highlighter', + 'hiking', + 'hippo', + 'history', + 'hockey-puck', + 'holly-berry', + 'home', + 'horse', + 'horse-head', + 'hospital', + 'hospital-alt', + 'hospital-symbol', + 'hospital-user', + 'hot-tub', + 'hotdog', + 'hotel', + 'hourglass', + 'hourglass-end', + 'hourglass-half', + 'hourglass-start', + 'house-damage', + 'house-user', + 'hryvnia', + 'i-cursor', + 'ice-cream', + 'icicles', + 'icons', + 'id-badge', + 'id-card', + 'id-card-alt', + 'igloo', + 'image', + 'images', + 'inbox', + 'indent', + 'industry', + 'infinity', + 'info', + 'info-circle', + 'integral', + 'intersection', + 'italic', + 'jedi', + 'joint', + 'journal-whills', + 'kaaba', + 'key', + 'keyboard', + 'khanda', + 'kiss', + 'kiss-beam', + 'kiss-wink-heart', + 'kiwi-bird', + 'lambda', + 'landmark', + 'language', + 'laptop', + 'laptop-code', + 'laptop-house', + 'laptop-medical', + 'laugh', + 'laugh-beam', + 'laugh-squint', + 'laugh-wink', + 'layer-group', + 'leaf', + 'lemon', + 'less-than', + 'less-than-equal', + 'level-down-alt', + 'level-up-alt', + 'life-ring', + 'lightbulb', + 'link', + 'lira-sign', + 'list', + 'list-alt', + 'list-ol', + 'list-ul', + 'location-arrow', + 'lock', + 'lock-open', + 'long-arrow-alt-down', + 'long-arrow-alt-left', + 'long-arrow-alt-right', + 'long-arrow-alt-up', + 'low-vision', + 'luggage-cart', + 'lungs', + 'lungs-virus', + 'magic', + 'magnet', + 'mail-bulk', + 'male', + 'map', + 'map-marked', + 'map-marked-alt', + 'map-marker', + 'map-marker-alt', + 'map-pin', + 'map-signs', + 'marker', + 'mars', + 'mars-double', + 'mars-stroke', + 'mars-stroke-h', + 'mars-stroke-v', + 'mask', + 'medal', + 'medkit', + 'meh', + 'meh-blank', + 'meh-rolling-eyes', + 'memory', + 'menorah', + 'mercury', + 'meteor', + 'microchip', + 'microphone', + 'microphone-alt', + 'microphone-alt-slash', + 'microphone-slash', + 'microscope', + 'minus', + 'minus-circle', + 'minus-square', + 'mitten', + 'mobile', + 'mobile-alt', + 'money-bill', + 'money-bill-alt', + 'money-bill-wave', + 'money-bill-wave-alt', + 'money-check', + 'money-check-alt', + 'monument', + 'moon', + 'mortar-pestle', + 'mosque', + 'motorcycle', + 'mountain', + 'mouse', + 'mouse-pointer', + 'mug-hot', + 'music', + 'network-wired', + 'neuter', + 'newspaper', + 'not-equal', + 'notes-medical', + 'object-group', + 'object-ungroup', + 'oil-can', + 'om', + 'omega', + 'otter', + 'outdent', + 'pager', + 'paint-brush', + 'paint-roller', + 'palette', + 'pallet', + 'paper-plane', + 'paperclip', + 'parachute-box', + 'paragraph', + 'parking', + 'passport', + 'pastafarianism', + 'paste', + 'pause', + 'pause-circle', + 'paw', + 'peace', + 'pen', + 'pen-alt', + 'pen-fancy', + 'pen-nib', + 'pen-square', + 'pencil-alt', + 'pencil-ruler', + 'people-arrows', + 'people-carry', + 'pepper-hot', + 'percent', + 'percentage', + 'person-booth', + 'phone', + 'phone-alt', + 'phone-slash', + 'phone-square', + 'phone-square-alt', + 'phone-volume', + 'photo-video', + 'pi', + 'piggy-bank', + 'pills', + 'pizza-slice', + 'place-of-worship', + 'plane', + 'plane-arrival', + 'plane-departure', + 'plane-slash', + 'play', + 'play-circle', + 'plug', + 'plus', + 'plus-circle', + 'plus-square', + 'podcast', + 'poll', + 'poll-h', + 'poo', + 'poo-storm', + 'poop', + 'portrait', + 'pound-sign', + 'power-off', + 'pray', + 'praying-hands', + 'prescription', + 'prescription-bottle', + 'prescription-bottle-alt', + 'print', + 'procedures', + 'project-diagram', + 'pump-medical', + 'pump-soap', + 'puzzle-piece', + 'qrcode', + 'question', + 'question-circle', + 'quidditch', + 'quote-left', + 'quote-right', + 'quran', + 'radiation', + 'radiation-alt', + 'rainbow', + 'random', + 'receipt', + 'record-vinyl', + 'recycle', + 'redo', + 'redo-alt', + 'registered', + 'remove-format', + 'reply', + 'reply-all', + 'republican', + 'restroom', + 'retweet', + 'ribbon', + 'ring', + 'road', + 'robot', + 'rocket', + 'route', + 'rss', + 'rss-square', + 'ruble-sign', + 'ruler', + 'ruler-combined', + 'ruler-horizontal', + 'ruler-vertical', + 'running', + 'rupee-sign', + 'sad-cry', + 'sad-tear', + 'satellite', + 'satellite-dish', + 'save', + 'school', + 'screwdriver', + 'scroll', + 'sd-card', + 'search', + 'search-dollar', + 'search-location', + 'search-minus', + 'search-plus', + 'seedling', + 'server', + 'shapes', + 'share', + 'share-alt', + 'share-alt-square', + 'share-square', + 'shekel-sign', + 'shield-alt', + 'shield-virus', + 'ship', + 'shipping-fast', + 'shoe-prints', + 'shopping-bag', + 'shopping-basket', + 'shopping-cart', + 'shower', + 'shuttle-van', + 'sigma', + 'sign', + 'sign-in-alt', + 'sign-language', + 'sign-out-alt', + 'signal', + 'signal-alt', + 'signal-alt-slash', + 'signal-slash', + 'signature', + 'sim-card', + 'sink', + 'sitemap', + 'skating', + 'skiing', + 'skiing-nordic', + 'skull', + 'skull-crossbones', + 'slash', + 'sleigh', + 'sliders-h', + 'smile', + 'smile-beam', + 'smile-wink', + 'smog', + 'smoking', + 'smoking-ban', + 'sms', + 'snowboarding', + 'snowflake', + 'snowman', + 'snowplow', + 'soap', + 'socks', + 'solar-panel', + 'sort', + 'sort-alpha-down', + 'sort-alpha-down-alt', + 'sort-alpha-up', + 'sort-alpha-up-alt', + 'sort-amount-down', + 'sort-amount-down-alt', + 'sort-amount-up', + 'sort-amount-up-alt', + 'sort-down', + 'sort-numeric-down', + 'sort-numeric-down-alt', + 'sort-numeric-up', + 'sort-numeric-up-alt', + 'sort-up', + 'spa', + 'space-shuttle', + 'spell-check', + 'spider', + 'spinner', + 'splotch', + 'spray-can', + 'square', + 'square-full', + 'square-root', + 'square-root-alt', + 'stamp', + 'star', + 'star-and-crescent', + 'star-half', + 'star-half-alt', + 'star-of-david', + 'star-of-life', + 'step-backward', + 'step-forward', + 'stethoscope', + 'sticky-note', + 'stop', + 'stop-circle', + 'stopwatch', + 'stopwatch-20', + 'store', + 'store-alt', + 'store-alt-slash', + 'store-slash', + 'stream', + 'street-view', + 'strikethrough', + 'stroopwafel', + 'subscript', + 'subway', + 'suitcase', + 'suitcase-rolling', + 'sun', + 'superscript', + 'surprise', + 'swatchbook', + 'swimmer', + 'swimming-pool', + 'synagogue', + 'sync', + 'sync-alt', + 'syringe', + 'table', + 'table-tennis', + 'tablet', + 'tablet-alt', + 'tablets', + 'tachometer-alt', + 'tag', + 'tags', + 'tally', + 'tape', + 'tasks', + 'taxi', + 'teeth', + 'teeth-open', + 'temperature-high', + 'temperature-low', + 'tenge', + 'terminal', + 'text-height', + 'text-width', + 'th', + 'th-large', + 'th-list', + 'theater-masks', + 'thermometer', + 'thermometer-empty', + 'thermometer-full', + 'thermometer-half', + 'thermometer-quarter', + 'thermometer-three-quarters', + 'theta', + 'thumbs-down', + 'thumbs-up', + 'thumbtack', + 'ticket-alt', + 'tilde', + 'times', + 'times-circle', + 'tint', + 'tint-slash', + 'tired', + 'toggle-off', + 'toggle-on', + 'toilet', + 'toilet-paper', + 'toilet-paper-slash', + 'toolbox', + 'tools', + 'tooth', + 'torah', + 'torii-gate', + 'tractor', + 'trademark', + 'traffic-light', + 'trailer', + 'train', + 'tram', + 'transgender', + 'transgender-alt', + 'trash', + 'trash-alt', + 'trash-restore', + 'trash-restore-alt', + 'tree', + 'trophy', + 'truck', + 'truck-loading', + 'truck-monster', + 'truck-moving', + 'truck-pickup', + 'tshirt', + 'tty', + 'tv', + 'umbrella', + 'umbrella-beach', + 'underline', + 'undo', + 'undo-alt', + 'union', + 'universal-access', + 'university', + 'unlink', + 'unlock', + 'unlock-alt', + 'upload', + 'user', + 'user-alt', + 'user-alt-slash', + 'user-astronaut', + 'user-check', + 'user-circle', + 'user-clock', + 'user-cog', + 'user-edit', + 'user-friends', + 'user-graduate', + 'user-injured', + 'user-lock', + 'user-md', + 'user-minus', + 'user-ninja', + 'user-nurse', + 'user-plus', + 'user-secret', + 'user-shield', + 'user-slash', + 'user-tag', + 'user-tie', + 'user-times', + 'users', + 'users-cog', + 'users-slash', + 'utensil-spoon', + 'utensils', + 'value-absolute', + 'vector-square', + 'venus', + 'venus-double', + 'venus-mars', + 'vest', + 'vest-patches', + 'vial', + 'vials', + 'video', + 'video-slash', + 'vihara', + 'virus', + 'virus-slash', + 'viruses', + 'voicemail', + 'volleyball-ball', + 'volume', + 'volume-down', + 'volume-mute', + 'volume-off', + 'volume-slash', + 'volume-up', + 'vote-yea', + 'vr-cardboard', + 'walking', + 'wallet', + 'warehouse', + 'water', + 'wave-square', + 'weight', + 'weight-hanging', + 'wheelchair', + 'wifi', + 'wifi-slash', + 'wind', + 'window-close', + 'window-maximize', + 'window-minimize', + 'window-restore', + 'wine-bottle', + 'wine-glass', + 'wine-glass-alt', + 'won-sign', + 'wrench', + 'x-ray', + 'yen-sign', + 'yin-yang' + ] +} diff --git a/src/components/ImageViewer/index.ts b/src/components/ImageViewer/index.ts new file mode 100644 index 0000000..35764d6 --- /dev/null +++ b/src/components/ImageViewer/index.ts @@ -0,0 +1,33 @@ +import ImageViewer from './src/ImageViewer.vue' +import { isClient } from '@/utils/is' +import { createVNode, render, VNode } from 'vue' +import { ImageViewerProps } from './src/types' + +let instance: Nullable<VNode> = null + +export function createImageViewer(options: ImageViewerProps) { + if (!isClient) return + const { + urlList, + initialIndex = 0, + infinite = true, + hideOnClickModal = false, + teleported = false, + zIndex = 2000, + show = true + } = options + + const propsData: Partial<ImageViewerProps> = {} + const container = document.createElement('div') + propsData.urlList = urlList + propsData.initialIndex = initialIndex + propsData.infinite = infinite + propsData.hideOnClickModal = hideOnClickModal + propsData.teleported = teleported + propsData.zIndex = zIndex + propsData.show = show + + document.body.appendChild(container) + instance = createVNode(ImageViewer, propsData) + render(instance, container) +} diff --git a/src/components/ImageViewer/src/ImageViewer.vue b/src/components/ImageViewer/src/ImageViewer.vue new file mode 100644 index 0000000..c84d06b --- /dev/null +++ b/src/components/ImageViewer/src/ImageViewer.vue @@ -0,0 +1,35 @@ +<script lang="ts" setup> +import { PropType } from 'vue' +import { propTypes } from '@/utils/propTypes' + +defineOptions({ name: 'ImageViewer' }) + +const props = defineProps({ + urlList: { + type: Array as PropType<string[]>, + default: (): string[] => [] + }, + zIndex: propTypes.number.def(200), + initialIndex: propTypes.number.def(0), + infinite: propTypes.bool.def(true), + hideOnClickModal: propTypes.bool.def(false), + teleported: propTypes.bool.def(false), + show: propTypes.bool.def(false) +}) + +const getBindValue = computed(() => { + const propsData: Recordable = { ...props } + delete propsData.show + return propsData +}) + +const show = ref(props.show) + +const close = () => { + show.value = false +} +</script> + +<template> + <ElImageViewer v-if="show" v-bind="getBindValue" @close="close" /> +</template> diff --git a/src/components/ImageViewer/src/types.ts b/src/components/ImageViewer/src/types.ts new file mode 100644 index 0000000..2fff4c0 --- /dev/null +++ b/src/components/ImageViewer/src/types.ts @@ -0,0 +1,9 @@ +export interface ImageViewerProps { + urlList?: string[] + zIndex?: number + initialIndex?: number + infinite?: boolean + hideOnClickModal?: boolean + teleported?: boolean + show?: boolean +} diff --git a/src/components/Infotip/index.ts b/src/components/Infotip/index.ts new file mode 100644 index 0000000..413fa5f --- /dev/null +++ b/src/components/Infotip/index.ts @@ -0,0 +1,3 @@ +import Infotip from './src/Infotip.vue' + +export { Infotip } diff --git a/src/components/Infotip/src/Infotip.vue b/src/components/Infotip/src/Infotip.vue new file mode 100644 index 0000000..0afd692 --- /dev/null +++ b/src/components/Infotip/src/Infotip.vue @@ -0,0 +1,54 @@ +<script lang="ts" setup> +import { PropType } from 'vue' +import { useDesign } from '@/hooks/web/useDesign' +import { propTypes } from '@/utils/propTypes' +import { TipSchema } from '@/types/infoTip' + +defineOptions({ name: 'InfoTip' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('infotip') + +defineProps({ + title: propTypes.string.def(''), + schema: { + type: Array as PropType<Array<string | TipSchema>>, + required: true, + default: () => [] + }, + showIndex: propTypes.bool.def(true), + highlightColor: propTypes.string.def('var(--el-color-primary)') +}) + +const emit = defineEmits(['click']) + +const keyClick = (key: string) => { + emit('click', key) +} +</script> + +<template> + <div + :class="[ + prefixCls, + 'p-20px mb-20px border-1px border-solid border-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]' + ]" + > + <div v-if="title" :class="[`${prefixCls}__header`, 'flex items-center']"> + <Icon :size="22" color="var(--el-color-primary)" icon="ep:warning-filled" /> + <span :class="[`${prefixCls}__title`, 'pl-5px text-16px font-bold']">{{ title }}</span> + </div> + <div :class="`${prefixCls}__content`"> + <p v-for="(item, $index) in schema" :key="$index" class="mt-15px text-14px"> + <Highlight + :color="highlightColor" + :keys="typeof item === 'string' ? [] : item.keys" + @click="keyClick" + > + {{ showIndex ? `${$index + 1}、` : '' }}{{ typeof item === 'string' ? item : item.label }} + </Highlight> + </p> + </div> + </div> +</template> diff --git a/src/components/InputPassword/index.ts b/src/components/InputPassword/index.ts new file mode 100644 index 0000000..1dcc38e --- /dev/null +++ b/src/components/InputPassword/index.ts @@ -0,0 +1,3 @@ +import InputPassword from './src/InputPassword.vue' + +export { InputPassword } diff --git a/src/components/InputPassword/src/InputPassword.vue b/src/components/InputPassword/src/InputPassword.vue new file mode 100644 index 0000000..b8c93e7 --- /dev/null +++ b/src/components/InputPassword/src/InputPassword.vue @@ -0,0 +1,152 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { useConfigGlobal } from '@/hooks/web/useConfigGlobal' +import type { ZxcvbnResult } from '@zxcvbn-ts/core' +import { zxcvbn } from '@zxcvbn-ts/core' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'InputPassword' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('input-password') + +const props = defineProps({ + // 是否显示密码强度 + strength: propTypes.bool.def(false), + modelValue: propTypes.string.def('') +}) + +watch( + () => props.modelValue, + (val: string) => { + if (val === unref(valueRef)) return + valueRef.value = val + } +) + +const { configGlobal } = useConfigGlobal() + +const emit = defineEmits(['update:modelValue']) + +// 设置input的type属性 +const textType = ref<'password' | 'text'>('password') + +const changeTextType = () => { + textType.value = unref(textType) === 'text' ? 'password' : 'text' +} + +// 输入框的值 +const valueRef = ref(props.modelValue) + +// 监听 +watch( + () => valueRef.value, + (val: string) => { + emit('update:modelValue', val) + } +) + +// 获取密码强度 +const getPasswordStrength = computed(() => { + const value = unref(valueRef) + const zxcvbnRef = zxcvbn(unref(valueRef)) as ZxcvbnResult + return value ? zxcvbnRef.score : -1 +}) + +const getIconName = computed(() => (unref(textType) === 'password' ? 'ep:hide' : 'ep:view')) +</script> + +<template> + <div :class="[prefixCls, `${prefixCls}--${configGlobal?.size}`]"> + <ElInput v-model="valueRef" :type="textType" v-bind="$attrs"> + <template #suffix> + <Icon :icon="getIconName" class="el-input__icon cursor-pointer" @click="changeTextType" /> + </template> + </ElInput> + <div + v-if="strength" + :class="`${prefixCls}__bar`" + class="relative mb-6px ml-auto mr-auto mt-10px h-6px" + > + <div :class="`${prefixCls}__bar--fill`" :data-score="getPasswordStrength"></div> + </div> + </div> +</template> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-input-password; + +.#{$prefix-cls} { + :deep(.#{$elNamespace}-input__clear) { + margin-left: 5px; + } + + &__bar { + background-color: var(--el-text-color-disabled); + border-radius: var(--el-border-radius-base); + + &::before, + &::after { + position: absolute; + z-index: 10; + display: block; + width: 20%; + height: inherit; + background-color: transparent; + border-color: var(--el-color-white); + border-style: solid; + border-width: 0 5px; + content: ''; + } + + &::before { + left: 20%; + } + + &::after { + right: 20%; + } + + &--fill { + position: absolute; + width: 0; + height: inherit; + background-color: transparent; + border-radius: inherit; + transition: + width 0.5s ease-in-out, + background 0.25s; + + &[data-score='0'] { + width: 20%; + background-color: var(--el-color-danger); + } + + &[data-score='1'] { + width: 40%; + background-color: var(--el-color-danger); + } + + &[data-score='2'] { + width: 60%; + background-color: var(--el-color-warning); + } + + &[data-score='3'] { + width: 80%; + background-color: var(--el-color-success); + } + + &[data-score='4'] { + width: 100%; + background-color: var(--el-color-success); + } + } + } + + &--mini > &__bar { + border-radius: var(--el-border-radius-small); + } +} +</style> diff --git a/src/components/InputWithColor/index.vue b/src/components/InputWithColor/index.vue new file mode 100644 index 0000000..2bc5317 --- /dev/null +++ b/src/components/InputWithColor/index.vue @@ -0,0 +1,59 @@ +<template> + <el-input v-model="valueRef" v-bind="$attrs"> + <template #append> + <el-color-picker v-model="colorRef" :predefine="PREDEFINE_COLORS" /> + </template> + </el-input> +</template> + +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { PREDEFINE_COLORS } from '@/utils/color' + +/** + * 带颜色选择器输入框 + */ +defineOptions({ name: 'InputWithColor' }) + +const props = defineProps({ + modelValue: propTypes.string.def('').isRequired, + color: propTypes.string.def('').isRequired +}) + +watch( + () => props.modelValue, + (val: string) => { + if (val === unref(valueRef)) return + valueRef.value = val + } +) + +const emit = defineEmits(['update:modelValue', 'update:color']) + +// 输入框的值 +const valueRef = ref(props.modelValue) +watch( + () => valueRef.value, + (val: string) => { + emit('update:modelValue', val) + } +) +// 颜色 +const colorRef = ref(props.color) +watch( + () => colorRef.value, + (val: string) => { + emit('update:color', val) + } +) +</script> +<style scoped lang="scss"> +:deep(.el-input-group__append) { + padding: 0; + .el-color-picker__trigger { + padding: 0; + border-left: none; + border-radius: 0 var(--el-input-border-radius) var(--el-input-border-radius) 0; + } +} +</style> diff --git a/src/components/MagicCubeEditor/index.vue b/src/components/MagicCubeEditor/index.vue new file mode 100644 index 0000000..9b9a43c --- /dev/null +++ b/src/components/MagicCubeEditor/index.vue @@ -0,0 +1,270 @@ +<template> + <div class="relative"> + <table class="cube-table"> + <!-- 底层:魔方矩阵 --> + <tbody> + <tr v-for="(rowCubes, row) in cubes" :key="row"> + <td + v-for="(cube, col) in rowCubes" + :key="col" + :class="['cube', { active: cube.active }]" + :style="{ + width: `${cubeSize}px`, + height: `${cubeSize}px` + }" + @click="handleCubeClick(row, col)" + @mouseenter="handleCellHover(row, col)" + > + <Icon icon="ep-plus" /> + </td> + </tr> + </tbody> + <!-- 顶层:热区 --> + <div + v-for="(hotArea, index) in hotAreas" + :key="index" + class="hot-area" + :style="{ + top: `${cubeSize * hotArea.top}px`, + left: `${cubeSize * hotArea.left}px`, + height: `${cubeSize * hotArea.height}px`, + width: `${cubeSize * hotArea.width}px` + }" + @click="handleHotAreaSelected(hotArea, index)" + @mouseover="exitHotAreaSelectMode" + > + <!-- 右上角热区删除按钮 --> + <div + v-if="selectedHotAreaIndex === index" + class="btn-delete" + @click="handleDeleteHotArea(index)" + > + <Icon icon="ep:circle-close-filled" /> + </div> + {{ `${hotArea.width}×${hotArea.height}` }} + </div> + </table> + </div> +</template> +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import * as vueTypes from 'vue-types' +import { Point, Rect, isContains, isOverlap, createRect } from './util' + +// 魔方编辑器 +// 有两部分组成: +// 1. 魔方矩阵:位于底层,由方块组件的二维表格,用于创建热区 +// 操作方法: +// 1.1 点击其中一个方块就会进入热区选择模式 +// 1.2 再次点击另外一个方块时,结束热区选择模式 +// 1.3 在两个方块中间的区域创建热区 +// 如果两次点击的都是同一方块,就只创建一个格子的热区 +// 2. 热区:位于顶层,采用绝对定位,覆盖在魔方矩阵上面。 +defineOptions({ name: 'MagicCubeEditor' }) + +/** + * 方块 + * @property active 是否激活 + */ +type Cube = Point & { active: boolean } + +// 定义属性 +const props = defineProps({ + // 热区列表 + modelValue: vueTypes.array<any>().isRequired, + // 行数,默认 4 行 + rows: propTypes.number.def(4), + // 列数,默认 4 列 + cols: propTypes.number.def(4), + // 方块大小,单位px,默认75px + cubeSize: propTypes.number.def(75) +}) + +// 魔方矩阵:所有的方块 +const cubes = ref<Cube[][]>([]) +// 监听行数、列数变化 +watch( + () => [props.rows, props.cols], + () => { + // 清空魔方 + cubes.value = [] + if (!props.rows || !props.cols) return + + // 初始化魔方 + for (let row = 0; row < props.rows; row++) { + cubes.value[row] = [] + for (let col = 0; col < props.cols; col++) { + cubes.value[row].push({ x: col, y: row, active: false }) + } + } + }, + { immediate: true } +) + +// 热区列表 +const hotAreas = ref<Rect[]>([]) +// 初始化热区 +watch( + () => props.modelValue, + () => (hotAreas.value = props.modelValue || []), + { immediate: true } +) + +// 热区起始方块 +const hotAreaBeginCube = ref<Cube>() +// 是否开启了热区选择模式 +const isHotAreaSelectMode = () => !!hotAreaBeginCube.value +/** + * 处理鼠标点击方块 + * + * @param currentRow 当前行号 + * @param currentCol 当前列号 + */ +const handleCubeClick = (currentRow: number, currentCol: number) => { + const currentCube = cubes.value[currentRow][currentCol] + // 情况1:进入热区选择模式 + if (!isHotAreaSelectMode()) { + hotAreaBeginCube.value = currentCube + hotAreaBeginCube.value.active = true + return + } + + // 情况2:结束热区选择模式 + hotAreas.value.push(createRect(hotAreaBeginCube.value!, currentCube)) + // 结束热区选择模式 + exitHotAreaSelectMode() + // 创建后就选中热区 + let hotAreaIndex = hotAreas.value.length - 1 + handleHotAreaSelected(hotAreas.value[hotAreaIndex], hotAreaIndex) + // 发送热区变动通知 + emitUpdateModelValue() +} +/** + * 处理鼠标经过方块 + * + * @param currentRow 当前行号 + * @param currentCol 当前列号 + */ +const handleCellHover = (currentRow: number, currentCol: number) => { + // 当前没有进入热区选择模式 + if (!isHotAreaSelectMode()) return + + // 当前已选的区域 + const currentSelectedArea = createRect( + hotAreaBeginCube.value!, + cubes.value[currentRow][currentCol] + ) + // 热区不允许重叠 + for (const hotArea of hotAreas.value) { + // 检查是否重叠 + if (isOverlap(hotArea, currentSelectedArea)) { + // 结束热区选择模式 + exitHotAreaSelectMode() + + return + } + } + + // 激活选中区域内部的方块 + eachCube((_, __, cube) => { + cube.active = isContains(currentSelectedArea, cube) + }) +} +/** + * 处理热区删除 + * + * @param index 热区索引 + */ +const handleDeleteHotArea = (index: number) => { + hotAreas.value.splice(index, 1) + // 结束热区选择模式 + exitHotAreaSelectMode() + // 发送热区变动通知 + emitUpdateModelValue() +} + +// 发送模型更新 +const emit = defineEmits(['update:modelValue', 'hotAreaSelected']) +// 发送热区变动通知 +const emitUpdateModelValue = () => emit('update:modelValue', hotAreas) + +// 热区选中 +const selectedHotAreaIndex = ref(0) +const handleHotAreaSelected = (hotArea: Rect, index: number) => { + selectedHotAreaIndex.value = index + emit('hotAreaSelected', hotArea, index) +} + +/** + * 结束热区选择模式 + */ +function exitHotAreaSelectMode() { + // 移除方块激活标记 + eachCube((_, __, cube) => { + if (cube.active) { + cube.active = false + } + }) + + // 清除起点 + hotAreaBeginCube.value = undefined +} + +/** + * 迭代魔方矩阵 + * @param callback 回调 + */ +const eachCube = (callback: (x: number, y: number, cube: Cube) => void) => { + for (let x = 0; x < cubes.value.length; x++) { + for (let y = 0; y < cubes.value[x].length; y++) { + callback(x, y, cubes.value[x][y]) + } + } +} +</script> +<style lang="scss" scoped> +.cube-table { + position: relative; + border-spacing: 0; + border-collapse: collapse; + + .cube { + border: 1px solid var(--el-border-color); + text-align: center; + color: var(--el-text-color-secondary); + cursor: pointer; + box-sizing: border-box; + &.active { + background: var(--el-color-primary-light-9); + } + } + + .hot-area { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--el-color-primary); + background: var(--el-color-primary-light-8); + color: var(--el-color-primary); + box-sizing: border-box; + border-spacing: 0; + border-collapse: collapse; + cursor: pointer; + + .btn-delete { + z-index: 1; + position: absolute; + top: -8px; + right: -8px; + height: 16px; + width: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: #fff; + } + } +} +</style> diff --git a/src/components/MagicCubeEditor/util.ts b/src/components/MagicCubeEditor/util.ts new file mode 100644 index 0000000..e7c6465 --- /dev/null +++ b/src/components/MagicCubeEditor/util.ts @@ -0,0 +1,72 @@ +// 坐标点 +export interface Point { + x: number + y: number +} + +// 矩形 +export interface Rect { + // 左上角 X 轴坐标 + left: number + // 左上角 Y 轴坐标 + top: number + // 右下角 X 轴坐标 + right: number + // 右下角 Y 轴坐标 + bottom: number + // 矩形宽度 + width: number + // 矩形高度 + height: number +} + +/** + * 判断两个矩形是否重叠 + * @param a 矩形 A + * @param b 矩形 B + */ +export const isOverlap = (a: Rect, b: Rect): boolean => { + return ( + a.left < b.left + b.width && + a.left + a.width > b.left && + a.top < b.top + b.height && + a.height + a.top > b.top + ) +} +/** + * 检查坐标点是否在矩形内 + * @param hotArea 矩形 + * @param point 坐标 + */ +export const isContains = (hotArea: Rect, point: Point): boolean => { + return ( + point.x >= hotArea.left && + point.x < hotArea.right && + point.y >= hotArea.top && + point.y < hotArea.bottom + ) +} + +/** + * 在两个坐标点中间,创建一个矩形 + * + * 存在以下情况: + * 1. 两个坐标点是同一个位置,只占一个位置的正方形,宽高都为 1 + * 2. X 轴坐标相同,只占一行的矩形,高度为 1 + * 3. Y 轴坐标相同,只占一列的矩形,宽度为 1 + * 4. 多行多列的矩形 + * + * @param a 坐标点一 + * @param b 坐标点二 + */ +export const createRect = (a: Point, b: Point): Rect => { + // 计算矩形的范围 + const [left, left2] = [a.x, b.x].sort() + const [top, top2] = [a.y, b.y].sort() + const right = left2 + 1 + const bottom = top2 + 1 + const height = bottom - top + const width = right - left + + return { left, right, top, bottom, height, width } +} diff --git a/src/components/MarkdownView/index.vue b/src/components/MarkdownView/index.vue new file mode 100644 index 0000000..74764d5 --- /dev/null +++ b/src/components/MarkdownView/index.vue @@ -0,0 +1,204 @@ +<template> + <div ref="contentRef" class="markdown-view" v-html="renderedMarkdown"></div> +</template> + +<script setup lang="ts"> +import { useClipboard } from '@vueuse/core' +import MarkdownIt from 'markdown-it' +import 'highlight.js/styles/vs2015.min.css' +import hljs from 'highlight.js' + +// 定义组件属性 +const props = defineProps({ + content: { + type: String, + required: true + } +}) + +const message = useMessage() // 消息弹窗 +const { copy } = useClipboard() // 初始化 copy 到粘贴板 +const contentRef = ref() + +const md = new MarkdownIt({ + highlight: function (str, lang) { + if (lang && hljs.getLanguage(lang)) { + try { + const copyHtml = `<div id="copy" data-copy='${str}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>` + return `<pre style="position: relative;">${copyHtml}<code class="hljs">${hljs.highlight(lang, str, true).value}</code></pre>` + } catch (__) {} + } + return `` + } +}) + +/** 渲染 markdown */ +const renderedMarkdown = computed(() => { + return md.render(props.content) +}) + +/** 初始化 **/ +onMounted(async () => { + // 添加 copy 监听 + contentRef.value.addEventListener('click', (e: any) => { + if (e.target.id === 'copy') { + copy(e.target?.dataset?.copy) + message.success('复制成功!') + } + }) +}) +</script> + +<style lang="scss"> +.markdown-view { + font-family: PingFang SC; + font-size: 0.95rem; + font-weight: 400; + line-height: 1.6rem; + letter-spacing: 0em; + text-align: left; + color: #3b3e55; + max-width: 100%; + + pre { + position: relative; + } + + pre code.hljs { + width: auto; + } + + code.hljs { + border-radius: 6px; + padding-top: 20px; + width: auto; + @media screen and (min-width: 1536px) { + width: 960px; + } + + @media screen and (max-width: 1536px) and (min-width: 1024px) { + width: calc(100vw - 400px - 64px - 32px * 2); + } + + @media screen and (max-width: 1024px) and (min-width: 768px) { + width: calc(100vw - 32px * 2); + } + + @media screen and (max-width: 768px) { + width: calc(100vw - 16px * 2); + } + } + + p, + code.hljs { + margin-bottom: 16px; + } + + p { + //margin-bottom: 1rem !important; + margin: 0; + margin-bottom: 3px; + } + + /* 标题通用格式 */ + h1, + h2, + h3, + h4, + h5, + h6 { + color: var(--color-G900); + margin: 24px 0 8px; + font-weight: 600; + } + + h1 { + font-size: 22px; + line-height: 32px; + } + + h2 { + font-size: 20px; + line-height: 30px; + } + + h3 { + font-size: 18px; + line-height: 28px; + } + + h4 { + font-size: 16px; + line-height: 26px; + } + + h5 { + font-size: 16px; + line-height: 24px; + } + + h6 { + font-size: 16px; + line-height: 24px; + } + + /* 列表(有序,无序) */ + ul, + ol { + margin: 0 0 8px 0; + padding: 0; + font-size: 16px; + line-height: 24px; + color: #3b3e55; // var(--color-CG600); + } + + li { + margin: 4px 0 0 20px; + margin-bottom: 1rem; + } + + ol > li { + list-style-type: decimal; + margin-bottom: 1rem; + // 表达式,修复有序列表序号展示不全的问题 + // &:nth-child(n + 10) { + // margin-left: 30px; + // } + + // &:nth-child(n + 100) { + // margin-left: 30px; + // } + } + + ul > li { + list-style-type: disc; + font-size: 16px; + line-height: 24px; + margin-right: 11px; + margin-bottom: 1rem; + color: #3b3e55; // var(--color-G900); + } + + ol ul, + ol ul > li, + ul ul, + ul ul li { + // list-style: circle; + font-size: 16px; + list-style: none; + margin-left: 6px; + margin-bottom: 1rem; + } + + ul ul ul, + ul ul ul li, + ol ol, + ol ol > li, + ol ul ul, + ol ul ul > li, + ul ol, + ul ol > li { + list-style: square; + } +} +</style> diff --git a/src/components/OperateLogV2/index.ts b/src/components/OperateLogV2/index.ts new file mode 100644 index 0000000..f69c222 --- /dev/null +++ b/src/components/OperateLogV2/index.ts @@ -0,0 +1,3 @@ +import OperateLogV2 from './src/OperateLogV2.vue' + +export { OperateLogV2 } diff --git a/src/components/OperateLogV2/src/OperateLogV2.vue b/src/components/OperateLogV2/src/OperateLogV2.vue new file mode 100644 index 0000000..6acc1cc --- /dev/null +++ b/src/components/OperateLogV2/src/OperateLogV2.vue @@ -0,0 +1,105 @@ +<!-- 某个记录的操作日志列表,目前主要用于 CRM 客户、商机等详情界面 --> +<template> + <div class="pt-20px"> + <el-timeline> + <el-timeline-item + v-for="(log, index) in logList" + :key="index" + :timestamp="formatDate(log.createTime)" + placement="top" + > + <div class="el-timeline-right-content"> + <el-tag class="mr-10px" type="success">{{ log.userName }}</el-tag> + {{ log.action }} + </div> + <template #dot> + <span :style="{ backgroundColor: getUserTypeColor(log.userType) }" class="dot-node-style"> + {{ getDictLabel(DICT_TYPE.USER_TYPE, log.userType)[0] }} + </span> + </template> + </el-timeline-item> + </el-timeline> + </div> +</template> + +<script lang="ts" setup> +import { OperateLogVO } from '@/api/system/operatelog' +import { formatDate } from '@/utils/formatTime' +import { DICT_TYPE, getDictLabel, getDictObj } from '@/utils/dict' +import { ElTag } from 'element-plus' + +defineOptions({ name: 'OperateLogV2' }) + +interface Props { + logList: OperateLogVO[] // 操作日志列表 +} + +withDefaults(defineProps<Props>(), { + logList: () => [] +}) + +/** 获得 userType 颜色 */ +const getUserTypeColor = (type: number) => { + const dict = getDictObj(DICT_TYPE.USER_TYPE, type) + switch (dict?.colorType) { + case 'success': + return '#67C23A' + case 'info': + return '#909399' + case 'warning': + return '#E6A23C' + case 'danger': + return '#F56C6C' + } + return '#409EFF' +} +</script> + +<style lang="scss" scoped> +// 时间线样式调整 +:deep(.el-timeline) { + margin: 10px 0 0 110px; + + .el-timeline-item__wrapper { + position: relative; + top: -20px; + + .el-timeline-item__timestamp { + position: absolute !important; + top: 10px; + left: -150px; + } + } + + .el-timeline-right-content { + display: flex; + align-items: center; + min-height: 30px; + padding: 10px; + background-color: #fff; + + &::before { + position: absolute; + top: 10px; + left: 13px; /* 将伪元素水平居中 */ + border-color: transparent #fff transparent transparent; /* 尖角颜色,左侧朝向 */ + border-style: solid; + border-width: 8px; /* 调整尖角大小 */ + content: ''; /* 必须设置 content 属性 */ + } + } +} + +.dot-node-style { + position: absolute; + left: -5px; + display: flex; + width: 20px; + height: 20px; + font-size: 10px; + color: #fff; + border-radius: 50%; + justify-content: center; + align-items: center; +} +</style> diff --git a/src/components/Pagination/index.vue b/src/components/Pagination/index.vue new file mode 100644 index 0000000..6bb00b3 --- /dev/null +++ b/src/components/Pagination/index.vue @@ -0,0 +1,87 @@ +<!-- 基于 ruoyi-vue3 的 Pagination 重构,核心是简化无用的属性,并使用 ts 重写 --> +<template> + <el-pagination + v-show="total > 0" + v-model:current-page="currentPage" + v-model:page-size="pageSize" + :background="true" + :page-sizes="[10, 20, 30, 50, 100]" + :pager-count="pagerCount" + :total="total" + :small="isSmall" + class="float-right mb-15px mt-15px" + layout="total, sizes, prev, pager, next, jumper" + @size-change="handleSizeChange" + @current-change="handleCurrentChange" + /> +</template> +<script lang="ts" setup> +import { computed, watchEffect } from 'vue' +import { useAppStore } from '@/store/modules/app' + +defineOptions({ name: 'Pagination' }) + +// 此处解决了当全局size为small的时候分页组件样式太大的问题 +const appStore = useAppStore() +const layoutCurrentSize = computed(() => appStore.currentSize) +const isSmall = ref<boolean>(layoutCurrentSize.value === 'small') +watchEffect(() => { + isSmall.value = layoutCurrentSize.value === 'small' +}) + +const props = defineProps({ + // 总条目数 + total: { + required: true, + type: Number + }, + // 当前页数:pageNo + page: { + type: Number, + default: 1 + }, + // 每页显示条目个数:pageSize + limit: { + type: Number, + default: 20 + }, + // 设置最大页码按钮数。 页码按钮的数量,当总页数超过该值时会折叠 + // 移动端页码按钮的数量端默认值 5 + pagerCount: { + type: Number, + default: document.body.clientWidth < 992 ? 5 : 7 + } +}) + +const emit = defineEmits(['update:page', 'update:limit', 'pagination']) +const currentPage = computed({ + get() { + return props.page + }, + set(val) { + // 触发 update:page 事件,更新 limit 属性,从而更新 pageNo + emit('update:page', val) + } +}) +const pageSize = computed({ + get() { + return props.limit + }, + set(val) { + // 触发 update:limit 事件,更新 limit 属性,从而更新 pageSize + emit('update:limit', val) + } +}) +const handleSizeChange = (val) => { + // 如果修改后超过最大页面,强制跳转到第 1 页 + if (currentPage.value * val > props.total) { + currentPage.value = 1 + } + // 触发 pagination 事件,重新加载列表 + emit('pagination', { page: currentPage.value, limit: val }) +} +const handleCurrentChange = (val) => { + // 触发 pagination 事件,重新加载列表 + emit('pagination', { page: val, limit: pageSize.value }) +} +</script> diff --git a/src/components/Qrcode/index.ts b/src/components/Qrcode/index.ts new file mode 100644 index 0000000..ce46161 --- /dev/null +++ b/src/components/Qrcode/index.ts @@ -0,0 +1,3 @@ +import Qrcode from './src/Qrcode.vue' + +export { Qrcode } diff --git a/src/components/Qrcode/src/Qrcode.vue b/src/components/Qrcode/src/Qrcode.vue new file mode 100644 index 0000000..f0ce7b7 --- /dev/null +++ b/src/components/Qrcode/src/Qrcode.vue @@ -0,0 +1,253 @@ +<script lang="ts" setup> +import { computed, nextTick, PropType, ref, unref, watch } from 'vue' +import QRCode, { QRCodeRenderersOptions } from 'qrcode' +import { cloneDeep } from 'lodash-es' +import { propTypes } from '@/utils/propTypes' +import { useDesign } from '@/hooks/web/useDesign' +import { isString } from '@/utils/is' +import { QrcodeLogo } from '@/types/qrcode' + +defineOptions({ name: 'Qrcode' }) + +const props = defineProps({ + // img 或者 canvas,img不支持logo嵌套 + tag: propTypes.string.validate((v: string) => ['canvas', 'img'].includes(v)).def('canvas'), + // 二维码内容 + text: { + type: [String, Array] as PropType<string | Recordable[]>, + default: null + }, + // qrcode.js配置项 + options: { + type: Object as PropType<QRCodeRenderersOptions>, + default: () => ({}) + }, + // 宽度 + width: propTypes.number.def(200), + // logo + logo: { + type: [String, Object] as PropType<Partial<QrcodeLogo> | string>, + default: '' + }, + // 是否过期 + disabled: propTypes.bool.def(false), + // 过期提示内容 + disabledText: propTypes.string.def('') +}) + +const emit = defineEmits(['done', 'click', 'disabled-click']) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('qrcode') + +const { toCanvas, toDataURL } = QRCode + +const loading = ref(true) + +const wrapRef = ref<Nullable<HTMLCanvasElement | HTMLImageElement>>(null) + +const renderText = computed(() => String(props.text)) + +const wrapStyle = computed(() => { + return { + width: props.width + 'px', + height: props.width + 'px' + } +}) + +const initQrcode = async () => { + await nextTick() + const options = cloneDeep(props.options || {}) + if (props.tag === 'canvas') { + // 容错率,默认对内容少的二维码采用高容错率,内容多的二维码采用低容错率 + options.errorCorrectionLevel = + options.errorCorrectionLevel || getErrorCorrectionLevel(unref(renderText)) + const _width: number = await getOriginWidth(unref(renderText), options) + options.scale = props.width === 0 ? undefined : (props.width / _width) * 4 + const canvasRef: HTMLCanvasElement | any = await toCanvas( + unref(wrapRef) as HTMLCanvasElement, + unref(renderText), + options + ) + if (props.logo) { + const url = await createLogoCode(canvasRef) + emit('done', url) + loading.value = false + } else { + emit('done', canvasRef.toDataURL()) + loading.value = false + } + } else { + const url = await toDataURL(renderText.value, { + errorCorrectionLevel: 'H', + width: props.width, + ...options + }) + ;(unref(wrapRef) as HTMLImageElement).src = url + emit('done', url) + loading.value = false + } +} + +watch( + () => renderText.value, + (val) => { + if (!val) return + initQrcode() + }, + { + deep: true, + immediate: true + } +) + +const createLogoCode = (canvasRef: HTMLCanvasElement) => { + const canvasWidth = canvasRef.width + const logoOptions: QrcodeLogo = Object.assign( + { + logoSize: 0.15, + bgColor: '#ffffff', + borderSize: 0.05, + crossOrigin: 'anonymous', + borderRadius: 8, + logoRadius: 0 + }, + isString(props.logo) ? {} : props.logo + ) + const { + logoSize = 0.15, + bgColor = '#ffffff', + borderSize = 0.05, + crossOrigin = 'anonymous', + borderRadius = 8, + logoRadius = 0 + } = logoOptions + const logoSrc = isString(props.logo) ? props.logo : props.logo.src + const logoWidth = canvasWidth * logoSize + const logoXY = (canvasWidth * (1 - logoSize)) / 2 + const logoBgWidth = canvasWidth * (logoSize + borderSize) + const logoBgXY = (canvasWidth * (1 - logoSize - borderSize)) / 2 + + const ctx = canvasRef.getContext('2d') + if (!ctx) return + + // logo 底色 + canvasRoundRect(ctx)(logoBgXY, logoBgXY, logoBgWidth, logoBgWidth, borderRadius) + ctx.fillStyle = bgColor + ctx.fill() + + // logo + const image = new Image() + if (crossOrigin || logoRadius) { + image.setAttribute('crossOrigin', crossOrigin) + } + ;(image as any).src = logoSrc + + // 使用image绘制可以避免某些跨域情况 + const drawLogoWithImage = (image: HTMLImageElement) => { + ctx.drawImage(image, logoXY, logoXY, logoWidth, logoWidth) + } + + // 使用canvas绘制以获得更多的功能 + const drawLogoWithCanvas = (image: HTMLImageElement) => { + const canvasImage = document.createElement('canvas') + canvasImage.width = logoXY + logoWidth + canvasImage.height = logoXY + logoWidth + const imageCanvas = canvasImage.getContext('2d') + if (!imageCanvas || !ctx) return + imageCanvas.drawImage(image, logoXY, logoXY, logoWidth, logoWidth) + + canvasRoundRect(ctx)(logoXY, logoXY, logoWidth, logoWidth, logoRadius) + if (!ctx) return + const fillStyle = ctx.createPattern(canvasImage, 'no-repeat') + if (fillStyle) { + ctx.fillStyle = fillStyle + ctx.fill() + } + } + + // 将 logo绘制到 canvas上 + return new Promise((resolve: any) => { + image.onload = () => { + logoRadius ? drawLogoWithCanvas(image) : drawLogoWithImage(image) + resolve(canvasRef.toDataURL()) + } + }) +} + +// 得到原QrCode的大小,以便缩放得到正确的QrCode大小 +const getOriginWidth = async (content: string, options: QRCodeRenderersOptions) => { + const _canvas = document.createElement('canvas') + await toCanvas(_canvas, content, options) + return _canvas.width +} + +// 对于内容少的QrCode,增大容错率 +const getErrorCorrectionLevel = (content: string) => { + if (content.length > 36) { + return 'M' + } else if (content.length > 16) { + return 'Q' + } else { + return 'H' + } +} + +// copy来的方法,用于绘制圆角 +const canvasRoundRect = (ctx: CanvasRenderingContext2D) => { + return (x: number, y: number, w: number, h: number, r: number) => { + const minSize = Math.min(w, h) + if (r > minSize / 2) { + r = minSize / 2 + } + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.arcTo(x + w, y, x + w, y + h, r) + ctx.arcTo(x + w, y + h, x, y + h, r) + ctx.arcTo(x, y + h, x, y, r) + ctx.arcTo(x, y, x + w, y, r) + ctx.closePath() + return ctx + } +} + +const clickCode = () => { + emit('click') +} + +const disabledClick = () => { + emit('disabled-click') +} +</script> + +<template> + <div v-loading="loading" :class="[prefixCls, 'relative inline-block']" :style="wrapStyle"> + <component :is="tag" ref="wrapRef" @click="clickCode" /> + <div + v-if="disabled" + :class="`${prefixCls}--disabled`" + class="absolute left-0 top-0 h-full w-full flex items-center justify-center" + @click="disabledClick" + > + <div class="absolute left-[50%] top-[50%] font-bold"> + <Icon :size="30" color="var(--el-color-primary)" icon="ep:refresh-right" /> + <div>{{ disabledText }}</div> + </div> + </div> + </div> +</template> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-qrcode; + +.#{$prefix-cls} { + &--disabled { + background: rgb(255 255 255 / 95%); + + & > div { + transform: translate(-50%, -50%); + } + } +} +</style> diff --git a/src/components/RouterSearch/index.vue b/src/components/RouterSearch/index.vue new file mode 100644 index 0000000..c035242 --- /dev/null +++ b/src/components/RouterSearch/index.vue @@ -0,0 +1,111 @@ +<template> + <ElDialog v-if="isModal" v-model="showSearch" :show-close="false" title="菜单搜索"> + <el-select + filterable + :reserve-keyword="false" + remote + placeholder="请输入菜单内容" + :remote-method="remoteMethod" + style="width: 100%" + @change="handleChange" + > + <el-option + v-for="item in options" + :key="item.value" + :label="item.label" + :value="item.value" + /> + </el-select> + </ElDialog> + <div v-else class="custom-hover" @click.stop="showTopSearch = !showTopSearch"> + <Icon icon="ep:search" /> + <el-select + filterable + :reserve-keyword="false" + remote + placeholder="请输入菜单内容" + :remote-method="remoteMethod" + class="overflow-hidden transition-all-600" + :class="showTopSearch ? '!w-220px ml2' : '!w-0'" + @change="handleChange" + > + <el-option + v-for="item in options" + :key="item.value" + :label="item.label" + :value="item.value" + /> + </el-select> + </div> +</template> + +<script lang="ts" setup> +defineProps({ + isModal: { + type: Boolean, + default: true + } +}) + +const router = useRouter() // 路由对象 +const showSearch = ref(false) // 是否显示弹框 +const showTopSearch = ref(false) // 是否显示顶部搜索框 +const value: Ref = ref('') // 用户输入的值 + +const routers = router.getRoutes() // 路由对象 +const options = computed(() => { + // 提示选项 + if (!value.value) { + return [] + } + const list = routers.filter((item: any) => { + if (item.meta.title?.indexOf(value.value) > -1 || item.path.indexOf(value.value) > -1) { + return true + } + }) + return list.map((item) => { + return { + label: `${item.meta.title}${item.path}`, + value: item.path + } + }) +}) + +function remoteMethod(data) { + // 这里可以执行相应的操作(例如打开搜索框等) + value.value = data +} + +function handleChange(path) { + router.push({ path }) + hiddenTopSearch() +} + +function hiddenTopSearch() { + showTopSearch.value = false +} + +onMounted(() => { + window.addEventListener('keydown', listenKey) + window.addEventListener('click', hiddenTopSearch) +}) + +onUnmounted(() => { + window.removeEventListener('keydown', listenKey) + window.removeEventListener('click', hiddenTopSearch) +}) + +// 监听 ctrl + k +function listenKey(event) { + if ((event.ctrlKey || event.metaKey) && event.key === 'k') { + showSearch.value = !showSearch.value + // 这里可以执行相应的操作(例如打开搜索框等) + } +} + +defineExpose({ + openSearch: () => { + showSearch.value = true + } +}) +</script> diff --git a/src/components/Search/index.ts b/src/components/Search/index.ts new file mode 100644 index 0000000..fcc6f16 --- /dev/null +++ b/src/components/Search/index.ts @@ -0,0 +1,3 @@ +import Search from './src/Search.vue' + +export { Search } diff --git a/src/components/Search/src/Search.vue b/src/components/Search/src/Search.vue new file mode 100644 index 0000000..3218a63 --- /dev/null +++ b/src/components/Search/src/Search.vue @@ -0,0 +1,157 @@ +<script lang="ts" setup> +import { PropType } from 'vue' +import { propTypes } from '@/utils/propTypes' + +import { useForm } from '@/hooks/web/useForm' +import { findIndex } from '@/utils' +import { cloneDeep } from 'lodash-es' +import { FormSchema } from '@/types/form' + +defineOptions({ name: 'Search' }) + +const { t } = useI18n() + +const props = defineProps({ + // 生成Form的布局结构数组 + schema: { + type: Array as PropType<FormSchema[]>, + default: () => [] + }, + // 是否需要栅格布局 + isCol: propTypes.bool.def(false), + // 表单label宽度 + labelWidth: propTypes.oneOfType([String, Number]).def('auto'), + // 操作按钮风格位置 + layout: propTypes.string.validate((v: string) => ['inline', 'bottom'].includes(v)).def('inline'), + // 底部按钮的对齐方式 + buttomPosition: propTypes.string + .validate((v: string) => ['left', 'center', 'right'].includes(v)) + .def('center'), + showSearch: propTypes.bool.def(true), + showReset: propTypes.bool.def(true), + // 是否显示伸缩 + expand: propTypes.bool.def(false), + // 伸缩的界限字段 + expandField: propTypes.string.def(''), + inline: propTypes.bool.def(true), + model: { + type: Object as PropType<Recordable>, + default: () => ({}) + } +}) + +const emit = defineEmits(['search', 'reset']) + +const visible = ref(true) + +const newSchema = computed(() => { + let schema: FormSchema[] = cloneDeep(props.schema) + if (props.expand && props.expandField && !unref(visible)) { + const index = findIndex(schema, (v: FormSchema) => v.field === props.expandField) + if (index > -1) { + const length = schema.length + schema.splice(index + 1, length) + } + } + if (props.layout === 'inline') { + schema = schema.concat([ + { + field: 'action', + formItemProps: { + labelWidth: '0px' + } + } + ]) + } + return schema +}) + +const { register, elFormRef, methods } = useForm({ + model: props.model || {} +}) + +const search = async () => { + await unref(elFormRef)?.validate(async (isValid) => { + if (isValid) { + const { getFormData } = methods + const model = await getFormData() + emit('search', model) + } + }) +} + +const reset = async () => { + unref(elFormRef)?.resetFields() + const { getFormData } = methods + const model = await getFormData() + emit('reset', model) +} + +const bottonButtonStyle = computed(() => { + return { + textAlign: props.buttomPosition as unknown as 'left' | 'center' | 'right' + } +}) + +const setVisible = () => { + unref(elFormRef)?.resetFields() + visible.value = !unref(visible) +} +</script> + +<template> + <!-- update by 芋艿:class="-mb-15px" 用于降低和 ContentWrap 组件的底部距离,避免空隙过大 --> + <Form + :inline="inline" + :is-col="isCol" + :is-custom="false" + :label-width="labelWidth" + :schema="newSchema" + class="-mb-15px" + hide-required-asterisk + @register="register" + > + <template #action> + <div v-if="layout === 'inline'"> + <!-- update by 芋艿:去除搜索的 type="primary",颜色变淡一点 --> + <ElButton v-if="showSearch" @click="search"> + <Icon class="mr-5px" icon="ep:search" /> + {{ t('common.query') }} + </ElButton> + <!-- update by 芋艿:将 icon="ep:refresh-right" 修改成 icon="ep:refresh",和 ruoyi-vue 搜索保持一致 --> + <ElButton v-if="showReset" @click="reset"> + <Icon class="mr-5px" icon="ep:refresh" /> + {{ t('common.reset') }} + </ElButton> + <ElButton v-if="expand" text @click="setVisible"> + {{ t(visible ? 'common.shrink' : 'common.expand') }} + <Icon :icon="visible ? 'ep:arrow-up' : 'ep:arrow-down'" /> + </ElButton> + <!-- add by 芋艿:补充在搜索后的按钮 --> + <slot name="actionMore"></slot> + </div> + </template> + <template v-for="name in Object.keys($slots)" :key="name" #[name]> + <slot :name="name"></slot> + </template> + </Form> + + <template v-if="layout === 'bottom'"> + <div :style="bottonButtonStyle"> + <ElButton v-if="showSearch" type="primary" @click="search"> + <Icon class="mr-5px" icon="ep:search" /> + {{ t('common.query') }} + </ElButton> + <ElButton v-if="showReset" @click="reset"> + <Icon class="mr-5px" icon="ep:refresh-right" /> + {{ t('common.reset') }} + </ElButton> + <ElButton v-if="expand" text @click="setVisible"> + {{ t(visible ? 'common.shrink' : 'common.expand') }} + <Icon :icon="visible ? 'ep:arrow-up' : 'ep:arrow-down'" /> + </ElButton> + <!-- add by 芋艿:补充在搜索后的按钮 --> + <slot name="actionMore"></slot> + </div> + </template> +</template> diff --git a/src/components/ShortcutDateRangePicker/index.vue b/src/components/ShortcutDateRangePicker/index.vue new file mode 100644 index 0000000..117c079 --- /dev/null +++ b/src/components/ShortcutDateRangePicker/index.vue @@ -0,0 +1,84 @@ +<template> + <div class="flex flex-row items-center gap-2"> + <el-radio-group v-model="shortcutDays" @change="handleShortcutDaysChange"> + <el-radio-button :label="1">昨天</el-radio-button> + <el-radio-button :label="7">最近7天</el-radio-button> + <el-radio-button :label="30">最近30天</el-radio-button> + </el-radio-group> + <el-date-picker + v-model="times" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + :shortcuts="shortcuts" + class="!w-240px" + @change="emitDateRangePicker" + /> + <slot></slot> + </div> +</template> +<script lang="ts" setup> +import dayjs from 'dayjs' +import * as DateUtil from '@/utils/formatTime' + +/** 快捷日期范围选择组件 */ +defineOptions({ name: 'ShortcutDateRangePicker' }) + +const shortcutDays = ref(7) // 日期快捷天数(单选按钮组), 默认7天 +const times = ref<[string, string]>(['', '']) // 时间范围参数 +defineExpose({ times }) // 暴露时间范围参数 +/** 日期快捷选择 */ +const shortcuts = [ + { + text: '昨天', + value: () => DateUtil.getDayRange(new Date(), -1) + }, + { + text: '最近7天', + value: () => DateUtil.getLast7Days() + }, + { + text: '本月', + value: () => [dayjs().startOf('M'), dayjs().subtract(1, 'd')] + }, + { + text: '最近30天', + value: () => DateUtil.getLast30Days() + }, + { + text: '最近1年', + value: () => DateUtil.getLast1Year() + } +] + +/** 设置时间范围 */ +function setTimes() { + const beginDate = dayjs().subtract(shortcutDays.value, 'd') + const yesterday = dayjs().subtract(1, 'd') + times.value = DateUtil.getDateRange(beginDate, yesterday) +} + +/** 快捷日期单选按钮选中 */ +const handleShortcutDaysChange = async () => { + // 设置时间范围 + setTimes() + // 发送时间范围选中事件 + await emitDateRangePicker() +} + +/** 触发事件:时间范围选中 */ +const emits = defineEmits<{ + (e: 'change', times: [dayjs.ConfigType, dayjs.ConfigType]): void +}>() +/** 触发时间范围选中事件 */ +const emitDateRangePicker = async () => { + emits('change', times.value) +} + +/** 初始化 **/ +onMounted(() => { + handleShortcutDaysChange() +}) +</script> diff --git a/src/components/SimpleProcessDesigner/src/addNode.vue b/src/components/SimpleProcessDesigner/src/addNode.vue new file mode 100644 index 0000000..6d09ae8 --- /dev/null +++ b/src/components/SimpleProcessDesigner/src/addNode.vue @@ -0,0 +1,237 @@ +/* stylelint-disable order/properties-order */ +<template> + <div class="add-node-btn-box"> + <div class="add-node-btn"> + <el-popover placement="right-start" v-model="visible" width="auto"> + <div class="add-node-popover-body"> + <a class="add-node-popover-item approver" @click="addType(1)"> + <div class="item-wrapper"> + <span class="iconfont"></span> + </div> + <p>审批人</p> + </a> + <a class="add-node-popover-item notifier" @click="addType(2)"> + <div class="item-wrapper"> + <span class="iconfont"></span> + </div> + <p>抄送人</p> + </a> + <a class="add-node-popover-item condition" @click="addType(4)"> + <div class="item-wrapper"> + <span class="iconfont"></span> + </div> + <p>条件分支</p> + </a> + </div> + <template #reference> + <button class="btn" type="button"> + <span class="iconfont"></span> + </button> + </template> + </el-popover> + </div> + </div> +</template> +<script setup> +import { ref } from 'vue' +let props = defineProps({ + childNodeP: { + type: Object, + default: () => ({}) + } +}) +let emits = defineEmits(['update:childNodeP']) +let visible = ref(false) +const addType = (type) => { + visible.value = false + if (type != 4) { + var data + if (type == 1) { + data = { + nodeName: '审核人', + error: true, + type: 1, + settype: 1, + selectMode: 0, + selectRange: 0, + directorLevel: 1, + examineMode: 1, + noHanderAction: 1, + examineEndDirectorLevel: 0, + childNode: props.childNodeP, + nodeUserList: [] + } + } else if (type == 2) { + data = { + nodeName: '抄送人', + type: 2, + ccSelfSelectFlag: 1, + childNode: props.childNodeP, + nodeUserList: [] + } + } + emits('update:childNodeP', data) + } else { + emits('update:childNodeP', { + nodeName: '路由', + type: 4, + childNode: null, + conditionNodes: [ + { + nodeName: '条件1', + error: true, + type: 3, + priorityLevel: 1, + conditionList: [], + nodeUserList: [], + childNode: props.childNodeP + }, + { + nodeName: '条件2', + type: 3, + priorityLevel: 2, + conditionList: [], + nodeUserList: [], + childNode: null + } + ] + }) + } +} +</script> +<style scoped lang="scss"> +.add-node-btn-box { + width: 240px; + display: inline-flex; + -ms-flex-negative: 0; + flex-shrink: 0; + -webkit-box-flex: 1; + -ms-flex-positive: 1; + position: relative; + + &:before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: -1; + margin: auto; + width: 2px; + height: 100%; + background-color: #cacaca; + } + + .add-node-btn { + user-select: none; + width: 240px; + padding: 20px 0 32px; + display: flex; + -webkit-box-pack: center; + justify-content: center; + flex-shrink: 0; + -webkit-box-flex: 1; + flex-grow: 1; + + .btn { + outline: none; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1); + width: 30px; + height: 30px; + background: #3296fa; + border-radius: 50%; + position: relative; + border: none; + line-height: 30px; + -webkit-transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); + transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); + + .iconfont { + color: #fff; + font-size: 16px; + } + + &:hover { + transform: scale(1.3); + box-shadow: 0 13px 27px 0 rgba(0, 0, 0, 0.1); + } + + &:active { + transform: none; + background: #1e83e9; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1); + } + } + } +} + +.add-node-popover-body { + display: flex; + + .add-node-popover-item { + margin-right: 10px; + cursor: pointer; + text-align: center; + flex: 1; + color: #191f25 !important; + + .item-wrapper { + user-select: none; + display: inline-block; + width: 80px; + height: 80px; + margin-bottom: 5px; + background: #fff; + border: 1px solid #e2e2e2; + border-radius: 50%; + transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); + + .iconfont { + font-size: 35px; + line-height: 80px; + } + } + + &.approver { + .item-wrapper { + color: #ff943e; + } + } + + &.notifier { + .item-wrapper { + color: #3296fa; + } + } + + &.condition { + .item-wrapper { + color: #15bc83; + } + } + + &:hover { + .item-wrapper { + background: #3296fa; + box-shadow: 0 10px 20px 0 rgba(50, 150, 250, 0.4); + } + + .iconfont { + color: #fff; + } + } + + &:active { + .item-wrapper { + box-shadow: none; + background: #eaeaea; + } + + .iconfont { + color: inherit; + } + } + } +} +</style> diff --git a/src/components/SimpleProcessDesigner/src/nodeWrap.vue b/src/components/SimpleProcessDesigner/src/nodeWrap.vue new file mode 100644 index 0000000..3c9d5eb --- /dev/null +++ b/src/components/SimpleProcessDesigner/src/nodeWrap.vue @@ -0,0 +1,297 @@ +<!-- eslint-disable vue/no-mutating-props --> +<!-- + * @Date: 2022-09-21 14:41:53 + * @LastEditors: StavinLi 495727881@qq.com + * @LastEditTime: 2023-05-24 15:20:24 + * @FilePath: /Workflow-Vue3/src/components/nodeWrap.vue +--> +<template> + <div class="node-wrap" v-if="nodeConfig.type < 3"> + <div class="node-wrap-box" :class="(nodeConfig.type == 0 ? 'start-node ' : '') +(isTried && nodeConfig.error ? 'active error' : '')"> + <div class="title" :style="`background: rgb(${bgColors[nodeConfig.type]});`"> + <span v-if="nodeConfig.type == 0">{{ nodeConfig.nodeName }}</span> + <template v-else> + <span class="iconfont">{{nodeConfig.type == 1?'':''}}</span> + <input + v-if="isInput" + type="text" + class="ant-input editable-title-input" + @blur="blurEvent()" + @focus="$event.currentTarget.select()" + v-focus + v-model="nodeConfig.nodeName" + :placeholder="defaultText" + /> + <span v-else class="editable-title" @click="clickEvent()">{{ nodeConfig.nodeName }}</span> + <i class="anticon anticon-close close" @click="delNode"></i> + </template> + </div> + <div class="content" @click="setPerson"> + <div class="text"> + <span class="placeholder" v-if="!showText">请选择{{defaultText}}</span> + {{showText}} + </div> + <i class="anticon anticon-right arrow"></i> + </div> + <div class="error_tip" v-if="isTried && nodeConfig.error"> + <i class="anticon anticon-exclamation-circle"></i> + </div> + </div> + <addNode v-model:childNodeP="nodeConfig.childNode" /> + </div> + <div class="branch-wrap" v-if="nodeConfig.type == 4"> + <div class="branch-box-wrap"> + <div class="branch-box"> + <button class="add-branch" @click="addTerm">添加条件</button> + <div class="col-box" v-for="(item, index) in nodeConfig.conditionNodes" :key="index"> + <div class="condition-node"> + <div class="condition-node-box"> + <div class="auto-judge" :class="isTried && item.error ? 'error active' : ''"> + <div class="sort-left" v-if="index != 0" @click="arrTransfer(index, -1)"><</div> + <div class="title-wrapper"> + <input + v-if="isInputList[index]" + type="text" + class="ant-input editable-title-input" + @blur="blurEvent(index)" + @focus="$event.currentTarget.select()" + v-model="item.nodeName" + /> + <span v-else class="editable-title" @click="clickEvent(index)">{{ item.nodeName }}</span> + <span class="priority-title" @click="setPerson(item.priorityLevel)">优先级{{ item.priorityLevel }}</span> + <i class="anticon anticon-close close" @click="delTerm(index)"></i> + </div> + <div class="sort-right" v-if="index != nodeConfig.conditionNodes.length - 1" @click="arrTransfer(index)">></div> + <div class="content" @click="setPerson(item.priorityLevel)">{{ conditionStr(nodeConfig, index) }}</div> + <div class="error_tip" v-if="isTried && item.error"> + <i class="anticon anticon-exclamation-circle"></i> + </div> + </div> + <addNode v-model:childNodeP="item.childNode" /> + </div> + </div> + <nodeWrap v-if="item.childNode" v-model:nodeConfig="item.childNode" /> + <template v-if="index == 0"> + <div class="top-left-cover-line"></div> + <div class="bottom-left-cover-line"></div> + </template> + <template v-if="index == nodeConfig.conditionNodes.length - 1"> + <div class="top-right-cover-line"></div> + <div class="bottom-right-cover-line"></div> + </template> + </div> + </div> + <addNode v-model:childNodeP="nodeConfig.childNode" /> + </div> + </div> + <nodeWrap v-if="nodeConfig.childNode" v-model:nodeConfig="nodeConfig.childNode" /> +</template> +<script setup> +import addNode from './addNode.vue' +import { onMounted, ref, watch, getCurrentInstance, computed } from 'vue' +import { + arrToStr, + conditionStr, + setApproverStr, + copyerStr, + bgColors, + placeholderList +} from './util' +import { useWorkFlowStoreWithOut } from '@/store/modules/simpleWorkflow' +let _uid = getCurrentInstance().uid + +let props = defineProps({ + nodeConfig: { + type: Object, + default: () => ({}) + }, + flowPermission: { + type: Object, + // eslint-disable-next-line vue/require-valid-default-prop + default: () => [] + } +}) + +let defaultText = computed(() => { + return placeholderList[props.nodeConfig.type] +}) +let showText = computed(() => { + if (props.nodeConfig.type == 0) return arrToStr(props.flowPermission) || '所有人' + if (props.nodeConfig.type == 1) return setApproverStr(props.nodeConfig) + return copyerStr(props.nodeConfig) +}) + +let isInputList = ref([]) +let isInput = ref(false) +const resetConditionNodesErr = () => { + for (var i = 0; i < props.nodeConfig.conditionNodes.length; i++) { + // eslint-disable-next-line vue/no-mutating-props + props.nodeConfig.conditionNodes[i].error = + conditionStr(props.nodeConfig, i) == '请设置条件' && + i != props.nodeConfig.conditionNodes.length - 1 + } +} +onMounted(() => { + if (props.nodeConfig.type == 1) { + // eslint-disable-next-line vue/no-mutating-props + props.nodeConfig.error = !setApproverStr(props.nodeConfig) + } else if (props.nodeConfig.type == 2) { + // eslint-disable-next-line vue/no-mutating-props + props.nodeConfig.error = !copyerStr(props.nodeConfig) + } else if (props.nodeConfig.type == 4) { + resetConditionNodesErr() + } +}) +let emits = defineEmits(['update:flowPermission', 'update:nodeConfig']) +let store = useWorkFlowStoreWithOut() +let { + setPromoter, + setApprover, + setCopyer, + setCondition, + setFlowPermission, + setApproverConfig, + setCopyerConfig, + setConditionsConfig +} = store +let isTried = computed(() => store.isTried) +let flowPermission1 = computed(() => store.flowPermission1) +let approverConfig1 = computed(() => store.approverConfig1) +let copyerConfig1 = computed(() => store.copyerConfig1) +let conditionsConfig1 = computed(() => store.conditionsConfig1) +watch(flowPermission1, (flow) => { + if (flow.flag && flow.id === _uid) { + emits('update:flowPermission', flow.value) + } +}) +watch(approverConfig1, (approver) => { + if (approver.flag && approver.id === _uid) { + emits('update:nodeConfig', approver.value) + } +}) +watch(copyerConfig1, (copyer) => { + if (copyer.flag && copyer.id === _uid) { + emits('update:nodeConfig', copyer.value) + } +}) +watch(conditionsConfig1, (condition) => { + if (condition.flag && condition.id === _uid) { + emits('update:nodeConfig', condition.value) + } +}) + +const clickEvent = (index) => { + if (index || index === 0) { + isInputList.value[index] = true + } else { + isInput.value = true + } +} +const blurEvent = (index) => { + if (index || index === 0) { + isInputList.value[index] = false + // eslint-disable-next-line vue/no-mutating-props + props.nodeConfig.conditionNodes[index].nodeName = + props.nodeConfig.conditionNodes[index].nodeName || '条件' + } else { + isInput.value = false + // eslint-disable-next-line vue/no-mutating-props + props.nodeConfig.nodeName = props.nodeConfig.nodeName || defaultText + } +} +const delNode = () => { + emits('update:nodeConfig', props.nodeConfig.childNode) +} +const addTerm = () => { + let len = props.nodeConfig.conditionNodes.length + 1 + // eslint-disable-next-line vue/no-mutating-props + props.nodeConfig.conditionNodes.push({ + nodeName: '条件' + len, + type: 3, + priorityLevel: len, + conditionList: [], + nodeUserList: [], + childNode: null + }) + resetConditionNodesErr() + emits('update:nodeConfig', props.nodeConfig) +} +const delTerm = (index) => { + // eslint-disable-next-line vue/no-mutating-props + props.nodeConfig.conditionNodes.splice(index, 1) + props.nodeConfig.conditionNodes.map((item, index) => { + item.priorityLevel = index + 1 + item.nodeName = `条件${index + 1}` + }) + resetConditionNodesErr() + emits('update:nodeConfig', props.nodeConfig) + if (props.nodeConfig.conditionNodes.length == 1) { + if (props.nodeConfig.childNode) { + if (props.nodeConfig.conditionNodes[0].childNode) { + reData(props.nodeConfig.conditionNodes[0].childNode, props.nodeConfig.childNode) + } else { + // eslint-disable-next-line vue/no-mutating-props + props.nodeConfig.conditionNodes[0].childNode = props.nodeConfig.childNode + } + } + emits('update:nodeConfig', props.nodeConfig.conditionNodes[0].childNode) + } +} +const reData = (data, addData) => { + if (!data.childNode) { + data.childNode = addData + } else { + reData(data.childNode, addData) + } +} +const setPerson = (priorityLevel) => { + var { type } = props.nodeConfig + if (type == 0) { + setPromoter(true) + setFlowPermission({ + value: props.flowPermission, + flag: false, + id: _uid + }) + } else if (type == 1) { + setApprover(true) + setApproverConfig({ + value: { + ...JSON.parse(JSON.stringify(props.nodeConfig)), + ...{ settype: props.nodeConfig.settype ? props.nodeConfig.settype : 1 } + }, + flag: false, + id: _uid + }) + } else if (type == 2) { + setCopyer(true) + setCopyerConfig({ + value: JSON.parse(JSON.stringify(props.nodeConfig)), + flag: false, + id: _uid + }) + } else { + setCondition(true) + setConditionsConfig({ + value: JSON.parse(JSON.stringify(props.nodeConfig)), + priorityLevel, + flag: false, + id: _uid + }) + } +} +const arrTransfer = (index, type = 1) => { + //向左-1,向右1 + // eslint-disable-next-line vue/no-mutating-props + props.nodeConfig.conditionNodes[index] = props.nodeConfig.conditionNodes.splice( + index + type, + 1, + props.nodeConfig.conditionNodes[index] + )[0] + props.nodeConfig.conditionNodes.map((item, index) => { + item.priorityLevel = index + 1 + }) + resetConditionNodesErr() + emits('update:nodeConfig', props.nodeConfig) +} +</script> diff --git a/src/components/SimpleProcessDesigner/src/util.ts b/src/components/SimpleProcessDesigner/src/util.ts new file mode 100644 index 0000000..f4acd76 --- /dev/null +++ b/src/components/SimpleProcessDesigner/src/util.ts @@ -0,0 +1,165 @@ +/** + * todo + */ +export const arrToStr = (arr?: [{ name: string }]) => { + if (arr) { + return arr + .map((item) => { + return item.name + }) + .toString() + } +} + +export const setApproverStr = (nodeConfig: any) => { + if (nodeConfig.settype == 1) { + if (nodeConfig.nodeUserList.length == 1) { + return nodeConfig.nodeUserList[0].name + } else if (nodeConfig.nodeUserList.length > 1) { + if (nodeConfig.examineMode == 1) { + return arrToStr(nodeConfig.nodeUserList) + } else if (nodeConfig.examineMode == 2) { + return nodeConfig.nodeUserList.length + '人会签' + } + } + } else if (nodeConfig.settype == 2) { + const level = + nodeConfig.directorLevel == 1 ? '直接主管' : '第' + nodeConfig.directorLevel + '级主管' + if (nodeConfig.examineMode == 1) { + return level + } else if (nodeConfig.examineMode == 2) { + return level + '会签' + } + } else if (nodeConfig.settype == 4) { + if (nodeConfig.selectRange == 1) { + return '发起人自选' + } else { + if (nodeConfig.nodeUserList.length > 0) { + if (nodeConfig.selectRange == 2) { + return '发起人自选' + } else { + return '发起人从' + nodeConfig.nodeUserList[0].name + '中自选' + } + } else { + return '' + } + } + } else if (nodeConfig.settype == 5) { + return '发起人自己' + } else if (nodeConfig.settype == 7) { + return '从直接主管到通讯录中级别最高的第' + nodeConfig.examineEndDirectorLevel + '个层级主管' + } +} + +export const copyerStr = (nodeConfig: any) => { + if (nodeConfig.nodeUserList.length != 0) { + return arrToStr(nodeConfig.nodeUserList) + } else { + if (nodeConfig.ccSelfSelectFlag == 1) { + return '发起人自选' + } + } +} +export const conditionStr = (nodeConfig, index) => { + const { conditionList, nodeUserList } = nodeConfig.conditionNodes[index] + if (conditionList.length == 0) { + return index == nodeConfig.conditionNodes.length - 1 && + nodeConfig.conditionNodes[0].conditionList.length != 0 + ? '其他条件进入此流程' + : '请设置条件' + } else { + let str = '' + for (let i = 0; i < conditionList.length; i++) { + const { + columnId, + columnType, + showType, + showName, + optType, + zdy1, + opt1, + zdy2, + opt2, + fixedDownBoxValue + } = conditionList[i] + if (columnId == 0) { + if (nodeUserList.length != 0) { + str += '发起人属于:' + str += + nodeUserList + .map((item) => { + return item.name + }) + .join('或') + ' 并且 ' + } + } + if (columnType == 'String' && showType == '3') { + if (zdy1) { + str += showName + '属于:' + dealStr(zdy1, JSON.parse(fixedDownBoxValue)) + ' 并且 ' + } + } + if (columnType == 'Double') { + if (optType != 6 && zdy1) { + const optTypeStr = ['', '<', '>', '≤', '=', '≥'][optType] + str += `${showName} ${optTypeStr} ${zdy1} 并且 ` + } else if (optType == 6 && zdy1 && zdy2) { + str += `${zdy1} ${opt1} ${showName} ${opt2} ${zdy2} 并且 ` + } + } + } + return str ? str.substring(0, str.length - 4) : '请设置条件' + } +} + +export const dealStr = (str: string, obj) => { + const arr = [] + const list = str.split(',') + for (const elem in obj) { + list.map((item) => { + if (item == elem) { + arr.push(obj[elem].value) + } + }) + } + return arr.join('或') +} + +export const removeEle = (arr, elem, key = 'id') => { + let includesIndex + arr.map((item, index) => { + if (item[key] == elem[key]) { + includesIndex = index + } + }) + arr.splice(includesIndex, 1) +} + +export const bgColors = ['87, 106, 149', '255, 148, 62', '50, 150, 250'] +export const placeholderList = ['发起人', '审核人', '抄送人'] +export const setTypes = [ + { value: 1, label: '指定成员' }, + { value: 2, label: '主管' }, + { value: 4, label: '发起人自选' }, + { value: 5, label: '发起人自己' }, + { value: 7, label: '连续多级主管' } +] + +export const selectModes = [ + { value: 1, label: '选一个人' }, + { value: 2, label: '选多个人' } +] + +export const selectRanges = [ + { value: 1, label: '全公司' }, + { value: 2, label: '指定成员' }, + { value: 3, label: '指定角色' } +] + +export const optTypes = [ + { value: '1', label: '小于' }, + { value: '2', label: '大于' }, + { value: '3', label: '小于等于' }, + { value: '4', label: '等于' }, + { value: '5', label: '大于等于' }, + { value: '6', label: '介于两个数之间' } +] diff --git a/src/components/SimpleProcessDesigner/theme/workflow.css b/src/components/SimpleProcessDesigner/theme/workflow.css new file mode 100644 index 0000000..888b1a8 --- /dev/null +++ b/src/components/SimpleProcessDesigner/theme/workflow.css @@ -0,0 +1,1292 @@ + +.clearfix { + zoom: 1 +} + +.clearfix:after, +.clearfix:before { + content: ""; + display: table +} + +.clearfix:after { + clear: both +} + +@font-face { + font-family: anticon; + font-display: fallback; + src: url("https://at.alicdn.com/t/font_148784_v4ggb6wrjmkotj4i.eot"); + src: url("https://at.alicdn.com/t/font_148784_v4ggb6wrjmkotj4i.woff") format("woff"), url("https://at.alicdn.com/t/font_148784_v4ggb6wrjmkotj4i.ttf") format("truetype"), url("https://at.alicdn.com/t/font_148784_v4ggb6wrjmkotj4i.svg#iconfont") format("svg") +} + +.anticon { + display: inline-block; + font-style: normal; + vertical-align: baseline; + text-align: center; + text-transform: none; + line-height: 1; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +.anticon:before { + display: block; + font-family: anticon!important +} +.anticon-close:before { + content: "\E633" +} +.anticon-right:before { + content: "\E61F" +} +.anticon-exclamation-circle{ + color: rgb(242, 86, 67) +} +.anticon-exclamation-circle:before { + content: "\E62C" +} + +.anticon-left:before { + content: "\E620" +} + +.anticon-close-circle:before { + content: "\E62E" +} + +.ant-btn { + line-height: 1.5; + display: inline-block; + font-weight: 400; + text-align: center; + touch-action: manipulation; + cursor: pointer; + background-image: none; + border: 1px solid transparent; + white-space: nowrap; + padding: 0 15px; + font-size: 14px; + border-radius: 4px; + height: 32px; + user-select: none; + transition: all .3s cubic-bezier(.645, .045, .355, 1); + position: relative; + color: rgba(0, 0, 0, .65); + background-color: #fff; + border-color: #d9d9d9 +} + +.ant-btn>.anticon { + line-height: 1 +} + +.ant-btn, +.ant-btn:active, +.ant-btn:focus { + outline: 0 +} + +.ant-btn>a:only-child { + color: currentColor +} + +.ant-btn>a:only-child:after { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: transparent +} + +.ant-btn:focus, +.ant-btn:hover { + color: #40a9ff; + background-color: #fff; + border-color: #40a9ff +} + +.ant-btn:focus>a:only-child, +.ant-btn:hover>a:only-child { + color: currentColor +} + +.ant-btn:focus>a:only-child:after, +.ant-btn:hover>a:only-child:after { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: transparent +} + +.ant-btn.active, +.ant-btn:active { + color: #096dd9; + background-color: #fff; + border-color: #096dd9 +} + +.ant-btn.active>a:only-child, +.ant-btn:active>a:only-child { + color: currentColor +} + +.ant-btn.active>a:only-child:after, +.ant-btn:active>a:only-child:after { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: transparent +} + +.ant-btn.active, +.ant-btn:active, +.ant-btn:focus, +.ant-btn:hover { + background: #fff; + text-decoration: none +} + +.ant-btn>i, +.ant-btn>span { + pointer-events: none +} + +.ant-btn:before { + position: absolute; + top: -1px; + left: -1px; + bottom: -1px; + right: -1px; + background: #fff; + opacity: .35; + content: ""; + border-radius: inherit; + z-index: 1; + transition: opacity .2s; + pointer-events: none; + display: none +} + +.ant-btn .anticon { + transition: margin-left .3s cubic-bezier(.645, .045, .355, 1) +} + +.ant-btn:active>span, +.ant-btn:focus>span { + position: relative +} + +.ant-btn>.anticon+span, +.ant-btn>span+.anticon { + margin-left: 8px +} + +.ant-input { + font-family: Chinese Quote, -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Helvetica Neue, Helvetica, Arial, sans-serif; + font-variant: tabular-nums; + box-sizing: border-box; + margin: 0; + padding: 0; + list-style: none; + position: relative; + display: inline-block; + padding: 4px 11px; + width: 100%; + height: 32px; + font-size: 14px; + line-height: 1.5; + color: rgba(0, 0, 0, .65); + background-color: #fff; + background-image: none; + border: 1px solid #d9d9d9; + border-radius: 4px; + transition: all .3s +} + +.ant-input::-moz-placeholder { + color: #bfbfbf; + opacity: 1 +} + +.ant-input:-ms-input-placeholder { + color: #bfbfbf +} + +.ant-input::-webkit-input-placeholder { + color: #bfbfbf +} + +.ant-input:focus, +.ant-input:hover { + border-color: #40a9ff; + border-right-width: 1px!important +} + +.ant-input:focus { + outline: 0; + box-shadow: 0 0 0 2px rgba(24, 144, 255, .2) +} + +textarea.ant-input { + max-width: 100%; + height: auto; + vertical-align: bottom; + transition: all .3s, height 0s; + min-height: 32px +} + +a, +abbr, +acronym, +address, +applet, +article, +aside, +audio, +b, +big, +blockquote, +body, +canvas, +caption, +center, +cite, +code, +dd, +del, +details, +dfn, +div, +dl, +dt, +em, +fieldset, +figcaption, +figure, +footer, +form, +h1, +h2, +h3, +h4, +h5, +h6, +header, +hgroup, +html, +i, +iframe, +img, +ins, +kbd, +label, +legend, +li, +mark, +menu, +nav, +object, +ol, +p, +pre, +q, +s, +samp, +section, +small, +span, +strike, +strong, +sub, +summary, +sup, +table, +tbody, +td, +tfoot, +th, +thead, +time, +tr, +tt, +u, +ul, +var, +video { + margin: 0; + padding: 0; + border: 0; + outline: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline +} + +*, +:after, +:before { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box +} + +html { + font-family: sans-serif; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100% +} + +body, +html { + font-size: 14px +} + +body { + font-family: Microsoft Yahei, Lucida Grande, Lucida Sans Unicode, Helvetica, Arial, Verdana, sans-serif; + line-height: 1.6; + background-color: #fff; + position: static!important; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0) +} + +ol, +ul { + list-style-type: none +} + +b, +strong { + font-weight: 700 +} + +img { + border: 0 +} + +button, +input, +select, +textarea { + font-family: inherit; + font-size: 100%; + margin: 0 +} + +textarea { + overflow: auto; + vertical-align: top; + -webkit-appearance: none +} + +button, +input { + line-height: normal +} + +button, +select { + text-transform: none +} + +button, +html input[type=button], +input[type=reset], +input[type=submit] { + -webkit-appearance: button; + cursor: pointer +} + +input[type=search] { + -webkit-appearance: textfield; + -moz-box-sizing: content-box; + -webkit-box-sizing: content-box; + box-sizing: content-box +} + +input[type=search]::-webkit-search-cancel-button, +input[type=search]::-webkit-search-decoration { + -webkit-appearance: none +} + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0 +} + +table { + width: 100%; + border-spacing: 0; + border-collapse: collapse +} + +table, +td, +th { + border: 0 +} + +td, +th { + padding: 0; + vertical-align: top +} + +th { + font-weight: 700; + text-align: left +} + +thead th { + white-space: nowrap +} + +a { + text-decoration: none; + cursor: pointer; + color: #3296fa +} + +a:active, +a:hover { + outline: 0; + color: #3296fa +} + +small { + font-size: 80% +} + +body, +html { + font-size: 12px!important; + color: #191f25!important; + background: #f6f6f6!important +} + +.wrap { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + height: 100% +} + +@font-face { + font-family: IconFont; + src: url("//at.alicdn.com/t/font_135284_ph2thxxbzgf.eot"); + src: url("//at.alicdn.com/t/font_135284_ph2thxxbzgf.eot?#iefix") format("embedded-opentype"), url("//at.alicdn.com/t/font_135284_ph2thxxbzgf.woff") format("woff"), url("//at.alicdn.com/t/font_135284_ph2thxxbzgf.ttf") format("truetype"), url("//at.alicdn.com/t/font_135284_ph2thxxbzgf.svg#IconFont") format("svg") +} + +.iconfont { + font-family: IconFont!important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + -webkit-text-stroke-width: .2px; + -moz-osx-font-smoothing: grayscale +} + +.fd-nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 997; + width: 100%; + height: 60px; + font-size: 14px; + color: #fff; + background: #3296fa; + display: flex; + align-items: center +} + +.fd-nav>* { + flex: 1; + width: 100% +} + +.fd-nav .fd-nav-left { + display: -webkit-box; + display: flex; + align-items: center +} + +.fd-nav .fd-nav-center { + flex: none; + width: 600px; + text-align: center +} + +.fd-nav .fd-nav-right { + display: flex; + align-items: center; + justify-content: flex-end; + text-align: right +} + +.fd-nav .fd-nav-back { + display: inline-block; + width: 60px; + height: 60px; + font-size: 22px; + border-right: 1px solid #1583f2; + text-align: center; + cursor: pointer +} + +.fd-nav .fd-nav-back:hover { + background: #5af +} + +.fd-nav .fd-nav-back:active { + background: #1583f2 +} + +.fd-nav .fd-nav-back .anticon { + line-height: 60px +} + +.fd-nav .fd-nav-title { + width: 0; + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding: 0 15px +} + +.fd-nav a { + color: #fff; + margin-left: 12px +} + +.fd-nav .button-publish { + min-width: 80px; + margin-left: 4px; + margin-right: 15px; + color: #3296fa; + border-color: #fff +} + +.fd-nav .button-publish.ant-btn:focus, +.fd-nav .button-publish.ant-btn:hover { + color: #3296fa; + border-color: #fff; + box-shadow: 0 10px 20px 0 rgba(0, 0, 0, .3) +} + +.fd-nav .button-publish.ant-btn:active { + color: #3296fa; + background: #d6eaff; + box-shadow: none +} + +.fd-nav .button-preview { + min-width: 80px; + margin-left: 16px; + margin-right: 4px; + color: #fff; + border-color: #fff; + background: transparent +} + +.fd-nav .button-preview.ant-btn:focus, +.fd-nav .button-preview.ant-btn:hover { + color: #fff; + border-color: #fff; + background: #59acfc +} + +.fd-nav .button-preview.ant-btn:active { + color: #fff; + border-color: #fff; + background: #2186ef +} + +.fd-nav-content { + position: fixed; + top: 60px; + left: 0; + right: 0; + bottom: 0; + z-index: 1; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: 30px +} + +.error-modal-desc { + font-size: 13px; + color: rgba(25, 31, 37, .56); + line-height: 22px; + margin-bottom: 14px +} + +.error-modal-list { + height: 200px; + overflow-y: auto; + margin-right: -25px; + padding-right: 25px +} + +.error-modal-item { + padding: 10px 20px; + line-height: 21px; + background: #f6f6f6; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + border-radius: 4px +} + +.error-modal-item-label { + flex: none; + font-size: 15px; + color: rgba(25, 31, 37, .56); + padding-right: 10px +} + +.error-modal-item-content { + text-align: right; + flex: 1; + font-size: 13px; + color: #191f25 +} + +#body.blur { + -webkit-filter: blur(3px); + filter: blur(3px) +} + +.zoom { + display: flex; + position: fixed; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: justify; + -ms-flex-pack: justify; + justify-content: space-between; + height: 40px; + width: 125px; + right: 40px; + margin-top: 30px; + z-index: 10 +} + +.zoom .zoom-in, +.zoom .zoom-out { + width: 30px; + height: 30px; + background: #fff; + color: #c1c1cd; + cursor: pointer; + background-size: 100%; + background-repeat: no-repeat +} + +.zoom .zoom-out { + background-image: url(https://gw.alicdn.com/tfs/TB1s0qhBHGYBuNjy0FoXXciBFXa-90-90.png) +} + +.zoom .zoom-out.disabled { + opacity: .5 +} + +.zoom .zoom-in { + background-image: url(https://gw.alicdn.com/tfs/TB1UIgJBTtYBeNjy1XdXXXXyVXa-90-90.png) +} + +.zoom .zoom-in.disabled { + opacity: .5 +} + +.auto-judge:hover .editable-title, +.node-wrap-box:hover .editable-title { + border-bottom: 1px dashed #fff +} + +.auto-judge:hover .editable-title.editing, +.node-wrap-box:hover .editable-title.editing { + text-decoration: none; + border: 1px solid #d9d9d9 +} + +.auto-judge:hover .editable-title { + border-color: #15bc83 +} + +.editable-title { + line-height: 15px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + border-bottom: 1px dashed transparent +} + +.editable-title:before { + content: ""; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 40px +} + +.editable-title:hover { + border-bottom: 1px dashed #fff +} + +.editable-title-input { + flex: none; + height: 18px; + padding-left: 4px; + text-indent: 0; + font-size: 12px; + line-height: 18px; + z-index: 1 +} + +.editable-title-input:hover { + text-decoration: none +} + +.ant-btn { + position: relative +} + +.node-wrap-box { + display: -webkit-inline-box; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + position: relative; + width: 220px; + min-height: 72px; + -ms-flex-negative: 0; + flex-shrink: 0; + background: #fff; + border-radius: 4px; + cursor: pointer +} + +.node-wrap-box:after { + pointer-events: none; + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 2; + border-radius: 4px; + border: 1px solid transparent; + transition: all .1s cubic-bezier(.645, .045, .355, 1); + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1) +} + +.node-wrap-box.active:after, +.node-wrap-box:active:after, +.node-wrap-box:hover:after { + border: 1px solid #3296fa; + box-shadow: 0 0 6px 0 rgba(50, 150, 250, .3) +} + +.node-wrap-box.active .close, +.node-wrap-box:active .close, +.node-wrap-box:hover .close { + display: block +} + +.node-wrap-box.error:after { + border: 1px solid #f25643; + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1) +} + +.node-wrap-box .title { + position: relative; + display: flex; + align-items: center; + padding-left: 16px; + padding-right: 30px; + width: 100%; + height: 24px; + line-height: 24px; + font-size: 12px; + color: #fff; + text-align: left; + background: #576a95; + border-radius: 4px 4px 0 0 +} + +.node-wrap-box .title .iconfont { + font-size: 12px; + margin-right: 5px +} + +.node-wrap-box .placeholder { + color: #bfbfbf +} + +.node-wrap-box .close { + display: none; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + font-size: 14px; + color: #fff; + border-radius: 50%; + text-align: center; + line-height: 20px +} + +.node-wrap-box .content { + position: relative; + font-size: 14px; + padding: 16px; + padding-right: 30px +} + +.node-wrap-box .content .text { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical +} + +.node-wrap-box .content .arrow { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 14px; + font-size: 14px; + color: #979797 +} + +.start-node.node-wrap-box .content .text { + display: block; + white-space: nowrap +} + +.node-wrap-box:before { + content: ""; + position: absolute; + top: -12px; + left: 50%; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); + width: 0; + height: 4px; + border-style: solid; + border-width: 8px 6px 4px; + border-color: #cacaca transparent transparent; + background: #f5f5f7 +} + +.node-wrap-box.start-node:before { + content: none +} + +.top-left-cover-line { + left: -1px +} + +.top-left-cover-line, +.top-right-cover-line { + position: absolute; + height: 8px; + width: 50%; + background-color: #f5f5f7; + top: -4px +} + +.top-right-cover-line { + right: -1px +} + +.bottom-left-cover-line { + left: -1px +} + +.bottom-left-cover-line, +.bottom-right-cover-line { + position: absolute; + height: 8px; + width: 50%; + background-color: #f5f5f7; + bottom: -4px +} + +.bottom-right-cover-line { + right: -1px +} + +.dingflow-design { + width: 100%; + background-color: #f5f5f7; + overflow: auto; + position: absolute; + bottom: 0; + left: 0; + right: 0; + top: 0 +} + +.dingflow-design .box-scale { + transform: scale(1); + display: inline-block; + position: relative; + width: 100%; + padding: 54.5px 0; + -webkit-box-align: start; + -ms-flex-align: start; + align-items: flex-start; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + min-width: -webkit-min-content; + min-width: -moz-min-content; + min-width: min-content; + background-color: #f5f5f7; + transform-origin: 50% 0px 0px; +} + +.dingflow-design .node-wrap { + flex-direction: column; + -webkit-box-pack: start; + -ms-flex-pack: start; + justify-content: flex-start; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-box-flex: 1; + -ms-flex-positive: 1; + padding: 0 50px; + position: relative +} + +.dingflow-design .branch-wrap, +.dingflow-design .node-wrap { + display: inline-flex; + width: 100% +} + +.dingflow-design .branch-box-wrap { + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + min-height: 270px; + width: 100%; + -ms-flex-negative: 0; + flex-shrink: 0 +} + +.dingflow-design .branch-box { + display: flex; + overflow: visible; + min-height: 180px; + height: auto; + border-bottom: 2px solid #ccc; + border-top: 2px solid #ccc; + position: relative; + margin-top: 15px +} + +.dingflow-design .branch-box .col-box { + background: #f5f5f7 +} + +.dingflow-design .branch-box .col-box:before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 0; + margin: auto; + width: 2px; + height: 100%; + background-color: #cacaca +} + +.dingflow-design .add-branch { + border: none; + outline: none; + user-select: none; + justify-content: center; + font-size: 12px; + padding: 0 10px; + height: 30px; + line-height: 30px; + border-radius: 15px; + color: #3296fa; + background: #fff; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .1); + position: absolute; + top: -16px; + left: 50%; + transform: translateX(-50%); + transform-origin: center center; + cursor: pointer; + z-index: 1; + display: inline-flex; + align-items: center; + -webkit-transition: all .3s cubic-bezier(.645, .045, .355, 1); + transition: all .3s cubic-bezier(.645, .045, .355, 1) +} + +.dingflow-design .add-branch:hover { + transform: translateX(-50%) scale(1.1); + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, .1) +} + +.dingflow-design .add-branch:active { + transform: translateX(-50%); + box-shadow: none +} + +.dingflow-design .col-box { + display: inline-flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + flex-direction: column; + -webkit-box-align: center; + align-items: center; + position: relative +} + +.dingflow-design .condition-node { + min-height: 220px +} + +.dingflow-design .condition-node, +.dingflow-design .condition-node-box { + display: inline-flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + flex-direction: column; + -webkit-box-flex: 1 +} + +.dingflow-design .condition-node-box { + padding-top: 30px; + padding-right: 50px; + padding-left: 50px; + -webkit-box-pack: center; + justify-content: center; + -webkit-box-align: center; + align-items: center; + flex-grow: 1; + position: relative +} + +.dingflow-design .condition-node-box:before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 2px; + height: 100%; + background-color: #cacaca +} + +.dingflow-design .auto-judge { + position: relative; + width: 220px; + min-height: 72px; + background: #fff; + border-radius: 4px; + padding: 14px 19px; + cursor: pointer +} + +.dingflow-design .auto-judge:after { + pointer-events: none; + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 2; + border-radius: 4px; + border: 1px solid transparent; + transition: all .1s cubic-bezier(.645, .045, .355, 1); + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1) +} + +.dingflow-design .auto-judge.active:after, +.dingflow-design .auto-judge:active:after, +.dingflow-design .auto-judge:hover:after { + border: 1px solid #3296fa; + box-shadow: 0 0 6px 0 rgba(50, 150, 250, .3) +} + +.dingflow-design .auto-judge.active .close, +.dingflow-design .auto-judge:active .close, +.dingflow-design .auto-judge:hover .close { + display: block +} + +.dingflow-design .auto-judge.error:after { + border: 1px solid #f25643; + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, .1) +} + +.dingflow-design .auto-judge .title-wrapper { + position: relative; + font-size: 12px; + color: #15bc83; + text-align: left; + line-height: 16px +} + +.dingflow-design .auto-judge .title-wrapper .editable-title { + display: inline-block; + max-width: 120px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis +} + +.dingflow-design .auto-judge .title-wrapper .priority-title { + display: inline-block; + float: right; + margin-right: 10px; + color: rgba(25, 31, 37, .56) +} + +.dingflow-design .auto-judge .placeholder { + color: #bfbfbf +} + +.dingflow-design .auto-judge .close { + display: none; + position: absolute; + right: -10px; + top: -10px; + width: 20px; + height: 20px; + font-size: 14px; + color: rgba(0, 0, 0, .25); + border-radius: 50%; + text-align: center; + line-height: 20px; + z-index: 2 +} + +.dingflow-design .auto-judge .content { + font-size: 14px; + color: #191f25; + text-align: left; + margin-top: 6px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical +} + +.dingflow-design .auto-judge .sort-left, +.dingflow-design .auto-judge .sort-right { + position: absolute; + top: 0; + bottom: 0; + display: none; + z-index: 1 +} + +.dingflow-design .auto-judge .sort-left { + left: 0; + border-right: 1px solid #f6f6f6 +} + +.dingflow-design .auto-judge .sort-right { + right: 0; + border-left: 1px solid #f6f6f6 +} + +.dingflow-design .auto-judge:hover .sort-left, +.dingflow-design .auto-judge:hover .sort-right { + display: flex; + align-items: center +} + +.dingflow-design .auto-judge .sort-left:hover, +.dingflow-design .auto-judge .sort-right:hover { + background: #efefef +} + +.dingflow-design .end-node { + border-radius: 50%; + font-size: 14px; + color: rgba(25, 31, 37, .4); + text-align: left +} + +.dingflow-design .end-node .end-node-circle { + width: 10px; + height: 10px; + margin: auto; + border-radius: 50%; + background: #dbdcdc +} + +.dingflow-design .end-node .end-node-text { + margin-top: 5px; + text-align: center +} + +.approval-setting { + border-radius: 2px; + margin: 20px 0; + position: relative; + background: #fff +} + +.ant-btn { + position: relative +} + + diff --git a/src/components/Sticky/index.ts b/src/components/Sticky/index.ts new file mode 100644 index 0000000..5e1de45 --- /dev/null +++ b/src/components/Sticky/index.ts @@ -0,0 +1,3 @@ +import Sticky from './src/Sticky.vue' + +export { Sticky } diff --git a/src/components/Sticky/src/Sticky.vue b/src/components/Sticky/src/Sticky.vue new file mode 100644 index 0000000..28ecbcb --- /dev/null +++ b/src/components/Sticky/src/Sticky.vue @@ -0,0 +1,143 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { isClient, useEventListener, useWindowSize } from '@vueuse/core' +import type { CSSProperties } from 'vue' + +defineOptions({ name: 'Sticky' }) + +const props = defineProps({ + // 距离顶部或者底部的距离(单位px) + offset: propTypes.number.def(0), + // 设置元素的堆叠顺序 + zIndex: propTypes.number.def(999), + // 设置指定的class + className: propTypes.string.def(''), + // 定位方式,默认为(top),表示距离顶部位置,可以设置为top或者bottom + position: { + type: String, + validator: function (value: string) { + return ['top', 'bottom'].indexOf(value) !== -1 + }, + default: 'top' + } +}) +const width = ref('auto' as string) +const height = ref('auto' as string) +const isSticky = ref(false) +const refSticky = shallowRef<HTMLElement>() +const scrollContainer = shallowRef<HTMLElement | Window>() +const { height: windowHeight } = useWindowSize() +onMounted(() => { + height.value = refSticky.value?.getBoundingClientRect().height + 'px' + + scrollContainer.value = getScrollContainer(refSticky.value!, true) + useEventListener(scrollContainer, 'scroll', handleScroll) + useEventListener('resize', handleResize) + handleScroll() +}) +onActivated(() => { + handleScroll() +}) + +const camelize = (str: string): string => { + return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : '')) +} + +const getStyle = (element: HTMLElement, styleName: keyof CSSProperties): string => { + if (!isClient || !element || !styleName) return '' + + let key = camelize(styleName) + if (key === 'float') key = 'cssFloat' + try { + const style = element.style[styleName] + if (style) return style + const computed = document.defaultView?.getComputedStyle(element, '') + return computed ? computed[styleName] : '' + } catch { + return element.style[styleName] + } +} +const isScroll = (el: HTMLElement, isVertical?: boolean): boolean => { + if (!isClient) return false + const key = ( + { + undefined: 'overflow', + true: 'overflow-y', + false: 'overflow-x' + } as const + )[String(isVertical)]! + const overflow = getStyle(el, key) + return ['scroll', 'auto', 'overlay'].some((s) => overflow.includes(s)) +} + +const getScrollContainer = ( + el: HTMLElement, + isVertical: boolean +): Window | HTMLElement | undefined => { + if (!isClient) return + let parent = el + while (parent) { + if ([window, document, document.documentElement].includes(parent)) return window + if (isScroll(parent, isVertical)) return parent + parent = parent.parentNode as HTMLElement + } + return parent +} + +const handleScroll = () => { + width.value = refSticky.value!.getBoundingClientRect().width! + 'px' + if (props.position === 'top') { + const offsetTop = refSticky.value?.getBoundingClientRect().top + if (offsetTop !== undefined && offsetTop < props.offset) { + sticky() + return + } + reset() + } else { + const offsetBottom = refSticky.value?.getBoundingClientRect().bottom + + if (offsetBottom !== undefined && offsetBottom > windowHeight.value - props.offset) { + sticky() + return + } + reset() + } +} +const handleResize = () => { + if (isSticky.value && refSticky.value) { + width.value = refSticky.value.getBoundingClientRect().width + 'px' + } +} +const sticky = () => { + if (isSticky.value) { + return + } + isSticky.value = true +} +const reset = () => { + if (!isSticky.value) { + return + } + width.value = 'auto' + isSticky.value = false +} +</script> +<template> + <div ref="refSticky" :style="{ height: height, zIndex: zIndex }"> + <div + :class="className" + :style="{ + top: position === 'top' ? offset + 'px' : '', + bottom: position !== 'top' ? offset + 'px' : '', + zIndex: zIndex, + position: isSticky ? 'fixed' : 'static', + width: width, + height: height + }" + > + <slot> + <div>sticky</div> + </slot> + </div> + </div> +</template> diff --git a/src/components/SummaryCard/index.vue b/src/components/SummaryCard/index.vue new file mode 100644 index 0000000..52da6da --- /dev/null +++ b/src/components/SummaryCard/index.vue @@ -0,0 +1,52 @@ +<template> + <div class="flex flex-row items-center gap-3 rounded bg-[var(--el-bg-color-overlay)] p-4"> + <div + class="h-12 w-12 flex flex-shrink-0 items-center justify-center rounded-1" + :class="`${iconColor} ${iconBgColor}`" + > + <Icon :icon="icon" class="!text-6" /> + </div> + <div class="flex flex-col gap-1"> + <div class="flex items-center gap-1 text-gray-500"> + <span class="text-3.5">{{ title }}</span> + <el-tooltip :content="tooltip" placement="top-start" v-if="tooltip"> + <Icon icon="ep:warning" class="item-center flex !text-3" /> + </el-tooltip> + </div> + <div class="flex flex-row items-baseline gap-2"> + <div class="text-7"> + <CountTo :prefix="prefix" :end-val="value" :decimals="decimals" /> + </div> + <span + v-if="percent != undefined" + :class="toNumber(percent) > 0 ? 'text-red-500' : 'text-green-500'" + > + <span class="text-sm">{{ Math.abs(toNumber(percent)) }}%</span> + <Icon + :icon="toNumber(percent) > 0 ? 'ep:caret-top' : 'ep:caret-bottom'" + class="ml-0.5 !text-3" + /> + </span> + </div> + </div> + </div> +</template> +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { toNumber } from 'lodash-es' + +/** 统计卡片 */ +defineOptions({ name: 'SummaryCard' }) + +defineProps({ + title: propTypes.string.def(''), + tooltip: propTypes.string.def(''), + icon: propTypes.string.def(''), + iconColor: propTypes.string.def(''), + iconBgColor: propTypes.string.def(''), + prefix: propTypes.string.def(''), + value: propTypes.number.def(0), + decimals: propTypes.number.def(0), + percent: propTypes.oneOfType([Number, String]).def(undefined) +}) +</script> diff --git a/src/components/Table/index.ts b/src/components/Table/index.ts new file mode 100644 index 0000000..9f89317 --- /dev/null +++ b/src/components/Table/index.ts @@ -0,0 +1,13 @@ +import Table from './src/Table.vue' +import { ElTable } from 'element-plus' +import { TableSetPropsType } from '@/types/table' +import TableSelectForm from './src/TableSelectForm.vue' + +export interface TableExpose { + setProps: (props: Recordable) => void + setColumn: (columnProps: TableSetPropsType[]) => void + selections: Recordable[] + elTableRef: ComponentRef<typeof ElTable> +} + +export { Table, TableSelectForm } diff --git a/src/components/Table/src/Table.vue b/src/components/Table/src/Table.vue new file mode 100644 index 0000000..279a9fa --- /dev/null +++ b/src/components/Table/src/Table.vue @@ -0,0 +1,311 @@ +<script lang="tsx"> +import { ElTable, ElTableColumn, ElPagination } from 'element-plus' +import { defineComponent, PropType, ref, computed, unref, watch, onMounted } from 'vue' +import { propTypes } from '@/utils/propTypes' +import { setIndex } from './helper' +import { getSlot } from '@/utils/tsxHelper' +import type { TableProps } from './types' +import { set } from 'lodash-es' +import { Pagination, TableColumn, TableSetPropsType, TableSlotDefault } from '@/types/table' + +export default defineComponent({ + // eslint-disable-next-line vue/no-reserved-component-names + name: 'Table', + props: { + pageSize: propTypes.number.def(10), + currentPage: propTypes.number.def(1), + // 是否多选 + selection: propTypes.bool.def(false), + // 是否所有的超出隐藏,优先级低于schema中的showOverflowTooltip, + showOverflowTooltip: propTypes.bool.def(true), + // 表头 + columns: { + type: Array as PropType<TableColumn[]>, + default: () => [] + }, + // 展开行 + expand: propTypes.bool.def(false), + // 是否展示分页 + pagination: { + type: Object as PropType<Pagination>, + default: (): Pagination | undefined => undefined + }, + // 仅对 type=selection 的列有效,类型为 Boolean,为 true 则会在数据更新之后保留之前选中的数据(需指定 row-key) + reserveSelection: propTypes.bool.def(false), + // 加载状态 + loading: propTypes.bool.def(false), + // 是否叠加索引 + reserveIndex: propTypes.bool.def(false), + // 对齐方式 + align: propTypes.string + .validate((v: string) => ['left', 'center', 'right'].includes(v)) + .def('center'), + // 表头对齐方式 + headerAlign: propTypes.string + .validate((v: string) => ['left', 'center', 'right'].includes(v)) + .def('center'), + data: { + type: Array as PropType<Recordable[]>, + default: () => [] + } + }, + emits: ['update:pageSize', 'update:currentPage', 'register'], + setup(props, { attrs, slots, emit, expose }) { + const elTableRef = ref<ComponentRef<typeof ElTable>>() + + // 注册 + onMounted(() => { + const tableRef = unref(elTableRef) + emit('register', tableRef?.$parent, elTableRef) + }) + + const pageSizeRef = ref(props.pageSize) + + const currentPageRef = ref(props.currentPage) + + // useTable传入的props + const outsideProps = ref<TableProps>({}) + + const mergeProps = ref<TableProps>({}) + + const getProps = computed(() => { + const propsObj = { ...props } + Object.assign(propsObj, unref(mergeProps)) + return propsObj + }) + + const setProps = (props: TableProps = {}) => { + mergeProps.value = Object.assign(unref(mergeProps), props) + outsideProps.value = props + } + + const setColumn = (columnProps: TableSetPropsType[], columnsChildren?: TableColumn[]) => { + const { columns } = unref(getProps) + for (const v of columnsChildren || columns) { + for (const item of columnProps) { + if (v.field === item.field) { + set(v, item.path, item.value) + } else if (v.children?.length) { + setColumn(columnProps, v.children) + } + } + } + } + + const selections = ref<Recordable[]>([]) + + const selectionChange = (selection: Recordable[]) => { + selections.value = selection + } + + expose({ + setProps, + setColumn, + selections + }) + + const pagination = computed(() => { + // update by 芋艿:保持和 Pagination 组件的逻辑一致 + return Object.assign( + { + small: false, + background: true, + pagerCount: document.body.clientWidth < 992 ? 5 : 7, + layout: 'total, sizes, prev, pager, next, jumper', + pageSizes: [10, 20, 30, 50, 100], + disabled: false, + hideOnSinglePage: false, + total: 10 + }, + unref(getProps).pagination + ) + }) + + watch( + () => unref(getProps).pageSize, + (val: number) => { + pageSizeRef.value = val + } + ) + + watch( + () => unref(getProps).currentPage, + (val: number) => { + currentPageRef.value = val + } + ) + + watch( + () => pageSizeRef.value, + (val: number) => { + emit('update:pageSize', val) + } + ) + + watch( + () => currentPageRef.value, + (val: number) => { + emit('update:currentPage', val) + } + ) + + const getBindValue = computed(() => { + const bindValue: Recordable = { ...attrs, ...props } + delete bindValue.columns + delete bindValue.data + return bindValue + }) + + const renderTableSelection = () => { + const { selection, reserveSelection, align, headerAlign } = unref(getProps) + // 渲染多选 + return selection ? ( + <ElTableColumn + type="selection" + reserveSelection={reserveSelection} + align={align} + headerAlign={headerAlign} + width="50" + ></ElTableColumn> + ) : undefined + } + + const renderTableExpand = () => { + const { align, headerAlign, expand } = unref(getProps) + // 渲染展开行 + return expand ? ( + <ElTableColumn type="expand" align={align} headerAlign={headerAlign}> + {{ + // @ts-ignore + default: (data: TableSlotDefault) => getSlot(slots, 'expand', data) + }} + </ElTableColumn> + ) : undefined + } + + const rnderTreeTableColumn = (columnsChildren: TableColumn[]) => { + const { align, headerAlign, showOverflowTooltip } = unref(getProps) + return columnsChildren.map((v) => { + const props = { ...v } + if (props.children) delete props.children + return ( + <ElTableColumn + showOverflowTooltip={showOverflowTooltip} + align={align} + headerAlign={headerAlign} + {...props} + prop={v.field} + > + {{ + default: (data: TableSlotDefault) => + v.children && v.children.length + ? rnderTableColumn(v.children) + : // @ts-ignore + getSlot(slots, v.field, data) || + v?.formatter?.(data.row, data.column, data.row[v.field], data.$index) || + data.row[v.field], + // @ts-ignore + header: getSlot(slots, `${v.field}-header`) + }} + </ElTableColumn> + ) + }) + } + + const rnderTableColumn = (columnsChildren?: TableColumn[]) => { + const { + columns, + reserveIndex, + pageSize, + currentPage, + align, + headerAlign, + showOverflowTooltip + } = unref(getProps) + return [...[renderTableExpand()], ...[renderTableSelection()]].concat( + (columnsChildren || columns).map((v) => { + // 自定生成序号 + if (v.type === 'index') { + return ( + <ElTableColumn + type="index" + index={ + v.index + ? v.index + : (index) => setIndex(reserveIndex, index, pageSize, currentPage) + } + align={v.align || align} + headerAlign={v.headerAlign || headerAlign} + label={v.label} + width="65px" + ></ElTableColumn> + ) + } else { + const props = { ...v } + if (props.children) delete props.children + return ( + <ElTableColumn + showOverflowTooltip={showOverflowTooltip} + align={align} + headerAlign={headerAlign} + {...props} + prop={v.field} + > + {{ + default: (data: TableSlotDefault) => + v.children && v.children.length + ? rnderTreeTableColumn(v.children) + : // @ts-ignore + getSlot(slots, v.field, data) || + v?.formatter?.(data.row, data.column, data.row[v.field], data.$index) || + data.row[v.field], + // @ts-ignore + header: () => getSlot(slots, `${v.field}-header`) || v.label + }} + </ElTableColumn> + ) + } + }) + ) + } + + return () => ( + <div v-loading={unref(getProps).loading}> + <ElTable + // @ts-ignore + ref={elTableRef} + data={unref(getProps).data} + onSelection-change={selectionChange} + {...unref(getBindValue)} + > + {{ + default: () => rnderTableColumn(), + // @ts-ignore + append: () => getSlot(slots, 'append') + }} + </ElTable> + {unref(getProps).pagination ? ( + // update by 芋艿:保持和 Pagination 组件一致 + <ElPagination + v-model:pageSize={pageSizeRef.value} + v-model:currentPage={currentPageRef.value} + class="float-right mb-15px mt-15px" + {...unref(pagination)} + ></ElPagination> + ) : undefined} + </div> + ) + } +}) +</script> +<style lang="scss" scoped> +:deep(.el-button.is-text) { + padding: 8px 4px; + margin-left: 0; +} + +:deep(.el-button.is-link) { + padding: 8px 4px; + margin-left: 0; +} +</style> diff --git a/src/components/Table/src/TableSelectForm.vue b/src/components/Table/src/TableSelectForm.vue new file mode 100644 index 0000000..7fece2d --- /dev/null +++ b/src/components/Table/src/TableSelectForm.vue @@ -0,0 +1,91 @@ +<!-- 列表选择通用组件,参考 ProductList 组件使用 --> +<template> + <Dialog v-model="dialogVisible" :appendToBody="true" :scroll="true" :title="title" width="60%"> + <el-table + ref="multipleTableRef" + v-loading="loading" + :data="list" + :show-overflow-tooltip="true" + :stripe="true" + @selection-change="handleSelectionChange" + > + <el-table-column type="selection" width="55" /> + <slot></slot> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> + +<script lang="ts" setup> +import { ElTable } from 'element-plus' + +defineOptions({ name: 'TableSelectForm' }) +withDefaults( + defineProps<{ + modelValue: any[] + title: string + }>(), + { modelValue: () => [], title: '选择' } +) +const list = ref([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) +const queryParams = reactive({ + pageNo: 1, + pageSize: 10 +}) +// 确认选择时的触发事件 +const emits = defineEmits<{ + (e: 'update:modelValue', v: number[]): void +}>() +const multipleTableRef = ref<InstanceType<typeof ElTable>>() +const multipleSelection = ref<any[]>([]) +const handleSelectionChange = (val: any[]) => { + multipleSelection.value = val +} +/** 触发 */ +const submitForm = () => { + formLoading.value = true + try { + emits('update:modelValue', multipleSelection.value) // 返回选择的原始数据由使用方处理 + } finally { + formLoading.value = false + // 关闭弹窗 + dialogVisible.value = false + } +} + +const getList = async (getListFunc: Function) => { + loading.value = true + try { + const data = await getListFunc(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 打开弹窗 */ +const open = async (getListFunc: Function) => { + dialogVisible.value = true + await nextTick() + if (multipleSelection.value.length > 0) { + multipleTableRef.value!.clearSelection() + } + await getList(getListFunc) +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/components/Table/src/helper.ts b/src/components/Table/src/helper.ts new file mode 100644 index 0000000..d8b34a8 --- /dev/null +++ b/src/components/Table/src/helper.ts @@ -0,0 +1,8 @@ +export const setIndex = (reserveIndex: boolean, index: number, size: number, current: number) => { + const newIndex = index + 1 + if (reserveIndex) { + return size * (current - 1) + newIndex + } else { + return newIndex + } +} diff --git a/src/components/Table/src/types.ts b/src/components/Table/src/types.ts new file mode 100644 index 0000000..1c7ff76 --- /dev/null +++ b/src/components/Table/src/types.ts @@ -0,0 +1,26 @@ +import { Pagination, TableColumn } from '@/types/table' + +export type TableProps = { + pageSize?: number + currentPage?: number + // 是否多选 + selection?: boolean + // 是否所有的超出隐藏,优先级低于schema中的showOverflowTooltip, + showOverflowTooltip?: boolean + // 表头 + columns?: TableColumn[] + // 是否展示分页 + pagination?: Pagination | undefined + // 仅对 type=selection 的列有效,类型为 Boolean,为 true 则会在数据更新之后保留之前选中的数据(需指定 row-key) + reserveSelection?: boolean + // 加载状态 + loading?: boolean + // 是否叠加索引 + reserveIndex?: boolean + // 对齐方式 + align?: 'left' | 'center' | 'right' + // 表头对齐方式 + headerAlign?: 'left' | 'center' | 'right' + data?: Recordable + expand?: boolean +} & Recordable diff --git a/src/components/Tooltip/index.ts b/src/components/Tooltip/index.ts new file mode 100644 index 0000000..ab66ddf --- /dev/null +++ b/src/components/Tooltip/index.ts @@ -0,0 +1,3 @@ +import Tooltip from './src/Tooltip.vue' + +export { Tooltip } diff --git a/src/components/Tooltip/src/Tooltip.vue b/src/components/Tooltip/src/Tooltip.vue new file mode 100644 index 0000000..1a2e09c --- /dev/null +++ b/src/components/Tooltip/src/Tooltip.vue @@ -0,0 +1,17 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' + +defineOptions({ name: 'Tooltip' }) + +defineProps({ + title: propTypes.string.def(''), + message: propTypes.string.def(''), + icon: propTypes.string.def('ep:question-filled') +}) +</script> +<template> + <span>{{ title }}</span> + <ElTooltip :content="message" placement="top"> + <Icon :icon="icon" class="relative top-1px ml-1px" /> + </ElTooltip> +</template> diff --git a/src/components/UploadFile/index.ts b/src/components/UploadFile/index.ts new file mode 100644 index 0000000..97c1d66 --- /dev/null +++ b/src/components/UploadFile/index.ts @@ -0,0 +1,5 @@ +import UploadImg from './src/UploadImg.vue' +import UploadImgs from './src/UploadImgs.vue' +import UploadFile from './src/UploadFile.vue' + +export { UploadImg, UploadImgs, UploadFile } diff --git a/src/components/UploadFile/src/UploadFile.vue b/src/components/UploadFile/src/UploadFile.vue new file mode 100644 index 0000000..3beb377 --- /dev/null +++ b/src/components/UploadFile/src/UploadFile.vue @@ -0,0 +1,214 @@ +<template> + <div class="upload-file"> + <el-upload + ref="uploadRef" + v-model:file-list="fileList" + :action="uploadUrl" + :auto-upload="autoUpload" + :before-upload="beforeUpload" + :disabled="disabled" + :drag="drag" + :http-request="httpRequest" + :limit="props.limit" + :multiple="props.limit > 1" + :on-error="excelUploadError" + :on-exceed="handleExceed" + :on-preview="handlePreview" + :on-remove="handleRemove" + :on-success="handleFileSuccess" + :show-file-list="true" + class="upload-file-uploader" + name="file" + > + <el-button v-if="!disabled" type="primary"> + <Icon icon="ep:upload-filled" /> + 选取文件 + </el-button> + <template v-if="isShowTip && !disabled" #tip> + <div style="font-size: 8px"> + 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> + </div> + <div style="font-size: 8px"> + 格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b> 的文件 + </div> + </template> + <!-- TODO @puhui999:1)表单展示的时候,位置会偏掉,已发微信;2)disable 的时候,应该把【删除】按钮也隐藏掉? --> + <template #file="row"> + <div class="flex items-center"> + <span>{{ row.file.name }}</span> + <div class="ml-10px"> + <el-link + :href="row.file.url" + :underline="false" + download + target="_blank" + type="primary" + > + 下载 + </el-link> + </div> + <div class="ml-10px"> + <el-button link type="danger" @click="handleRemove(row.file)"> 删除</el-button> + </div> + </div> + </template> + </el-upload> + </div> +</template> +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import type { UploadInstance, UploadProps, UploadRawFile, UploadUserFile } from 'element-plus' +import { isString } from '@/utils/is' +import { useUpload } from '@/components/UploadFile/src/useUpload' +import { UploadFile } from 'element-plus/es/components/upload/src/upload' + +defineOptions({ name: 'UploadFile' }) + +const message = useMessage() // 消息弹窗 +const emit = defineEmits(['update:modelValue']) + +const props = defineProps({ + modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired, + fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf']), // 文件类型, 例如['png', 'jpg', 'jpeg'] + fileSize: propTypes.number.def(5), // 大小限制(MB) + limit: propTypes.number.def(5), // 数量限制 + autoUpload: propTypes.bool.def(true), // 自动上传 + drag: propTypes.bool.def(false), // 拖拽上传 + isShowTip: propTypes.bool.def(true), // 是否显示提示 + disabled: propTypes.bool.def(false) // 是否禁用上传组件 ==> 非必传(默认为 false) +}) + +// ========== 上传相关 ========== +const uploadRef = ref<UploadInstance>() +const uploadList = ref<UploadUserFile[]>([]) +const fileList = ref<UploadUserFile[]>([]) +const uploadNumber = ref<number>(0) + +const { uploadUrl, httpRequest } = useUpload() + +// 文件上传之前判断 +const beforeUpload: UploadProps['beforeUpload'] = (file: UploadRawFile) => { + if (fileList.value.length >= props.limit) { + message.error(`上传文件数量不能超过${props.limit}个!`) + return false + } + let fileExtension = '' + if (file.name.lastIndexOf('.') > -1) { + fileExtension = file.name.slice(file.name.lastIndexOf('.') + 1) + } + const isImg = props.fileType.some((type: string) => { + if (file.type.indexOf(type) > -1) return true + return !!(fileExtension && fileExtension.indexOf(type) > -1) + }) + const isLimit = file.size < props.fileSize * 1024 * 1024 + if (!isImg) { + message.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式!`) + return false + } + if (!isLimit) { + message.error(`上传文件大小不能超过${props.fileSize}MB!`) + return false + } + message.success('正在上传文件,请稍候...') + uploadNumber.value++ +} +// 处理上传的文件发生变化 +// const handleFileChange = (uploadFile: UploadFile): void => { +// uploadRef.value.data.path = uploadFile.name +// } +// 文件上传成功 +const handleFileSuccess: UploadProps['onSuccess'] = (res: any): void => { + message.success('上传成功') + // 删除自身 + const index = fileList.value.findIndex((item) => item.response?.data === res.data) + fileList.value.splice(index, 1) + uploadList.value.push({ name: res.data, url: res.data }) + if (uploadList.value.length == uploadNumber.value) { + fileList.value.push(...uploadList.value) + uploadList.value = [] + uploadNumber.value = 0 + emitUpdateModelValue() + } +} +// 文件数超出提示 +const handleExceed: UploadProps['onExceed'] = (): void => { + message.error(`上传文件数量不能超过${props.limit}个!`) +} +// 上传错误提示 +const excelUploadError: UploadProps['onError'] = (): void => { + message.error('导入数据失败,请您重新上传!') +} +// 删除上传文件 +const handleRemove = (file: UploadFile) => { + const index = fileList.value.map((f) => f.name).indexOf(file.name) + if (index > -1) { + fileList.value.splice(index, 1) + emitUpdateModelValue() + } +} +const handlePreview: UploadProps['onPreview'] = (uploadFile) => { + console.log(uploadFile) +} + +// 监听模型绑定值变动 +watch( + () => props.modelValue, + (val: string | string[]) => { + if (!val) { + fileList.value = [] // fix:处理掉缓存,表单重置后上传组件的内容并没有重置 + return + } + + fileList.value = [] // 保障数据为空 + // 情况1:字符串 + if (isString(val)) { + fileList.value.push( + ...val.split(',').map((url) => ({ name: url.substring(url.lastIndexOf('/') + 1), url })) + ) + return + } + // 情况2:数组 + fileList.value.push( + ...(val as string[]).map((url) => ({ name: url.substring(url.lastIndexOf('/') + 1), url })) + ) + }, + { immediate: true, deep: true } +) +// 发送文件链接列表更新 +const emitUpdateModelValue = () => { + // 情况1:数组结果 + let result: string | string[] = fileList.value.map((file) => file.url!) + // 情况2:逗号分隔的字符串 + if (props.limit === 1 || isString(props.modelValue)) { + result = result.join(',') + } + emit('update:modelValue', result) +} +</script> +<style lang="scss" scoped> +.upload-file-uploader { + margin-bottom: 5px; +} + +:deep(.upload-file-list .el-upload-list__item) { + position: relative; + margin-bottom: 10px; + line-height: 2; + border: 1px solid #e4e7ed; +} + +:deep(.el-upload-list__item-file-name) { + max-width: 250px; +} + +:deep(.upload-file-list .ele-upload-list__item-content) { + display: flex; + justify-content: space-between; + align-items: center; + color: inherit; +} + +:deep(.ele-upload-list__item-content-action .el-link) { + margin-right: 10px; +} +</style> diff --git a/src/components/UploadFile/src/UploadImg.vue b/src/components/UploadFile/src/UploadImg.vue new file mode 100644 index 0000000..ac0c162 --- /dev/null +++ b/src/components/UploadFile/src/UploadImg.vue @@ -0,0 +1,271 @@ +<template> + <div class="upload-box"> + <el-upload + :id="uuid" + :accept="fileType.join(',')" + :action="uploadUrl" + :before-upload="beforeUpload" + :class="['upload', drag ? 'no-border' : '']" + :disabled="disabled" + :drag="drag" + :http-request="httpRequest" + :multiple="false" + :on-error="uploadError" + :on-success="uploadSuccess" + :show-file-list="false" + > + <template v-if="modelValue"> + <img :src="modelValue" class="upload-image" /> + <div class="upload-handle" @click.stop> + <div v-if="!disabled" class="handle-icon" @click="editImg"> + <Icon icon="ep:edit" /> + <span v-if="showBtnText">{{ t('action.edit') }}</span> + </div> + <div class="handle-icon" @click="imagePreview(modelValue)"> + <Icon icon="ep:zoom-in" /> + <span v-if="showBtnText">{{ t('action.detail') }}</span> + </div> + <div v-if="showDelete && !disabled" class="handle-icon" @click="deleteImg"> + <Icon icon="ep:delete" /> + <span v-if="showBtnText">{{ t('action.del') }}</span> + </div> + </div> + </template> + <template v-else> + <div class="upload-empty"> + <slot name="empty"> + <Icon icon="ep:plus" /> + <!-- <span>请上传图片</span> --> + </slot> + </div> + </template> + </el-upload> + <div class="el-upload__tip"> + <slot name="tip"></slot> + </div> + </div> +</template> + +<script lang="ts" setup> +import type { UploadProps } from 'element-plus' + +import { generateUUID } from '@/utils' +import { propTypes } from '@/utils/propTypes' +import { createImageViewer } from '@/components/ImageViewer' +import { useUpload } from '@/components/UploadFile/src/useUpload' + +defineOptions({ name: 'UploadImg' }) + +type FileTypes = + | 'image/apng' + | 'image/bmp' + | 'image/gif' + | 'image/jpeg' + | 'image/pjpeg' + | 'image/png' + | 'image/svg+xml' + | 'image/tiff' + | 'image/webp' + | 'image/x-icon' + +// 接受父组件参数 +const props = defineProps({ + modelValue: propTypes.string.def(''), + drag: propTypes.bool.def(true), // 是否支持拖拽上传 ==> 非必传(默认为 true) + disabled: propTypes.bool.def(false), // 是否禁用上传组件 ==> 非必传(默认为 false) + fileSize: propTypes.number.def(5), // 图片大小限制 ==> 非必传(默认为 5M) + fileType: propTypes.array.def(['image/jpeg', 'image/png', 'image/gif']), // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"]) + height: propTypes.string.def('150px'), // 组件高度 ==> 非必传(默认为 150px) + width: propTypes.string.def('150px'), // 组件宽度 ==> 非必传(默认为 150px) + borderradius: propTypes.string.def('8px'), // 组件边框圆角 ==> 非必传(默认为 8px) + showDelete: propTypes.bool.def(true), // 是否显示删除按钮 + showBtnText: propTypes.bool.def(true) // 是否显示按钮文字 +}) +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +// 生成组件唯一id +const uuid = ref('id-' + generateUUID()) +// 查看图片 +const imagePreview = (imgUrl: string) => { + createImageViewer({ + zIndex: 9999999, + urlList: [imgUrl] + }) +} + +const emit = defineEmits(['update:modelValue']) + +const deleteImg = () => { + emit('update:modelValue', '') +} + +const { uploadUrl, httpRequest } = useUpload() + +const editImg = () => { + const dom = document.querySelector(`#${uuid.value} .el-upload__input`) + dom && dom.dispatchEvent(new MouseEvent('click')) +} + +const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => { + const imgSize = rawFile.size / 1024 / 1024 < props.fileSize + const imgType = props.fileType + if (!imgType.includes(rawFile.type as FileTypes)) + message.notifyWarning('上传图片不符合所需的格式!') + if (!imgSize) message.notifyWarning(`上传图片大小不能超过 ${props.fileSize}M!`) + return imgType.includes(rawFile.type as FileTypes) && imgSize +} + +// 图片上传成功提示 +const uploadSuccess: UploadProps['onSuccess'] = (res: any): void => { + message.success('上传成功') + emit('update:modelValue', res.data) +} + +// 图片上传错误提示 +const uploadError = () => { + message.notifyError('图片上传失败,请您重新上传!') +} +</script> +<style lang="scss" scoped> +.is-error { + .upload { + :deep(.el-upload), + :deep(.el-upload-dragger) { + border: 1px dashed var(--el-color-danger) !important; + + &:hover { + border-color: var(--el-color-primary) !important; + } + } + } +} + +:deep(.disabled) { + .el-upload, + .el-upload-dragger { + cursor: not-allowed !important; + background: var(--el-disabled-bg-color); + border: 1px dashed var(--el-border-color-darker) !important; + + &:hover { + border: 1px dashed var(--el-border-color-darker) !important; + } + } +} + +.upload-box { + .no-border { + :deep(.el-upload) { + border: none !important; + } + } + + :deep(.upload) { + .el-upload { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: v-bind(width); + height: v-bind(height); + overflow: hidden; + border: 1px dashed var(--el-border-color-darker); + border-radius: v-bind(borderradius); + transition: var(--el-transition-duration-fast); + + &:hover { + border-color: var(--el-color-primary); + + .upload-handle { + opacity: 1; + } + } + + .el-upload-dragger { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 0; + overflow: hidden; + background-color: transparent; + border: 1px dashed var(--el-border-color-darker); + border-radius: v-bind(borderradius); + + &:hover { + border: 1px dashed var(--el-color-primary); + } + } + + .el-upload-dragger.is-dragover { + background-color: var(--el-color-primary-light-9); + border: 2px dashed var(--el-color-primary) !important; + } + + .upload-image { + width: 100%; + height: 100%; + object-fit: contain; + } + + .upload-empty { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 30px; + color: var(--el-color-info); + + .el-icon { + font-size: 28px; + color: var(--el-text-color-secondary); + } + } + + .upload-handle { + position: absolute; + top: 0; + right: 0; + display: flex; + width: 100%; + height: 100%; + cursor: pointer; + background: rgb(0 0 0 / 60%); + opacity: 0; + box-sizing: border-box; + transition: var(--el-transition-duration-fast); + align-items: center; + justify-content: center; + + .handle-icon { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0 6%; + color: aliceblue; + + .el-icon { + margin-bottom: 40%; + font-size: 130%; + line-height: 130%; + } + + span { + font-size: 85%; + line-height: 85%; + } + } + } + } + } + + .el-upload__tip { + line-height: 18px; + text-align: center; + } +} +</style> diff --git a/src/components/UploadFile/src/UploadImgs.vue b/src/components/UploadFile/src/UploadImgs.vue new file mode 100644 index 0000000..85da64c --- /dev/null +++ b/src/components/UploadFile/src/UploadImgs.vue @@ -0,0 +1,323 @@ +<template> + <div class="upload-box"> + <el-upload + v-model:file-list="fileList" + :accept="fileType.join(',')" + :action="uploadUrl" + :before-upload="beforeUpload" + :class="['upload', drag ? 'no-border' : '']" + :disabled="disabled" + :drag="drag" + :http-request="httpRequest" + :limit="limit" + :multiple="true" + :on-error="uploadError" + :on-exceed="handleExceed" + :on-success="uploadSuccess" + list-type="picture-card" + > + <div class="upload-empty"> + <slot name="empty"> + <Icon icon="ep:plus" /> + <!-- <span>请上传图片</span> --> + </slot> + </div> + <template #file="{ file }"> + <img :src="file.url" class="upload-image" /> + <div class="upload-handle" @click.stop> + <div class="handle-icon" @click="handlePictureCardPreview(file)"> + <Icon icon="ep:zoom-in" /> + <span>查看</span> + </div> + <div v-if="!disabled" class="handle-icon" @click="handleRemove(file)"> + <Icon icon="ep:delete" /> + <span>删除</span> + </div> + </div> + </template> + </el-upload> + <div class="el-upload__tip"> + <slot name="tip"></slot> + </div> + <el-image-viewer + v-if="imgViewVisible" + :url-list="[viewImageUrl]" + @close="imgViewVisible = false" + /> + </div> +</template> +<script lang="ts" setup> +import type { UploadFile, UploadProps, UploadUserFile } from 'element-plus' +import { ElNotification } from 'element-plus' + +import { propTypes } from '@/utils/propTypes' +import { useUpload } from '@/components/UploadFile/src/useUpload' + +defineOptions({ name: 'UploadImgs' }) + +const message = useMessage() // 消息弹窗 + +type FileTypes = + | 'image/apng' + | 'image/bmp' + | 'image/gif' + | 'image/jpeg' + | 'image/pjpeg' + | 'image/png' + | 'image/svg+xml' + | 'image/tiff' + | 'image/webp' + | 'image/x-icon' + +const props = defineProps({ + modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired, + drag: propTypes.bool.def(true), // 是否支持拖拽上传 ==> 非必传(默认为 true) + disabled: propTypes.bool.def(false), // 是否禁用上传组件 ==> 非必传(默认为 false) + limit: propTypes.number.def(5), // 最大图片上传数 ==> 非必传(默认为 5张) + fileSize: propTypes.number.def(5), // 图片大小限制 ==> 非必传(默认为 5M) + fileType: propTypes.array.def(['image/jpeg', 'image/png', 'image/gif']), // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"]) + height: propTypes.string.def('150px'), // 组件高度 ==> 非必传(默认为 150px) + width: propTypes.string.def('150px'), // 组件宽度 ==> 非必传(默认为 150px) + borderradius: propTypes.string.def('8px') // 组件边框圆角 ==> 非必传(默认为 8px) +}) + +const { uploadUrl, httpRequest } = useUpload() + +const fileList = ref<UploadUserFile[]>([]) +const uploadNumber = ref<number>(0) +const uploadList = ref<UploadUserFile[]>([]) +/** + * @description 文件上传之前判断 + * @param rawFile 上传的文件 + * */ +const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => { + const imgSize = rawFile.size / 1024 / 1024 < props.fileSize + const imgType = props.fileType + if (!imgType.includes(rawFile.type as FileTypes)) + ElNotification({ + title: '温馨提示', + message: '上传图片不符合所需的格式!', + type: 'warning' + }) + if (!imgSize) + ElNotification({ + title: '温馨提示', + message: `上传图片大小不能超过 ${props.fileSize}M!`, + type: 'warning' + }) + uploadNumber.value++ + return imgType.includes(rawFile.type as FileTypes) && imgSize +} + +// 图片上传成功 +interface UploadEmits { + (e: 'update:modelValue', value: string[]): void +} + +const emit = defineEmits<UploadEmits>() +const uploadSuccess: UploadProps['onSuccess'] = (res: any): void => { + message.success('上传成功') + // 删除自身 + const index = fileList.value.findIndex((item) => item.response?.data === res.data) + fileList.value.splice(index, 1) + uploadList.value.push({ name: res.data, url: res.data }) + if (uploadList.value.length == uploadNumber.value) { + fileList.value.push(...uploadList.value) + uploadList.value = [] + uploadNumber.value = 0 + emitUpdateModelValue() + } +} + +// 监听模型绑定值变动 +watch( + () => props.modelValue, + (val: string | string[]) => { + if (!val) { + fileList.value = [] // fix:处理掉缓存,表单重置后上传组件的内容并没有重置 + return + } + + fileList.value = [] // 保障数据为空 + fileList.value.push( + ...(val as string[]).map((url) => ({ name: url.substring(url.lastIndexOf('/') + 1), url })) + ) + }, + { immediate: true, deep: true } +) +// 发送图片链接列表更新 +const emitUpdateModelValue = () => { + let result: string[] = fileList.value.map((file) => file.url!) + emit('update:modelValue', result) +} +// 删除图片 +const handleRemove = (uploadFile: UploadFile) => { + fileList.value = fileList.value.filter( + (item) => item.url !== uploadFile.url || item.name !== uploadFile.name + ) + emit( + 'update:modelValue', + fileList.value.map((file) => file.url!) + ) +} + +// 图片上传错误提示 +const uploadError = () => { + ElNotification({ + title: '温馨提示', + message: '图片上传失败,请您重新上传!', + type: 'error' + }) +} + +// 文件数超出提示 +const handleExceed = () => { + ElNotification({ + title: '温馨提示', + message: `当前最多只能上传 ${props.limit} 张图片,请移除后上传!`, + type: 'warning' + }) +} + +// 图片预览 +const viewImageUrl = ref('') +const imgViewVisible = ref(false) +const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => { + viewImageUrl.value = uploadFile.url! + imgViewVisible.value = true +} +</script> + +<style lang="scss" scoped> +.is-error { + .upload { + :deep(.el-upload--picture-card), + :deep(.el-upload-dragger) { + border: 1px dashed var(--el-color-danger) !important; + + &:hover { + border-color: var(--el-color-primary) !important; + } + } + } +} + +:deep(.disabled) { + .el-upload--picture-card, + .el-upload-dragger { + cursor: not-allowed; + background: var(--el-disabled-bg-color) !important; + border: 1px dashed var(--el-border-color-darker); + + &:hover { + border-color: var(--el-border-color-darker) !important; + } + } +} + +.upload-box { + .no-border { + :deep(.el-upload--picture-card) { + border: none !important; + } + } + + :deep(.upload) { + .el-upload-dragger { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: 0; + overflow: hidden; + border: 1px dashed var(--el-border-color-darker); + border-radius: v-bind(borderradius); + + &:hover { + border: 1px dashed var(--el-color-primary); + } + } + + .el-upload-dragger.is-dragover { + background-color: var(--el-color-primary-light-9); + border: 2px dashed var(--el-color-primary) !important; + } + + .el-upload-list__item, + .el-upload--picture-card { + width: v-bind(width); + height: v-bind(height); + background-color: transparent; + border-radius: v-bind(borderradius); + } + + .upload-image { + width: 100%; + height: 100%; + object-fit: contain; + } + + .upload-handle { + position: absolute; + top: 0; + right: 0; + display: flex; + width: 100%; + height: 100%; + cursor: pointer; + background: rgb(0 0 0 / 60%); + opacity: 0; + box-sizing: border-box; + transition: var(--el-transition-duration-fast); + align-items: center; + justify-content: center; + + .handle-icon { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0 6%; + color: aliceblue; + + .el-icon { + margin-bottom: 15%; + font-size: 140%; + } + + span { + font-size: 100%; + } + } + } + + .el-upload-list__item { + &:hover { + .upload-handle { + opacity: 1; + } + } + } + + .upload-empty { + display: flex; + flex-direction: column; + align-items: center; + font-size: 12px; + line-height: 30px; + color: var(--el-color-info); + + .el-icon { + font-size: 28px; + color: var(--el-text-color-secondary); + } + } + } + + .el-upload__tip { + line-height: 15px; + text-align: center; + } +} +</style> diff --git a/src/components/UploadFile/src/useUpload.ts b/src/components/UploadFile/src/useUpload.ts new file mode 100644 index 0000000..c0465a2 --- /dev/null +++ b/src/components/UploadFile/src/useUpload.ts @@ -0,0 +1,97 @@ +import * as FileApi from '@/api/infra/file' +import CryptoJS from 'crypto-js' +import { UploadRawFile, UploadRequestOptions } from 'element-plus/es/components/upload/src/upload' +import axios from 'axios' + +export const useUpload = () => { + // 后端上传地址 + const uploadUrl = import.meta.env.VITE_UPLOAD_URL + // 是否使用前端直连上传 + const isClientUpload = UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE + // 重写ElUpload上传方法 + const httpRequest = async (options: UploadRequestOptions) => { + // 模式一:前端上传 + if (isClientUpload) { + // 1.1 生成文件名称 + const fileName = await generateFileName(options.file) + // 1.2 获取文件预签名地址 + const presignedInfo = await FileApi.getFilePresignedUrl(fileName) + // 1.3 上传文件(不能使用 ElUpload 的 ajaxUpload 方法的原因:其使用的是 FormData 上传,Minio 不支持) + return axios.put(presignedInfo.uploadUrl, options.file, { + headers: { + 'Content-Type': options.file.type, + } + }).then(() => { + // 1.4. 记录文件信息到后端(异步) + createFile(presignedInfo, fileName, options.file) + // 通知成功,数据格式保持与后端上传的返回结果一致 + return { data: presignedInfo.url } + }) + } else { + // 模式二:后端上传 + // 重写 el-upload httpRequest 文件上传成功会走成功的钩子,失败走失败的钩子 + return new Promise((resolve, reject) => { + FileApi.updateFile({ file: options.file }) + .then((res) => { + if (res.code === 0) { + resolve(res) + } else { + reject(res) + } + }) + .catch((res) => { + reject(res) + }) + }) + } + } + + return { + uploadUrl, + httpRequest + } +} + +/** + * 创建文件信息 + * @param vo 文件预签名信息 + * @param name 文件名称 + * @param file 文件 + */ +function createFile(vo: FileApi.FilePresignedUrlRespVO, name: string, file: UploadRawFile) { + const fileVo = { + configId: vo.configId, + url: vo.url, + path: name, + name: file.name, + type: file.type, + size: file.size + } + FileApi.createFile(fileVo) + return fileVo +} + +/** + * 生成文件名称(使用算法SHA256) + * @param file 要上传的文件 + */ +async function generateFileName(file: UploadRawFile) { + // 读取文件内容 + const data = await file.arrayBuffer() + const wordArray = CryptoJS.lib.WordArray.create(data) + // 计算SHA256 + const sha256 = CryptoJS.SHA256(wordArray).toString() + // 拼接后缀 + const ext = file.name.substring(file.name.lastIndexOf('.')) + return `${sha256}${ext}` +} + +/** + * 上传类型 + */ +enum UPLOAD_TYPE { + // 客户端直接上传(只支持S3服务) + CLIENT = 'client', + // 客户端发送到后端上传 + SERVER = 'server' +} diff --git a/src/components/Verifition/index.ts b/src/components/Verifition/index.ts new file mode 100644 index 0000000..bcfe6d9 --- /dev/null +++ b/src/components/Verifition/index.ts @@ -0,0 +1,3 @@ +import Verify from './src/Verify.vue' + +export { Verify } diff --git a/src/components/Verifition/src/Verify.vue b/src/components/Verifition/src/Verify.vue new file mode 100644 index 0000000..b7b5048 --- /dev/null +++ b/src/components/Verifition/src/Verify.vue @@ -0,0 +1,441 @@ +<template> + <div v-show="showBox" :class="mode == 'pop' ? 'mask' : ''"> + <div + :class="mode == 'pop' ? 'verifybox' : ''" + :style="{ 'max-width': parseInt(imgSize.width) + 20 + 'px' }" + > + <div v-if="mode == 'pop'" class="verifybox-top"> + {{ t('captcha.verification') }} + <span class="verifybox-close" @click="closeBox"> + <i class="iconfont icon-close"></i> + </span> + </div> + <div :style="{ padding: mode == 'pop' ? '10px' : '0' }" class="verifybox-bottom"> + <!-- 验证码容器 --> + <component + :is="componentType" + v-if="componentType" + ref="instance" + :arith="arith" + :barSize="barSize" + :blockSize="blockSize" + :captchaType="captchaType" + :explain="explain" + :figure="figure" + :imgSize="imgSize" + :mode="mode" + :type="verifyType" + :vSpace="vSpace" + /> + </div> + </div> + </div> +</template> +<script type="text/babel"> +/** + * Verify 验证码组件 + * @description 分发验证码使用 + * */ +import { VerifyPoints, VerifySlide } from './Verify' +import { computed, ref, toRefs, watchEffect } from 'vue' + +export default { + name: 'Vue3Verify', + components: { + VerifySlide, + VerifyPoints + }, + props: { + captchaType: { + type: String, + required: true + }, + figure: { + type: Number + }, + arith: { + type: Number + }, + mode: { + type: String, + default: 'pop' + }, + vSpace: { + type: Number + }, + explain: { + type: String + }, + imgSize: { + type: Object, + default() { + return { + width: '310px', + height: '155px' + } + } + }, + blockSize: { + type: Object + }, + barSize: { + type: Object + } + }, + setup(props) { + const { t } = useI18n() + const { captchaType, mode } = toRefs(props) + const clickShow = ref(false) + const verifyType = ref(undefined) + const componentType = ref(undefined) + + const instance = ref({}) + + const showBox = computed(() => { + if (mode.value == 'pop') { + return clickShow.value + } else { + return true + } + }) + /** + * refresh + * @description 刷新 + * */ + const refresh = () => { + if (instance.value.refresh) { + instance.value.refresh() + } + } + const closeBox = () => { + clickShow.value = false + refresh() + } + const show = () => { + if (mode.value == 'pop') { + clickShow.value = true + } + } + watchEffect(() => { + switch (captchaType.value) { + case 'blockPuzzle': + verifyType.value = '2' + componentType.value = 'VerifySlide' + break + case 'clickWord': + verifyType.value = '' + componentType.value = 'VerifyPoints' + break + } + }) + + return { + t, + clickShow, + verifyType, + componentType, + instance, + showBox, + closeBox, + show + } + } +} +</script> +<style> +.verifybox { + position: relative; + top: 50%; + left: 50%; + background-color: #fff; + border: 1px solid #e4e7eb; + border-radius: 5px; + transform: translate(-50%, -50%); + box-shadow: 0 0 10px rgb(0 0 0 / 30%); + box-sizing: border-box; +} + +.verifybox-top { + height: 40px; + padding: 0 15px; + font-size: 16px; + line-height: 40px; + color: #45494c; + text-align: left; + border-bottom: 1px solid #e4e7eb; + box-sizing: border-box; +} + +.verifybox-bottom { + padding: 10px; + box-sizing: border-box; +} + +.verifybox-close { + position: absolute; + top: 13px; + right: 9px; + width: 24px; + height: 24px; + text-align: center; + cursor: pointer; +} + +.mask { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: 100%; + height: 100vh; + background: rgb(0 0 0 / 30%); + + /* display: none; */ + transition: all 0.5s; +} + +.verify-tips { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 30px; + line-height: 30px; + color: #fff; + text-indent: 10px; +} + +.suc-bg { + background-color: rgb(92 184 92 / 50%); + filter: progid:DXImageTransform.Microsoft.gradient(startcolorstr=#7f5CB85C, endcolorstr=#7f5CB85C); +} + +.err-bg { + background-color: rgb(217 83 79 / 50%); + filter: progid:DXImageTransform.Microsoft.gradient(startcolorstr=#7fD9534F, endcolorstr=#7fD9534F); +} + +.tips-enter, +.tips-leave-to { + bottom: -30px; +} + +.tips-enter-active, +.tips-leave-active { + transition: bottom 0.5s; +} + +/* ---------------------------- */ + +/* 常规验证码 */ +.verify-code { + margin-bottom: 5px; + font-size: 20px; + text-align: center; + cursor: pointer; + border: 1px solid #ddd; +} + +.cerify-code-panel { + height: 100%; + overflow: hidden; +} + +.verify-code-area { + float: left; +} + +.verify-input-area { + float: left; + width: 60%; + padding-right: 10px; +} + +.verify-change-area { + float: left; + line-height: 30px; +} + +.varify-input-code { + display: inline-block; + width: 100%; + height: 25px; +} + +.verify-change-code { + color: #337ab7; + cursor: pointer; +} + +.verify-btn { + width: 200px; + height: 30px; + margin-top: 10px; + color: #fff; + background-color: #337ab7; + border: none; + border-radius: 8px; +} + +/* 滑动验证码 */ +.verify-bar-area { + position: relative; + text-align: center; + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; + box-sizing: content-box; +} + +.verify-bar-area .verify-move-block { + position: absolute; + top: 0; + left: 0; + cursor: pointer; + background: #fff; + border-radius: 8px; + box-shadow: 0 0 2px #888; + box-sizing: content-box; +} + +.verify-bar-area .verify-move-block:hover { + color: #fff; + background-color: #337ab7; +} + +.verify-bar-area .verify-left-bar { + position: absolute; + top: -1px; + left: -1px; + cursor: pointer; + background: #f0fff0; + border: 1px solid #ddd; + border-radius: 8px; + box-sizing: content-box; +} + +.verify-img-panel { + position: relative; + margin: 0; + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + border-radius: 3px; + box-sizing: content-box; +} + +.verify-img-panel .verify-refresh { + position: absolute; + top: 0; + right: 0; + z-index: 2; + width: 25px; + height: 25px; + padding: 5px; + text-align: center; + cursor: pointer; +} + +.verify-img-panel .icon-refresh { + font-size: 20px; + color: #fff; +} + +.verify-img-panel .verify-gap { + position: relative; + z-index: 2; + background-color: #fff; + border: 1px solid #fff; +} + +.verify-bar-area .verify-move-block .verify-sub-block { + position: absolute; + z-index: 3; + text-align: center; + + /* border: 1px solid #fff; */ +} + +.verify-bar-area .verify-move-block .verify-icon { + font-size: 18px; +} + +.verify-bar-area .verify-msg { + z-index: 3; +} + +/* 字体图标的css */ + +/* @font-face {font-family: "iconfont"; */ + +/* src: url('../fonts/iconfont.eot?t=1508229193188'); !* IE9*! */ + +/* src: url('../fonts/iconfont.eot?t=1508229193188#iefix') format('embedded-opentype'), !* IE6-IE8 *! */ + +/* url('data:application/x-font-woff;charset=utf-8;base64,d09GRgABAAAAAAaAAAsAAAAACUwAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABCAAAADMAAABCsP6z7U9TLzIAAAE8AAAARAAAAFZW7kiSY21hcAAAAYAAAAB3AAABuM+qBlRnbHlmAAAB+AAAAnQAAALYnrUwT2hlYWQAAARsAAAALwAAADYPNwajaGhlYQAABJwAAAAcAAAAJAfeA4dobXR4AAAEuAAAABMAAAAYF+kAAGxvY2EAAATMAAAADgAAAA4CvAGsbWF4cAAABNwAAAAfAAAAIAEVAF1uYW1lAAAE/AAAAUUAAAJtPlT+fXBvc3QAAAZEAAAAPAAAAE3oPPXPeJxjYGRgYOBikGPQYWB0cfMJYeBgYGGAAJAMY05meiJQDMoDyrGAaQ4gZoOIAgCKIwNPAHicY2Bk/sM4gYGVgYOpk+kMAwNDP4RmfM1gxMjBwMDEwMrMgBUEpLmmMDgwVDxbwtzwv4EhhrmBoQEozAiSAwAw1A0UeJzFkcENgCAMRX8RjCGO4gTe9eQcnhzAfXC2rqG/hYsT8MmD9gdS0gJIAAaykAjIBYHppCvuD8juR6zMJ67A89Zdn/f1aNPikUn8RvYo8G20CjKim6Rf6b9m34+WWd/vBr+oW8V6q3vF5qKlYrPRp4L0Ad5nGL8AeJxFUc9rE0EYnTezu8lMsrvtbrqb3TRt0rS7bdOmdI0JbWmCtiItIv5oi14qevCk9SQVLFiQgqAF8Q9QLKIHLx48FkHo3ZNnFUXwD5C2B6dO6sFhmI83w7z3fe8RnZCjb2yX5YlLhskkmScXCIFRxYBFiyjH9Rqtoqes9/g5i8WVuJyqDNTYLPwBI+cljXrkGynDhoU+nCgnjbhGY5yst+gMEq8IBIXwsjPU67CnEPm4b0su0h309Fd67da4XBhr55KSm17POk7gOE/Shq6nKdVsC7d9j+tcGPKVboc9u/0jtB/ZIA7PXTVLBef6o/paccjnwOYm3ELJetPuDrvV3gg91wlSXWY6H5qVwRzWf2TybrYYfSdqoXOwh/Qa8RWIjBTiSI3h614/vKSNRhONOrsnQi6Xf4nQFQDTmJE1NKbhI6crHEJO/+S5QPxhYJRRyvBFBP+5T9EPpEAIVzzRQIrjmJ6jY1WTo+NXTMchuBsKuS8PRZATSMl9oTA4uNLkeIA0V1UeqOoGQh7IAxGo+7T83fn3T+voqCNPPAUazUYUI7LgKSV1Jk2oUeghYGhZ+cKOe2FjVu5ZKEY2VkE13AK1+jI4r1KLbPlZfrKiPhOXKPRj7q9sj9XJ7LFHNmrKJS3VCdhXGSdKrtmoQaWeMjQVt0KD6sGPOx0oH2fgtzoNROxtNq8F3tzYM/n+TjKSX5qf2jx941276TIr9FjXxKr8eX/6bK4yuopwo9py1sw8F9kdw4AmurRpLUM3tYx5ZnKpfHPi8dzz19vJ6MjyxYUrpqeb1uLs3eGV6vr21pSqpeWkqonAN9oUyIiXpv8XvlN5e3icY2BkYGAA4n0vN4fG89t8ZeBmYQCBa9wPPRH0/wcsDMwmQC4HAxNIFABAfAqaAHicY2BkYGBu+N/AEMPCAAJAkpEBFbABAEcMAm94nGNhYGBgfsnAwMKAigESnwEBAAAAAAAAdgCkANoBCAFsAAB4nGNgZGBgYGMIZGBlAAEmIOYCQgaG/2A+AwARSAFzAHicZY9NTsMwEIVf+gekEqqoYIfkBWIBKP0Rq25YVGr3XXTfpk6bKokjx63UA3AejsAJOALcgDvwSCebNpbH37x5Y08A3OAHHo7fLfeRPVwyO3INF7gXrlN/EG6QX4SbaONVuEX9TdjHM6bCbXRheYPXuGL2hHdhDx18CNdwjU/hOvUv4Qb5W7iJO/wKt9Dx6sI+5l5XuI1HL/bHVi+cXqnlQcWhySKTOb+CmV7vkoWt0uqca1vEJlODoF9JU51pW91T7NdD5yIVWZOqCas6SYzKrdnq0AUb5/JRrxeJHoQm5Vhj/rbGAo5xBYUlDowxQhhkiMro6DtVZvSvsUPCXntWPc3ndFsU1P9zhQEC9M9cU7qy0nk6T4E9XxtSdXQrbsuelDSRXs1JErJCXta2VELqATZlV44RelzRiT8oZ0j/AAlabsgAAAB4nGNgYoAALgbsgI2RiZGZkYWRlZGNkZ2BsYI1OSM1OZs1OSe/OJW1KDM9o4S9KDWtKLU4g4EBAJ79CeQ=') format('woff'), */ + +/* url('../fonts/iconfont.ttf?t=1508229193188') format('truetype'), !* chrome, firefox, opera, Safari, Android, iOS 4.2+*! */ + +/* url('../fonts/iconfont.svg?t=1508229193188#iconfont') format('svg'); !* iOS 4.1- *! */ + +/* } */ + +.iconfont { + font-family: iconfont !important; + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-style: normal; +} + +.icon-check::before { + position: absolute; + z-index: 9999; + display: block; + width: 16px; + height: 16px; + margin: auto; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAYAAAD9yHLdAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAAAAAAAA+UO7fwAAAAlwSFlzAAAASAAAAEgARslrPgAAIlFJREFUeNrt3X1cVNW6B/BnbcS3xJd7fLmSeo+op/Qmyp4BFcQEwpd8Nyc9iZppgUfE49u1tCwlNcMySCM1S81jCoaioiJvKoYgswfUo5wSJ69SZFKCKSAws+4f2/GetFFRYG3g9/2Hz2xj+O2J4Zm19trrIQIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKgjmOgAAADwOBhz83TzdPNs397qanW1ujJ2s8fNHjd7FBTkhuSG5IbculVdP1kSfeoAAPBwdFzHdXzgQN0S3RLdkpgY2SJbZMvNm9It6ZZ064cfGmQ2yGyQmZfX3KO5R3OPwkJdsi5Zl5yYKIfL4XL4mDHqs7AqGzhgBAIAoFFdI7pGdI1o1KjFlhZbWmxZv149OmXK4z3r4cPEiROfOFExKSbFVFDwqM+EEQgAgMY8y5/lz/LGjZu3bt66eev9+9Wjj1s4bAYNIkaMWHKyx3mP8x7nmzd/1GdyEP1CAQCASifrZJ3s6FjmWuZa5rprF3uLvcXeGjq0en5au3a8nJfz8k6d8lPyU/JTYmIq+wwYgQAAaIIk0WgaTaO/+IJm0SyaNWJEtf/IPMqjvJde0g/QD9APcHOrdGIhrxMAANzGmJwr58q569ZRLMVS7MSJNfajFVJIYYy/wF/gL7z0UmW/vUGNvk4AAHCHTqfT6XQrVtB4Gk/jg4KEBfmBfqAf+vSp7LdhBAIAUMPUwvH66+oj21eBSqmUStu3r+y3oYAAANQQtXDMmKE+WrlSdB4bvpwv58t/+62y34cCAgBQzeSt8lZ568SJFEiBFLh2reg8d2MD2UA28PTpyn4fCggAQDXRh+pD9aEjR1IABVDA5s20ntbTeklzf3eZF/NiXvv2Vfb7NHciAAC1nRwsB8vBvr5Wf6u/1X/nTubO3Jl7A+0tWvImb/LOyemc3zm/c/6ePZX9dmxlAgBQRfTd9N303Tw8rFusW6xbEhPZLDaLzXJyEp3rHjNoBs24dYt/wj/hn3h5mUwmk8mkKJV9GoxAAAAekz5AH6APeOYZ6znrOeu5Awc0WzgCKZACrVZ2hB1hR15++VELhw1GIAAAj0hdVdWli/ooNVX9WvnlsNUflHSk45wbuZEbg4LUwrFhw+M+LUYgAACV1CuoV1CvoCef5Kv4Kr4qIUE9qsHCcRsv4AW8YOHCqiocNtq7qAMAoFHqZoetW9MgGkSDDh+mhbSQFnbuLDrX/YWGmmJMMaaYsLCqfmZMYQEAPIBt23PLp5ZPLZ8mJ9MROkJHdDrRueyKpViKXbdO6aB0UDoEB1fXj8EUFgCAHX0v973c93KTJpbvLd9bvt+3T+uFg0/mk/nkL79UC0dISHX/PIxAAADuYuvLwQ/xQ/zQnj1sKBvKhj7/vOhc9vA4HsfjYmOd2jm1c2o3btxRdpQdZRUV1f1zMQIBALjNYDAYDAYHB9pEm2jTl19qvXBQGIVRWFKSWjgmTKipwmGDi+gAAERExJhZZ9aZdZGRNJ2m0/Tx40UnssuHfMgnPb2koKSgpGD0aIUpTGGlpTUdAwUEAOo9XbguXBf+/vu0lbbS1ldfFZ3HrgE0gAacPu0423G24+xhw5SOSkel440bouKggABAvaXjOq7j77xDetKTfv580Xns8iIv8srNlfKkPClv8OD0jukd0zv++qvoWLiIDgD1jrpnVXAwb86b8+Yffyw6jz18NV/NV+flWQZaBloGenufYqfYKXbxouhcNriIDgD1hi5Zl6xLnjyZL+AL+ILwcNF57OpLfanv1atsPpvP5vv7a61w2GAEAgB1nrpn1ejRPJNn8szoaM1ur05EREVF6ldfX0VRFEUxmUQnskejLyAAwOPT79fv1+9/7jn+E/+J/7Rjh7YLR3ExceLEhw9XTIpJMWm3cNho9IUEAHh08hB5iDykb1/+M/+Z/7x7N0VSJEU2aiQ61z30pCd9WZl1inWKdcoLL2R5ZnlmeR4/LjrWw8I1EACoM+S2clu5rasr+yv7K/vrgQO0jtbRumbNROe6G4/kkTzSYqFMyqTMgAC1cBw6JDpXZaGAAECt1zukd0jvkG7daBftol2HD1MERVBEq1aic93jdl8O9gv7hf0SGKhOVUVHi471qFBAAKDW0hfri/XFHTs6cAfuwBMS2Bw2h81p1050LruepWfp2fnzlaHKUGXopk2i4zwuFBAAqHVcw1zDXMPatrWSlayUkEBplEZp//VfonPZw86ys+zsm28qE5WJysQPPxSdp6qggABAraHuktuiRYOgBkENgg4dYt7Mm3k/9ZToXHZNpIk0MTzcWGosNZYuXy46TlXDfSAAoHnqfRxNm6qP4uPVr/37i85l11gaS2M3b1YWK4uVxa+8oh7kXHSsqoYRCABoVo+oHlE9oho2pME0mAbHxKhHNVw4IimSImNiXLJdsl2yp09XD9a9wmGDAgIAmmPry9G4f+P+jfv/4x8UT/EUP3iw6Fz3d/hwUXpRelH6Sy9FR0dHR0dbLKITVTfcSAgAGsPYhT4X+lzos2EDG8FGsBHjxolOZA9fxBfxRWlpFeYKc4V57NjckNyQ3JBbt0Tnqim4BgIAmiEvkhfJiz78kMWzeBY/Z47oPPbwpXwpX5qdbRlmGWYZ5uOjbnZYWCg6V03DFBYACKdbq1urW7tiheYLRypP5anffluRU5FTkTN4cH0tHDYYgQCAMOqeVX//O7vKrrKra9aIzmMPP86P8+NmM/fjftzP2zsrLSstK+3HH0XnEg0jEACocXJXuavcdepU1ol1Yp00fGNdP+pH/X78UUqSkqQkf38Ujt9DAQGAGqMP0YfoQ154gbbTdtq+cSMppJDCtDcTwokTLyiwvGh50fKiv79xuHG4cbjZLDqW1mjvfxwA1DluZjezm3nECMkgGSTD11+rRx0dRee6G8/gGTzj+nU+gA/gA/z81BGH0Sg6l1ZhBAIA1Ua9g9zHh/3MfmY/R0WpRzVYOE7yk/xkSYmUI+VIOSNHonA8HIxAAKDK6bvpu+m7eXhYt1i3WLckJrJZbBab5eQkOtcfKy9Xv44Zo7aQjYsTnai2cBAdAADqDn2APkAf8Mwz1gRrgjUhIYG9wF5gL7RsKTrXPQIpkAKtVlbMilnxpElKvBKvxO/eLTpWbYMRCAA8NnWqqksXddXSsWN0gk7QCWdn0bnuDao2dOJGbuTGoCCTyWQymTZsEB2rtsI1EAB4ZL2CegX1CnrySb6Kr+KrEhI0Wzhu4wW8gBcsXIjCUTWwFxYAVJral6N1axpEg2jQ4cO0kBbSws6dRee6v9BQU4wpxhQTFiY6SV2BKSwAeGge5z3Oe5xv3tzyreVby7dJSfQ2vU1v6/Wic9kVS7EUu26d0kHpoHQIDhYdp67BFBYAPFDfy30v973cpElFVkVWRdbevZovHJtpM23etk0tHCEhouPUVRiBAIBd6lSVoyMxYsRsq5SGDROdyx4ex+N4XGysUzundk7txo07yo6yo6yiQnSuugojEACwQ5L4dD6dT9+6VX2s3cJBYRRGYUlJauGYMAGFo2bUWAHps73P9j7b27Xr2bNnz549W7USfeIAYA9jslk2y+YNG9gmtoltmjBBdCJ7bA2dypVypVwZNUotHKWlonPVF1U+hfX7PW8CA9UtAnx9mQfzYB5Nmtz5Dz3IgzwKC+k1eo1ei4+naTSNpq1Zo5gUk2LKyBD9wgDUR/I5+Zx87oMP2CQ2iU2aO1d0HnvQ0EkbHruA9OK9eC/esmWD1AapDVK/+orm0ByaM2TIIz9hNEVT9IYNRfuL9hftDwmpby0iAUSQT8on5ZNLlrAZbAabsXSp6Dz28JV8JV/53XcVpypOVZzy9j694PSC0wt+/ll0rvrqkQuI15+8/uT1Jyen0smlk0snHz9Ox+gYHXN1rdp4KSnlE8onlE8YMUL9Rbl5U/QLBlCXqBfJQ0LUi+Th4aLz3N+lS+o2697e6kzFpUuiE9V3j3wNpHR26ezS2ZGR1VM4bHx8HHs59nLsdeBAj6geUT2imjUT9UIB1CVylBwlR738MulJT/qPPhKdxx6+hq/ha65ckWKlWCnW3x+FQ1sqPQJxN7gb3A29e1tbWVtZW5lMNdUQhifxJJ70zTdNujTp0qTL0KHf/PLNL9/88ttvYl42gNrJ7Te339x+GzuW5bAclhMVpU5ZOWhvU9UQCqGQa9es063TrdN9fLLKs8qzyk+dEh0Lfq/SIxBrf2t/a/+JE2u6kxjzY37Mz8ur9OXSl0tfTklRb2z6j/+o2ZcLoHZyi3aLdov285N2Sjulndu3a7ZwEBFRcTFP4Ak8YdQoFA5tq/wU1l/oL/QXLy9hiY/QETqi05U1L2te1vzgQdtFfGF5ADRMX6wv1hd7eqo9vWNjKZIiKbJRI9G57jGDZtCMW7fYUraULR01yrTNtM20LTVVdCy4v0qPINSLbrm56kW3Ll1EnwAtpaW01Ggse6PsjbI3Bg06c+bMmTNnrl0THQtApDtTza2tra2tU1LoJJ2kk9r7oMUzeSbPrKhg7syduRsMakOnPXtE54KHU+kRCF/Gl/FlGrr2cHtPHseVjisdVyYn39klFKAe6h3SO6R3SLduln9Y/mH5x8GDWi0ctr4cLJ7Fs/igIBSO2qnyU1i9qTf1zskRHfxu7G32Nnu7d2+1oCQmopBAfaL+vnfqJIVJYVJYUhLrx/qxfv/5n6Jz2cNSWApLCQlRhipDlaGbNonOA4+m8gWkM3WmzrGxooPbtYyW0bJevdQptuRk1zDXMNewtm1FxwKoDrYtgugNeoPeSExknsyTeXbsKDqXPewsO8vOvvmm8bzxvPH82rWi88DjqXQB6TK6y+guo3ftosW0mBafOyf6BO6vZ0/Hrxy/cvzq6FE3TzdPN0/tdkoDqAx1xNGiRfmI8hHlIw4epPfoPXqvWzfRueyaSBNpYni4sdRYaixdvlx0HKgaj7wMV5ZlWZZ1OsYYY+zYMfVo06aiT8genspTeeq331rmWuZa5vr5nfr01KenPv3hB9G5ACpD7T1ue5/Fx6tf+/cXncuusTSWxm7erCxWFiuLX3lFPci56FhQNR75TnS1p7Ci8Ml8Mp8cEKAeLS8XfUL2MG/mzbyfesphrMNYh7HJybZezqJzATyMrhFdI7pGNGrE5/F5fJ5tClm7hYNP49P4tB071MIxbdrtoygcdUyV3Qioy9Pl6fKef57n8Tye9/XXbCabyWY2biz6BO1aQAtowcWLFeMrxleMt+3mefGi6FgA/85gMBgMBgcH8wXzBfOFr75Sr+0ZDKJz3d/hw0VTiqYUTRk5Epuh1m1Vfie5foN+g37D0KFWV6ur1TUmRvOFxJM8yfN//9fhosNFh4s+Pif3ndx3ct/334uOBfD/fTk2bmQGZmAG2yd57bH15agwV5grzIMGYfPT+qHatiKRF8mL5EWDB1MohVLo7t339APRJNsmbb6+6rr0CxdEJ4L6SX3/fPihep/EnDmi89iDvhz1W7V1JDStMK0wrYiPV+8wHT1abSxVUiL6hO+vUyeextN4WkqKW5pbmlta166iE0H9oivVlepKly/XfOG4vSilIqcipyJn8GAUjvqp2lvaqtsvHz6sbss8ZAjNpJk088YN0Sduj20dPbvFbrFbKSm2O3tF54K6TU6UE+XE2bPJi7zIa9Ei0Xns4cf5cX7cbObP8ef4c76+aOhUv9XYbro2coAcIAd4e9Pf6G/0t7g4NovNYrOcnES/EPbwE/wEP/HTT9Z0a7o13c8ve0D2gOwBWr//BWoLW18OlsgSWeLnn9f0LtcPrR/1o34//siGsCFsiLe3cbhxuHG42Sw6FohV7SOQu9l22WTBLJgFP/88/5h/zD/W0N5ad7FtCSGRRBIlJ7uvdV/rvva//1t0LqjdbH056M/0Z/rzZ59ptnBw4sQLCqSnpaelpwcNQuGAf1fjBcRGndo6flzqLfWWeg8ZwjN4Bs+4fl30C2IPm8PmsDnt2llbWFtYW9g2bezZU3QuqF3U35tBg7Tel8P2frQ2tja2Nh46NDM4Mzgz+OxZ0blAW4QVEBtjU2NTY9O0NPIgD/Lw9eXhPJyH//qr6Fx2fUQf0Udt26pD+qQkua3cVm5bXS19oa6w9eVQf89jYrTal8O22IU5MAfmMGpUVlpWWlaa0Sg6F2iT5obM6lYNsqwWkoQENpvNZrM13HnQ1npzvXW9df2gQXjDwb+rLX05VLadJMaMUZexx8WJTgTaJnwEcjf1F9dkkhZJi6RFzz3H03k6T//lF9G57IqgCIpo1UrqJfWSeiUkuHd27+ze2d1ddCwQSx+qD9WHPvWUdaR1pHVkfLxmC0cgBVKg1cq6s+6s++TJKBxQGZobgdztzie4C9YL1gsJCepWDhru8+FBHuRRWEgZlEEZQ4ao13oyMkTHgpqh36/fr9/v4sIP8UP8UGoqnaATdEKDu0DfbujEjdzIjUFB6t52GzaIjgW1i+YLiI26aqV7d9aINWKNkpO13jBHVVTE2/A2vM2QIaZDpkOmQ+npohNB9bC1C2BJLIklpaay/qw/6+/iIjqXPczMzMy8cKHxmvGa8dr774vOA7WT5qaw7MlyynLKcsrJUQuHj496ND9fdK77a9GCXWVX2dVDh9wC3QLdAvv1E50Iqpat86U0X5ovzU9I0HrhUIWGonBAVag1BcRGnaP917/UR76+thucROe6vxYtJCYxiSUk6LiO6/jAgaITwePxOO9x3uN88+ZqB8yDB2k5LaflPXqIzmVXLMVS7Lp16vtnyRLRcaBuqDVTWPbYLlZyF+7CXZKS6EP6kD7UcJ8Pd3In95s3eQPegDcYOdK01rTWtDY5WXQseDh9L/e93PdykyZlT5Q9UfbEgQPMn/kzfw1/INhMm2nztm1KT6Wn0nPKFPWg1So6FtQNtb6A2Nj2rJLGSGOkMcnJbD6bz+Z36CA61/0VF1tft75ufX3kyCxDliHLkJQkOhH8MXWqytFRXcSxe7d6dNgw0bns4XE8jsfFxjq1c2rn1G7cuKPsKDvKKipE54K6pdZNYdmTHZEdkR1x/rxloGWgZaC3N1/FV/FVWu/r0bSp9J70nvTe3r26Ql2hrtDfX3Qi+COSxKfz6Xz61q3qY+0WDgqjMApLSlILx4QJKBxQnepMAbGxdRbk2TybZ/v42HYPFZ3r/po2pV20i3bt2yevkFfIK4YPF50IiIgY05l1Zp05MpJtYpvYpgkTRCeyy4d8yCc9vaSgpKCkYPRotXCUloqOBXVbnZnCskedeujUSX2UnKxOQXTpIjqXXXrSk76sjHVgHVgHg8H4lvEt41t794qOVd/I8+R58rxVq9gRdoQd+Z//EZ3n/s6ccdzjuMdxz8CB6R3TO6Z31PBWQFCn1LkRyN3UG/kuXWLH2XF23MdH7beQmys6l11GMpKxYUO1t3x0tO5fun/p/jVqlOhY9YW6lc5bb2m+cNz+PZZcJBfJZdAgFA4Qoc4XEBt108bLl6V8KV/K9/amxbSYFmu4r8ftQkJraA2tiYqSw+VwOXzMGNGx6ir5oHxQPvi3v6mPli0Tnccevpqv5qvz8irCK8Irwv39M6MzozOjf/pJdC6on+pNAbGxveEalDYobVDq68vf5e/ydzW8TfXtQsK2sq1s686dd/pIQJVQd1MOCGCX2WV2+eOPReexqy/1pb5Xr6qrC/39bdf6RMeC+q3eFRCbjJcyXsp46coVx2uO1xyv+fnxo/woP/rPf4rOdX+OjiyH5bCcqCh5q7xV3jpxouhEtdWdqcGf6Cf66YsvaD2tp/WSRt8PRUWUTumUPmTI72+kBRBLo2+YmmMrJBWRFZEVkX5+6tEzZ0TnsudOA6Kn6Wl6essW2ydo0blqC7dot2i3aD8/XsgLeeGOHcyduTP3Bg1E5/pjxcW8O+/Ou48YYdulWnQigH9X51dhVVbvY72P9T7Wpo3DbofdDrsTE+kYHaNj2m0YxSN5JI+0WNgNdoPdeOUVxVfxVXxt9yuAjboar08fCqZgCk5MpHW0jtY1ayY61z1ur8KzTrFOsU4ZNSrLM8szy/PQIdGxAP5IvR+B3C17QPaA7AFXr5YlliWWJQ4cSEtpKS3VboMo24iEN+PNeLPPP5ej5Cg56uWXRefSClvrYR7BI3jEgQNaLRy2DwKUSZmUGRCAwgG1AUYgD9CL9+K9eMuWDtcdrjtcj49nvsyX+Xp4iM5l1+0+D6SQQsrMmerUR2Sk6Fg1zS3NLc0trWtXpmd6pk9N1ez2/7b/X2NoDI159VVlqDJUGbppk+hYAA8DI5AHUFe7FBZamluaW5oPHkycOHENN4hSSCGFMfUP07p18gB5gDxg5kzRsWqKuktuhw7SJemSdCkhQbOFw+ZZepaenT8fhQNqI4xAKkmdEmnRgnzJl3wPHaIUSqGUvn1F57If+PYnXH/yJ//ZsxWDYlAMGl6u+ojuXLuKcYhxiDl6lFIplVK7dxedyx52lp1lZ99801hqLDWWLl8uOg/Ao3AQHaC2yc/Pz8/Pv3WrzZg2Y9qM2bFDWiOtkdZ4erIv2Zfsyz//WXS+ewNTPuUzRiVUQiVDhjhzZ+7Mr11Tz0PDI6mHZCvoUrwUL8UnJNAlukSXtLvoQRURoVxWLiuXFy0SnQTgcaCAPKIrCVcSriSUl7dp3aZ1m9a7djn80+GfDv+0dRzs3Fl0vnvYCome9KQfMqR9m/Zt2rcpKsrPzc/Nz619rXbVLUeaNqXn6Dl67sAB+p6+p+81PBIcS2Np7ObNyjZlm7JtxgzRcQCqAq6BPKbTC04vOL3g5k310fDh6lSRhhtE3b5GorbaXbNGDpAD5IDa80m4R1SPqB5RDRvy2Xw2n71rFyVREiV5e4vOZVckRVJkTIxLtku2S/b06epBzkXHAqgKGIFUEXVKqLzcucS5xLlk1y4+j8/j8/r0YSfYCXZCuz2yWQErYAV+fs6hzqHOoRZL/t78vfl7jx0TnetuBoPBYDA4ONzYd2PfjX3bt7MMlsEytL7J5OHDRa2LWhe1Hjfu+AfHPzj+QXm56EQAVQkX0avJndanTcqalDWJjWWD2WA2WPsNo9T7Ed5+2+Rh8jB5aGVTQcZks2yWzRs3MgMzMMO0aaIT2cMX8UV8UVpahbnCXGEeNOj3I1SAugUFpJp1jega0TWiUaMW+hb6FvroaJpFs2jWiBGicz0I/4J/wb9YtcrkanI1ub7+uqgc8jn5nHzugw/YJDaJTZo7V/TrYg9fypfypdnZlmGWYZZhPj625d+icwFUJ1wDqWa5IbkhuSG3bpXkleSV5I0bx2fymXym9htEsalsKpu6cKF8Wj4tn37vvZr++bJJNsmm0FDNF46VfCVf+d13FTkVORU5gwejcEB9ghFIDbNdBG6yqsmqJqt27lSPjh4tOtcDJVESJYWFKS2VlkrL6mu0pC7LDQlRO0eGh4s+7fu7dEm9sdTb29a4THQigJqEEUgNO/fiuRfPvVhWpv7hefFF2yod0bkeyI/8yG/BAvUP/OrVVf306rLcKVPUZcYffST6dO3qR/2o348/sqVsKVvq44PCAfUZVmEJoq7aslr7F/Yv7F/49dfXrl27du1a167qv/bsKTqfXYwYMU/P9lPbT20/tUWL/NT81PzUw4cf9enuNMjqQ32oz7ZtbCPbyDZqsC8HJ068oEDyl/wlfz8/Y4AxwBjw3XeiYwGIpL03aj0THR0dHR1tsbi4uLi4uEyeTJtpM23etk10rgdh8Syexc+ZI+fKuXLuJ5/cPvrQU6K6Ql2hrtDfX9op7ZR2bt9+p8+JxvAMnsEzrl+3NrY2tjYeOjQzODM4M1jDHSwBahCugWiM7X6HC/0v9L/Q/4sv1Fa2kyaJzvVA0RRN0Rs2KC6Ki+Jiu9Paar37P9MX64v1xZ6efC6fy+cePqxuX/7EE6Lj342f5Cf5yZISJjGJSc8/rzCFKezIEdG5ALQEBUSjbIXEbDabzWbbLq1TpojO9UCcOPHPPlOvDQQGqgetVneDu8Hd0Lu3tbW1tbV1SgqdpJN0smVL0XH/mO2GvzFj1O3w4+JEJwLQIs1NGYDq3Llz586d41y9VrJ3r3OKc4pzSqdOFEMxFOPmJjqfXYwYMVluP6/9vPbzOnZ0/sX5F+dfvvvOusS6xLokMZF9zj5nn7duLTrmPQIpkAKtVlbMilnxpElKvBKvxO/eLToWgJZhBFKrSJK6Cmr9evUPtW1vJQ273aKVjGQkY8OGouPc4/Z293wYH8aHBQaaRplGmUZt3Cg6FkBtgAJSKzEmvyO/I78TEcH2sX1sX3Cw6ES1FTMzMzMvXGi8ZrxmvPb++6LzANQmmMKqpfKP5B/JP3LokLOzs7Ozc6tW6tE+fUTnql1CQxWzYlbM774rOglAbYRlvLUa5+pF3r//nQ7SQTqo4RvwtGI8jafxn3yivm5LloiOA1CbYQqrjtGV6kp1pcuXkxd5kVft6fNR7W7fX6P0VHoqPW2r2e5dZgwADw8jkDpGaaw0VhovXsw38o18I6ZmeByP43Gxsc2eafZMs2emTlWPonAAVAUUkDrKJJtkk/zWW/QqvUqvaqWvRw0KozAKS0pyaufUzqndhAlH2VF2lFVUiI4FUJeggNRxSpASpAS9/ba6jHbpUtF5qh0nTjwjo6SgpKCkYPRotXCUloqOBVAXoYDUE+pWHO+8QyEUQiHiGkRVrzNnHGMdYx1jn39e3fX4xg3RiQDqMizjrWfy9+Tvyd/zzTdPlj5Z+mRpSQm1olbUSvutdu3yIi/yys2VHCVHydHX9+T0k9NPTr96VXQsgPoAq7DqOfmYfEw+Nn8+m8PmsDlhYaLzPCy+mq/mq/PyLAMtAy0Dvb3VToAXL4rOBVCfYAqrnjMNMA0wDVi9mubSXJo7b57oPA/Ul/pS36tX2Xw2n83390fhABAHIxD4HV2sLlYXGxREcRRHcZ98QgoppDx8n4/qVVSkfvX1VW8ENJlEJwKoz3ANBH4nf0f+jvwdRmN73p635/n5LIgFsaBhw8QWkuJi3p13592HDTPFm+JN8RkZol8nAMAIBB5AjpVj5dhXX2VX2BV25dNPaT2tp/U10HL29i6+TMd0TDd6tPE142vG1w4eFP16AMD/QwGBh6I7qDuoOzhtGl2my3R5w4bqKiQ8kkfySItFHfn89a9qY6roaNHnDwD3QgGBSpG7yl3lrlOn0nbaTts3bqyqXua2wiEtk5ZJy6ZONe437jfu//JL0ecLAPbhGghUSv6v+b/m/5qd3b5N+zbt22RksLFsLBvbvz+lURqlVb5FLU/lqTz122+l36TfpN8MBuMc4xzjnL17RZ8nADwYlvHCIzGtMK0wrYiPbza+2fhm47t3V48uWcJX8pV85Xff2fu+3//7kiXXP7v+2fXPevUy9jT2NPY8elT0eQHAw8MUFlQL1zDXMNewJ55o2L1h94bd27UryynLKcu5cuX0gtMLTi+4eVN0PgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAO/4PSBxbMqgmA24AAAAldEVYdGRhdGU6Y3JlYXRlADIwMTctMTItMTVUMTU6NTc6MjcrMDg6MDCiEb4vAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE3LTEyLTE1VDE1OjU3OjI3KzA4OjAw00wGkwAAAE10RVh0c3ZnOmJhc2UtdXJpAGZpbGU6Ly8vaG9tZS9hZG1pbi9pY29uLWZvbnQvdG1wL2ljb25fY2sxYnphMHpqOWpqZGN4ci9jaGVjay5zdmfbTpDYAAAAAElFTkSuQmCC'); + background-size: contain; + content: ' '; + inset: 0; +} + +.icon-close::before { + position: absolute; + z-index: 9999; + display: block; + width: 16px; + height: 16px; + margin: auto; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAYAAAD9yHLdAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAAAAAAAA+UO7fwAAAAlwSFlzAAAASAAAAEgARslrPgAADwRJREFUeNrt3V1sU+cZwPHndTAjwZ0mbZPKR/hKm0GqtiJJGZ9CIvMCawJoUksvOpC2XjSi4kMECaa2SO0qFEEhgFCQSqWOVWqJEGJJuyYYWCG9QCIOhQvYlgGCIFmatrVSUhzixO8ujNM1gSZOfPye857/7wYlfPg5xj5/n/fExyIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABATizsWti1sCs/v6y0rLSsdMaMZ/Y8s+eZPZMnm54LQO6kn/fp/UB6v2B6LrdRpgcwZf7e+Xvn7505MxAIBAKBrVt1ja7RNdXVaqlaqpbOmTP0z+u9eq/ee/euFEqhFH7ySeCjwEeBj+rr299of6P9jb//3fT2AMhcWVlZWVnZ3Ln6uD6uj2/eLF3SJV1VVapW1ara6dOH/nn9hf5Cf3HzpupW3aq7qSl5LHkseay+/nLt5drLtbdvm96eXPNZQJQqn1Q+qXzS73+vN+gNesObb0q7tEv7xImZ/kv6kr6kL/X3q0PqkDpUXx/aFNoU2rRz53l1Xp1X/f2mtxTAcMv1cr1cT5jQfb37evf1ujrpkR7p2bxZ1agaVZOXl/E/WCM1UnP/vv5cf64/f+utjg87Puz4cPfu1G9qbXp7neaTgChVeqD0QOmBP/5RHVPH1LHf/CbrN1EplVLZ2iqt0iqtv/51NBqNRqP37pnecgDpI42CgtTz9OTJ1PO0sjLbt6PX6/V6/Z/+1LG5Y3PH5g0bHnzX2pBkXlyPKTtadrTs6Ouvq/fV++r9LVscu6EbckNuPPGEhCUs4UWLpsanxqfGT5yIxWKxWCyRMH0/AH40GI6whCXc3Cyn5bScDoeduj11RV1RV559dkrFlIopFX19sauxq7GrbW2m7wenBEwP4JT0OY7UV6+/nrMbjkhEIitWSIVUSEVLS0ljSWNJYyhk+v4A/GQwHHtkj+xpahp8XuaImqwmq8m7di2oXlC9oHr2bNP3h1OsDUhgfWB9YP2WLdIgDdLwgx/kfICzclbOLluW35Hfkd/x5z8PPqABOGbYEcd22S7bKypyPsiDc6v9df11/XWvvWb6fnGKtQHRj+nH9GOrV5ueY/CVz4MHNCEBsm9YOHJ8xPEo6og6oo64YD/k1PaZHiDbvruD/uYb0/MMUyEVUtHWFi+Pl8fLf/Wray9ee/Haiz09pscCvGjYUpWpI44RBE8FTwVPFRRcLLxYeLEwHjc9T7ZYdwSi2lSbavvxj03P8UgsbQHj5pqlqlFK9iZ7k70u3i+NkXUB6Tvcd7jv8H//a3qOEXGyHciY6ZPjYzXw0sBLAy95YL+UIeuWsNJK75feL71/545arBarxYWFpucZUVjCEj53LvWEqK7mfSTAt9x6jmNEi2WxLL59O3ooeih6aNYs0+Nkm3VHIIO6pEu6Pv3U9Bijxsl2YBjPhiOtUAql0EP7oQxZG5C8SXmT8ibt35++5IjpeUaNpS3As0tVabpBN+iGgQE5Lsfl+KFDpudxirUBuTT90vRL0//xj/S1qkzPkzFOtsOHvHZy/FFUsSpWxfv2pZai//Y30/M4xfpLmRR/VvxZ8Wd//Wvf7b7bfbd//vPBS454xU25KTdnz+YSKbCZ55eq0h5cE2/OB3M+mPPBb3977dq1a9eu2XstLGtPog+Vvp5/X1tfW19bU5N6V72r3v3FL0zPlTHeRwKLeOV9HCPaLbtl94UL8a/jX8e/fv55vzwvfROQNEICmEc47OC7gKQREiD3CIddfBuQNEICOI9w2Mn3AUkjJED2EQ67EZAhCAkwfoTDHwjIIxASIHOEw18IyAgICTAywuFPBGSUCAkwHOHwNwKSIUICEA6kEJAxIiTwI8KB/0dAxomQwA8IBx6GgGQJIYGNCAe+DwHJMkICGxAOjAYBcQghgRcRDmSCgDiMkMALCAfGgoDkCCGBGxEOjAcByTFCAjcgHMgGAmIIIYEJhAPZREAMIyTIBcIBJxAQlyAkcALhgJMIiMsQEmQD4UAuEBCXIiQYC8KBXCIgLkdIMBqEAyYQEI8gJHgYwgGTCIjHEBKIEA64AwHxKELiT4QDbkJAPI6Q+APhgBsREEsQEjsRDrgZAbEMIbED4YAXEBBLERJvIhzwEgJiOULiDYQDXkRAfIKQuBPhgJcREJ8hJO5AOGADAuJThMQMwgGbEBCfIyS5QThgIwICESEkTiEcsBkBwXcQkuwgHPADAoKHIiRjQzjgJwQE34uQjA7hgB8REIwKIXk4wgE/IyDICCFJIRwAAcEY+TUkhAP4FgHBuPglJIQDGI6AICtsDUl+XX5dfl0ySTiA4QgIsmrwlXpYwhJubpaIRCSyYoXpuTIWlrCEz50b/Nrr2xGRiESqq6PRaDQavXfP9FiwAwGBI6w5IvEqjjiQAwQEjiIkOUY4kEMEBDlBSBxGOGAAAUFOEZIsIxwwiIDACEIyToQDLkBAYBQhyRDhgIsQELgCIRkB4YALERC4CiEZgnDAxQgIXMn3ISEc8AACAlfzXUgIBzyEgMATrA8J4YAHERB4inUhIRzwsIDpAYBMJNYm1ibWKqUeV4+rx5X3XwCdkTNyxoLtgC/xwIUnWPN5HI/i8Ge2A04gIHA168MxFCGBhxAQuJLvwjEUIYEHEBC4iu/DMRQhgYsRELgC4RgBIYELERAYRTgyREjgIgQERhCOcSIkcAECgpwiHFlGSGAQAUFOEA6HERIYQEDgKMKRY4QEOURA4AjCYRghQQ7kmR4AdhkMR1jCEm5uliNyRI54MBxhCUv43DkpkiIpunVLbspNuTl7tumxRu2W3JJbM2cGC4IFwYKFC6fGp8anxk+ciMVisVgskTA9HuzAxRSRFcOOOCISkciKFabnylj66ril8dJ46Zo1wY3BjcGNVVV6m96mt505Y3q8jKX/HyqkQipaWkoaSxpLGkMh02PBDixhYVysWaoa4bLq1lxGnqUtZBEBwZj4JRxDERLgWwQEGfFrOIYiJAABwSgRjocjJPAzAoLvRThGh5DAjwgIHopwjA0hgZ8QEHwH4cgOQgI/ICAQEcLhFEICmxEQnyMcuUFIYCMC4lOEwwxCApsQEJ8hHO5ASGADAuIThMOdCAm8jIBYjnB4AyGBFxEQSxEObyIk8BICYhnCYQdCAi8gIJYgHHYiJHAzAuJxhMMfCAnciIB4FOHwJ0ICNyEgHkM4IEJI4A4ExCMIBx6GkMAkAuJyhAOjQUhgAgFxKcKBsSAkyCUC4jKEA9lASJALBMQlCAecQEjgJAJiGOFALhASOIGAGEI4YAIhQTYRkBwjHHADQoJsICA5QjjgRoQE4xEwPYDtbAtH4kriSuIKT1BbXCy8WHixMB6fuGzisonLVq/W2/Q2ve3MGdNzZeysnJWzy5blt+e357f/5S8ljSWNJY2hkOmxbMcRiENsDcfV7Ve3X93+zTemx4IzOCJBJghIlhEO2ICQYDQISJYQDtiIkOD7EJBxIhzwA0KChyEgY0Q44EeEBP+PgGSIcACEBCkEZJQIBzAcIfE3AjICwgGMjJD4EwF5BMIBZI6Q+AsBGYJwAONHSPyBgDxAOIDsIyR2831ACAfgPEJiJ98GhHAAuUdI7OK7gBAOwDxCYgffBIRwAO5DSLzN+oAs18v1cj1hQk95T3lP+aefpr77y1+anitje2SP7Dl7NhW+1auj0Wg0Gr13z/RYQDYMvsALS1jCzc0SkYhEVqwwPVfGKqVSKltbQ++E3gm9U1V1Xp1X51V/v+mxnGL9B0p1X+++3n29ri71FeEA3GjwcR2RiESqq1MhOXfO9FwZa5VWaa2s7DnYc7Dn4O7dpsdxmrUBKX+7/O3yt3/2M5krc2Xupk2m58lYeqkqmogmomvWEA74QfpxHtwY3BjcWFXl1U9I1Iv0Ir1o69b53fO753fPm2d6HqdYG5BkXjIvmbd1q3pOPaeemzDB9Dyjlj7i2Ck7ZeeqVZzjgB+lP2o3dU5kzRqvHZGoGlWjavLyAg2BhkDDa6+Znscp1gZEzVQz1cyqKtNzjBpLVcAwnl/aOi7H5biH9kMZsi4gCzoXdC7o/OEPZZ/sk33TppmeZ0QsVQEj8vbS1owZJY0ljSWNoZDpSbLNuoAMrBtYN7DuRz8yPceIWKoCMubVpa3Q/ND80HwP7JcyZF1ARIkS9e9/mx7jkTjiAMbNa0ckgUmBSYFJ//mP6Tmyzdr3gZTGS+Ol8Rs31FK1VC2dM8f0POkjjuCTwSeDT1ZXp19JmR4LsIFr30eyQ3bIjs7O6AvRF6IvFBebHifb7DsCeUA1qAbV0Nxseg7CATjPrSfb9VP6Kf2UC/ZDDrE2IMlkMplM7t8vNVIjNffv53yAIUtVhANwnluWtvRhfVgf7u1VL6uX1csHDpi+X5xibUAu116uvVx7+3bqqz/8IWc3nD7imBecF5y3ciUnx4HcM36yPSlJSb71VrQj2hHtuHPH9P3hlDzTAzgt1hRrijW1tU3ZMWXHlB1z5qgr6oq68uyzWb+h/bJf9re0BIuCRcGitWs54gDMi8VisVgskZganxqfGj9xInWtqvJyuSE35MYTT2T79vRJfVKfPHas4+mOpzuerq01vf1Osz4gabGWWEus5dSpaV9N+2raV4mE7JJdsmvJEmmXdmnP/J3q+pK+pC/190undErn3r1FkaJIUeR3vzv9yulXTr/S12d6ewF8Kx2S4gvFF4ovfPxxX29fb19vQYE+qo/qowsWqPfUe+q9QMYrMumlKlklq2TVm29+Nxxam95up1n7U1gjKSstKy0rnTFDr9Qr9cotW1SLalEtq1enfgy4qOjhf+vOHVkn62TdJ58M3B24O3C3vv7Lg18e/PJgZ6fp7QGQufQ18/QpfUqf2rw59d3nn0/9OmPGsL+wRJbIkn/+U7+qX9WvNjUFZgVmBWbV17cXtBe0F3R1md6eXPNtQB4l/fkEiTWJNYk1P/1p+n0lvF8D8I/BHwvWokX/5CehaCgaiv7rX6nLs/f2mp4PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtvsf2vlfs7i0WI4AAAAldEVYdGRhdGU6Y3JlYXRlADIwMTctMTItMTVUMTU6NTc6MjcrMDg6MDCiEb4vAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE3LTEyLTE1VDE1OjU3OjI3KzA4OjAw00wGkwAAAE10RVh0c3ZnOmJhc2UtdXJpAGZpbGU6Ly8vaG9tZS9hZG1pbi9pY29uLWZvbnQvdG1wL2ljb25fY2sxYnphMHpqOWpqZGN4ci9jbG9zZS5zdmdHkn2WAAAAAElFTkSuQmCC'); + background-size: contain; + content: ' '; + inset: 0; +} + +.icon-right::before { + position: absolute; + z-index: 9999; + display: block; + width: 16px; + height: 16px; + margin: auto; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAYAAAD9yHLdAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAAAAAAAA+UO7fwAAAAlwSFlzAAAASAAAAEgARslrPgAAJ4pJREFUeNrt3XtcVXW6P/Dn2VwCBxUzNbnkkXRSGzXW2huQRLyMIqKRJF7Q1CkrDS+VGp3Gy9g5YzI6qVsNfTmlqGmipQiIiJqAcnOvhaKRHidshoatpKaBogL7OX+s6Mz8flO5CfzutXne/+zXWhR8QOXZ3+93Pd8vAHuAEKW10lpp7dix0mXpsnR5/34pX8qX8r/7TpZlWZaJGl//9f6+fY3/X+PnEf2dMMYY/yJqYcbbxtvG2/7+lEM5lLN7NyyCRbBowICmfj56m96mt/PzDZGGSEPkxImWNpY2ljYVFaK/T8ZY6+MiOoCzMn1t+tr09a9/TQfpIB0sLITlsByW9+r1Sz8v5mEe5vn7Q3toD+0nT/Y77Xfa73ROTuWNyhuVNyorRX/fjLHWg0cgzUybcmrThvIoj/JUFcMwDMOeeKLFvmA8xEN8TQ2sh/Ww/rnnFFVRFfXwYdE/B8aY8zOIDuBsqDf1pt6vvdbihaPRBtgAG7y8wAQmMKWlyflyvpw/aZLonwNjzPlxAWlWiOiN3ugdH//Av7QFLGBxd4dzcA7O7dgh75H3yHvmzBH9E2GMOS+ewmomplhTrCn2qads5bZyW3lJieg8jWgADaABf/yjul5dr65fvPj7uyQ6F2NM/3gE0kxsb9vetr3do4foHP8vLMACLPj977W1mS1bwimcwsnVVXQuxpj+cQFpLt/Ct/BtmzaiY/y0adNqltYsrVmakqIVEg8P0YkYY/rFj/E2E5+zPmd9znbpAggI+PzzovP8qItwES727n23w90OdzuEhfl86fOlz5f79lmtVqvVeveu6HiMMf3gEUgzqVfqlXqluFi7qqsTnefnYCImYmJ4OOVSLuWeONF/Zv+Z/Wf6+orOxRjTD15Eb2ZSlVQlVWVkYCRGYuSoUaLz3C86QSfoRHk5lVAJlURElISWhJaE/vWvonMxxhwXj0CaGT1Lz9KzS5eCDDLI+nnaCQfiQBwYEID1WI/1J05oi+6SJDoXY8xx8RpIM7tccbnickVlZdekrkldk4gwAzMwY8gQ0bnuF2ZhFmZ5eWkd7pMn+1T4VPhUKIq2RvLll6LzMcYcB09htShE6YJ0Qbqwdi3GYRzG6bCxbxbMgll372ojqilTlEAlUAncu1d0LMaYeDyF1aKI1CfUJ9Qn5s6FuTAX5r71lt6mtiAJkiDpoYeojuqo7uOP5VQ5VU6dOVN0LMaYeDwCecCkFClFSpk+HcbBOBi3eTOa0IQm/TX20RbaQlsSE9V+aj+131tvic7DGHvwuIAIIp+Xz8vno6OpJ/Wknrt2YRAGYZCnp+hcdpsAE2DC++8rbypvKm82TtHZbKJjMcZaHhcQwYxnjWeNZ8PDaTpNp+mpqdrd9u1F52qa/fu9LF4WL8ukSTmYgzl4547oRIyxlsNrIIJZ+lr6Wvrm5GBv7I29Bw6EN+ANeOMf/xCdq2mefbbGWGOsMR48GHQx6GLQxXbtRCdijLUcfozXQVSWVpZWllZV+df51/nX7dtH8RRP8aNGwQk4ASc6dhSdzz7du9NVukpXR4zoFNMpplPM/v1Xsq9kX8m+dUt0MsZY8+ERiIMpTitOK067dMm1zrXOtS4sTLurqqJz2e04HIfjsuw623W26+yCgsD8wPzAfMfbrZgx1nRcQBxUUVxRXFHclSu1CbUJtQnh4dpd/R1V+0OHuxGNaMzLazw3RXQuxtgvx4voOtEnpU9KnxR3d88yzzLPsu3bIQ3SIG38eNG57BYEQRB04wZVUzVVP/OMukPdoe7IyxMdizFmPx6B6ETZ+LLxZePv3Qv4PODzgM/j4mg37abdGzeKzmW3YiiGYm9vHIyDcXB2tlwil8gl48aJjsUYsx+PQHROTpaT5eSEBDCDGcwrVojOYy9KoiRKamgAK1jBOmuWGq1Gq9GbN4vOxRj7eVxAnISUKWVKma++ihVYgRXr1sEm2ASbDPoZYTZu8bIJNsGmd95RUEEF//AH0bEYYz9OP79g2E9SI9VINfL996mWaql23DjaQBtog44a+RRQQEEEIxjBuHSptgml2ax9UEeFkLFWhEcgTko7z2PIECqiIiravx+DMRiD9drYt3MnEBDQ9OmKqqiK6vgnPjLWGvA7OyelKIqiKJ99pj31NHQovAavwWtVVaJzNU1cHKyCVbAqM/Ppjk93fLpj27aiEzHGeATSahjTjenG9IAAOkyH6XBWFpyEk3BSf419tISW0JJTp2wdbB1sHaKiTg86Pej0oG++EZ2LsdaIC0grozXyPfpow7SGaQ3TMjNxKS7FpTps7CMgoPPntYuICG1q6+9/Fx2LsdaEC0gr1Z/6U3/y9nZNcE1wTThwAI7CUTjauHWK3litVEEVVDFypFqlVqlVpaWiEzHWGvAaSCt1Bs/gGbxx46bfTb+bfsOHUxqlUZpej6rt2hVX4kpcefy4sYOxg7HD00+LTsRYa8AjEAYAALGxsbGxsS4u5XK5XC4nJcEe2AN7XnpJdK6muX1bex0/XnuYICNDdCLGnBEXEPZvIMokk0xLlzb2ZYhOZK/GDne8htfw2iuvKJFKpBL5wQeiczHmTPg8EPZvWZdZl1mXHT/uY/Yx+5ivX4cn4Ul4MiLih4Y/B4cZmIEZBgPchJtwc8wY33Lfct/y2trKO5V3Ku+cPCk6H2POwOF/ETDHoDUmxsVpV1u3aq9ubqJzNY3ZrE1tvf66ds1nuDPWFFxAmF0C9wTuCdwzbBj6oi/67tuHc3AOztFfYx9Npak0dft2TMZkTH7xRe5wZ8x+XEBYk5i6m7qbuptMtlG2UbZRGRlQCIVQ2KmT6Fx2i4RIiExPh0zIhMwJE7SRSeMiPGPsp3ABYb+INrXVq5d2lZWlvT72mOhcdiMgoKIi7WL0aG1EcvWq6FiMOTLuA2G/iPaOvbEjPCQEBsEgGKTDRj4EBAwOhkWwCBbl5BhvG28bb/v7i47FmCPjEQhrVn379u3bt2+HDm55bnlueWlpOAyH4TAdNvaFQiiE/u1v2Bk7Y+eICMtiy2LL4gsXRMdizJHwY7ysWVVVVVVVVd2545Ptk+2T/fHH2t3GvbZ+/WvR+e5bBVRAhbc3zaJZNCsu7lG3R90edcvLu6xcVi4rX38tOh5jjoCnsFiLaFyMDggICAgIiI6mPbSH9uivkQ/n4Tyc9/DDBjSgAbOzA/MD8wPzR44UnYsxR8BTWOwBQpTmS/Ol+StW4HE8jsfffFN0IrsZwQjGe/dgGkyDadOnK6FKqBK6a5foWIyJwFNY7IGyFlgLrAVHjnTd3nV71+03buDj+Dg+PmKEXjrcoRIqodLFBaqgCqpiYnzAB3ygpsZqtVqt1oIC0fEYe5C4gDAhrNus26zbiop8yZd86dIlqIEaqBk9Wvuoi+P/vbSCFayNBW/EiK5ZXbO6Znl6WpOsSdako0dFx2PsQXD8d3ysVZCWS8ul5aNH4yf4CX6ye7d2t00b0bmaJjnZy+Jl8bLMmJGDOZiD9fWiEzHWEriAMIciS7IkS8HB2lV6utaf8cgjonPZi+IpnuIPHHAf7j7cffjEiYX+hf6F/rW1onMx1pz4KSzmULQO8KKihjUNaxrWhIdTPuVTfkWF6Fz2wg24ATc888y9gnsF9woyM7XC2L696FyMNScuIMwhnR50etDpQWVltI7W0bqwMMqjPMrTXyMfJmIiJoaHUy7lUu6JE/1n9p/Zf6avr+hcjDUHnsJiuhBSEVIRUvHww3Xn6s7VnUtP17YcGTBAdC57USIlUuKlS7YDtgO2AxERp82nzafNFy+KzsVYU/AIhOmCtoZw/bpWQIYPh9WwGlYfOiQ6l70wARMwoXt3wzjDOMO4vDxtM0pJEp2LsabgAsJ0pXRh6cLShbdu1V6uvVx7OTqaUimVUvXXyIev4+v4epcuEA/xEJ+To62RjBghOhdj9uApLOYEELVfwCtXak9tzZ8vOpHdvu9wJ5lkkp9/Xn1ZfVl9OSVFdCzGforjN2wxdh+0TvDDh31W+KzwWXHnDtRDPdQPG/avDX8OrLHDfQbMgBkxMT6jfUb7jK6qsn5s/dj6scUiOh5j/47j/8NirAm0tYVp0+gUnaJTf/kLmtCEJldX0bnsRVtoC21JTFT7qf3Ufm+9JToPY/+MCwhzavJ5+bx8PjqaelJP6rlrFwZhEAZ5eorOZbcJMAEmvP++8qbypvLmnDnaTZtNdCzWunEBYa2C8azxrPFseDhNp+k0PTVVu6vDxr4oiIKoffu8lnkt81oWF6dtlXLnjuhYrHXip7BYq2Dpa+lr6ZuTg72xN/YeOBDegDfgjX/8Q3Quu2VABmSMHVtjrDHWGA8eDLoYdDHoYrt2omOx1okX0VmrUllaWVpZWlXlX+df51+3b5+2Z9WoUXACTsCJjh1F57NP9+50la7S1REjOsV0iukUs3//lewr2Veyb90SnYy1DjwCYa1ScVpxWnHapUuuda51rnVhYdpdVRWdy27H4Tgcl2XX2a6zXWcXFGgnJvboIToWax24gLBWrSiuKK4o7sqV2oTahNqE8HDt7uHDonPZCwfiQBwYEIBGNKIxL88Ua4o1xTaeRc9Yy+BFdMb+SZ+UPil9UtzdPcs8yzzLtm+HNEiDtPHjReeyWxAEQdCNG1RN1VT9zDPqDnWHuiMvT3Qs5lx4BMLYPykbXza+bPy9ewGfB3we8HlcHO2m3bR740bRuexWDMVQ7O2Ng3EwDs7OlkvkErlk3DjRsZhz4REIY/dBTpaT5eSEBDCDGcwrVojOYy9KoiRKamjQOvNnzVKj1Wg1evNm0bmYvnEBYcwOUqaUKWW++ipWYAVWrFsHm2ATbDLoZyQvgwwykZb7nXcUVFDBP/xBdCymT/r5i8+YA1Aj1Ug18v33qZZqqXbcONpAG2iDjhr5FFBAQdQ2b1y6VLogXZAumM3aB3VUCJlD4BEIY7+AtufWkCFUREVUtH8/BmMwBuu1sW/nTiAgoOnTtaOF6+pEJ2KOjd9xMPYLKIqiKMpnn2lPPQ0dCq/Ba/BaVZXoXE0TFwerYBWsysx8uuPTHZ/u2Lat6ETMsfEIhLFmZEw3phvTAwLoMB2mw1lZcBJOwkn9NfbRElpCS06dsnWwdbB1iIrSzqj/5hvRuZhj4QLCWAvQGvkefbRhWsO0hmmZmbgUl+JSHTb2ERDQ+fPaRUSENrX197+LjsUcAxcQxlpQf+pP/cnb2zXBNcE14cABOApH4Wjj1il6Y7VSBVVQxciRapVapVaVlopOxMTiNRDGWtAZPINn8MaNm343/W76DR9OaZRGaXv3is7VNF274kpciSuPHzd2MHYwdnj6adGJmFg8AmHsAYqNjY2NjXVxKZfL5XI5KQn2wB7Y89JLonM1ze3b2uv48drDBBkZohOxB4sLCGPCIMokk0xLlzb2ZYhOZK/GDne8htfw2iuvKJFKpBL5wQeic7EHg88DYUwg6zLrMuuy48d9zD5mH/P16/AkPAlPRkT80PDn4DADMzDDYICbcBNujhnjW+5b7lteW1t5p/JO5Z2TJ0XnYy3L4f+CMtaaaI2JcXHa1dat2qubm+hcTWM2a1Nbr7+uXfMZ7s6GCwhjDihwT+CewD3DhqEv+qLvvn04B+fgHP019tFUmkpTt2/HZEzG5Bdf5A5358IFhDEHZupu6m7qbjLZRtlG2UZlZEAhFEJhp06ic9ktEiIhMj0dMiETMidM0EYmjYvwTK+4gDCmA9rUVq9e2lVWlvb62GOic9mNgICKigwHDAcMB6KiTvmd8jvld+2a6FisabgPhDEd0N6xN3aEh4TAIBgEg3TYyIeAgMHBtmJbsa04NzfoYtDFoIt+fqJjsabhEQhjOtS3b9++fft26OCW55bnlpeWhsNwGA7TYWNfKIRC6N/+hp2xM3aOiLAstiy2LL5wQXQsdn/4MV7GdKiqqqqqqurOHZ9sn2yf7I8/1u427rX161+LznffKqACKry9aRbNollxcY+6Per2qFte3mXlsnJZ+fpr0fHYT+MpLMZ0rHExOiAgICAgIDqa9tAe2qO/Rj6ch/Nw3sMPG9CABszODswPzA/MHzlSdC7203gKizGngyjNl+ZL81eswON4HI+/+aboRHYzghGM9+7hLbyFt6ZNs+yw7LDsaBxpMUfBU1iMOSFrgbXAWnDkSNftXbd33X7jBj6Oj+PjI0bopcMdKqESKl1coBt0g27PPecDPuADNTVWq9VqtRYUiI7HNFxAGHNi1m3WbdZtRUW+5Eu+dOkS1EAN1IwerX3UxfH//VvBCtbGgjdiRNesrlldszw9rUnWJGvS0aOi47V2jv9OhDHWbKTl0nJp+ejR+Al+gp/s3q3dbdNGdC67xUAMxGzd6vW219teb7/0Ug7mYA7W14uO1dpwAWGsFZIlWZKl4GDtKj1d68945BHRuexFGZRBGamp7nXude51kyYV+hf6F/rX1orO1VrwU1iMtULanlRFRQ1rGtY0rAkPp3zKp/yKCtG57IVRGIVR0dH3Cu4V3CvIzNQKY/v2onO1FlxAGGvFTg86Pej0oLIyWkfraF1YGOVRHuXpr5EPEzERE8PDKZdyKffEif4z+8/sP9PXV3QuZ8dTWIyxHzyV+1TuU7mdOhm+NXxr+DYjA9/Bd/Adk0l0LnvRCTpBJ8rLaRgNo2FhYSX5Jfkl+ZWVonM5Gx6BMMZ+oI1Ivvnmzt07d+/cHTpUu3v4sOhc9sKBOBAHBgQYFhsWGxbv3dsnpU9KnxR3d9G5nA2PQBhjP6rxF69HqEeoR+jWrRiN0Rg9aZLoXE3z6qta535SkugkzoILCGPsPhkM0gXpgnRhzRqMwziMmzNHdKL7thAWwsKvvlImKhOVid27i47jLLiAMMbsJifLyXJyQgKchJNw8t139dLhjs/is/hsr16862/z4DUQxpjdlGnKNGVaYiJFURRFvfIKJVESJTU0iM71s76Bb+Cb3/xGdAxnwQWEMdZkarQarUZv3ky9qTf1Hj8eXoFX4BWbTXSuH0PP0rP07K9+JTqHs+ACwhhrstjY2NjYWBcX3ISbcFNUFGyCTbDJ4Li/VxbCQljIW540F8f9g2aMOSztjPY2bb7c8OWGLzccOIC7cBfueuEF0bl+ViqkQuqNG6JjOAtX0QEYY/rReJQuHaWjdFRHR+nKIINMVLerblfdLotFdBxnwQWEMfazgi4GXQy66OfXcLbhbMPZrCwYBsNgWJ8+onPdL/oT/Yn+lJ9f6l3qXepdVSU6j7PgAsIY+1HaVFWvXg0TGyY2TMzK0u4+9pjoXPYypBhSDCl//KPoHM6G10AYY/8fU3dTd1N3kwlCIARCcnO1u/orHPQcPUfPbdpkednysuXlzEzReZwNj0AYYz+Q3pbelt6OiLBdt123Xf/kEyiEQijU32OvFE/xFH/gwHc139V8VzNvnug8zsrhO0cZYy1Pm6qKi9Outm7VXt3cROeyF31Kn9Kn27bhWByLY2fM0M49qasTnctZ8RQWY62Ysaexp7Hn7NlaA+D27dpd/RUOjdmsdlO7qd2mT+fC8WC4iA7AGHvwftjL6jSchtPvvaeXvaz+7xvQHssld3In94QE9Zh6TD22eLHoWK0Nj0AYawUaO8blcrlcLt+0CcxgBvOKFaJz2YtO0Sk6VV+P5/E8np8xQ/1U/VT9dOVK0blaK/2842CM2a2HuYe5h/mhh9pvbb+1/dbt2wEBAWNjRedqmtu3tU7y2FjFT/FT/A4eFJ2oteMRCGNOSDsIysurXVy7uHZxaWm6LRxzYS7M/fZbLMdyLB8xgguHY+ERCGNOJHhn8M7gnV261I2pG1M3JjMTB+NgHBwYKDqX3QbAABhQWQn5kA/5I0dqi+Jnz4qOxf4Vj0AYcwJBY4LGBI3p3r3erd6t3i0vT7eFIwzCIOyLL7TCMWAAFw7HxiMQxnTMOMU4xTjlN78hb/Im76wsKIACKPDxEZ3LXrSEltCSU6dwGS7DZaNGaYXj6lXRudhP4050xnRIJplkGjyYjGQk4/792t327UXnshfNp/k0/8gRzxc8X/B8ISbm5LWT105eq64WnYvdH57CYkxH5PPyefl8dDQVUREVNe7tpL/CAdEQDdEffYSrcBWuGjWKC4c+8RQWYzogpUgpUsr06TAOxsG4zZvRhCY0uep0BsFsVhRFUZTXX9euHfcIXPbTuIAw5sB+6BjXaeNfY8e4dtTtO+8oqKCCf/iD6Fiseej0HQxjzgxRKpPKpLJVq+B5eB6ef+MN0YnsRUmUREkNDWAFK1hnzVJRRRU3bxadizUvHoEw5gC0xj93d4+rHlc9riYn4wf4AX4wcaLoXHabBbNg1t27WIqlWDp5ssVsMVvMn3wiOhZrGVxAGBOo38p+K/ut/NWv3ILdgt2C9+6F1+F1eH3kSNG57BYEQRB04wZVUzVVP/OMukPdoe7IyxMdi7UsLiCMCRBSEVIRUvHww3Xn6s7VnUtPh0WwCBYNGCA6V9NYrbZSW6mtNDKypK6krqTuzBnRidiDwY/xMvYABa4KXBW4qlu3ex3vdbzXMT9fr4WDTtAJOlFerl2FhXHhaJ14EZ2xB+Cp3Kdyn8rt0weDMRiDDx3CUAzFUH9/0bnstgyWwTKLpX59/fr69VFRpUqpUqpUVYmOxcTgKSzGWpAsyZIsBQdrV+np2q64jzwiOpfdhsNwGH7smMuLLi+6vDh2bHHP4p7FPb/7TnQsJhZPYTHWAqTl0nJp+ejRWsE4dky3hSMKoiBq3z6vd73e9Xo3KooLB/tnPAJhrBlJnaXOUucpU9Af/dH/ww+1uzo8YzwVUiF1wwbt/I25c7Wb3DHO/hWfic5YM5COSEekI/PmYSAGYuDGjdoZ4/rbaoS20Bbakpio9lR7qj0bGxiJROdijkl3f8EZcxyIUqlUKpW++y7+Dn+Hv0tIEJ3IXo0d42hFK1pnz1b7qf3Ufhs3is7F9IGnsBizQ2xsbGxsrItL+ZflX5Z/uXGjtrYxY4boXHb7vmOcbGQj29Sp6svqy+rLKSmiYzF94QLC2H3oYe5h7mF+6KH2Ie1D2ofs3Kn9Ao6JEZ3LbvEQD/E1NRADMRATE6N4K96Kd3a26FhMn7iAMPYT+lN/6k/e3q5GV6OrMS1NuztwoOhc9qLVtJpWX7liWGRYZFgUGWnJteRacktKROdi+sZrIIz9G7Isy7LctSscgANwoPHgpv79ReeyFyVSIiVeumTba9tr2xsRoeaquWruxYuiczHnwCMQxv6JVjgefxwICCgrS1vjePxx0bnsRTmUQznnzjUsaFjQsGDkyDMbz2w8s/Ef/xCdizkXbiRkDAACQwNDA0ONRgiBEAgpKNBt4UigBErIycFBOAgHDRzIhYO1JB6BsFZNmi3NlmYPHQprYA2s2bdP26uqXTvRuexFGZRBGamp7nXude51kyYV+hf6F/rX1orOxZwbr4GwVklaK62V1o4dC8EQDME7d2qFw8NDdC67xUAMxGzd2rZL2y5tu7z0Ug7mYA7W14uOxVoHHoGwVkUaJA2SBsXH4xScglPMZu2sboPupnJ/6Bjvp/ZT+731lug8rHXiAsJaBTlZTpaTExLADGYwr1ghOo/93wDIIBNBOIRD+IIFymRlsjL5vfdEx2Ktm+7eeTF2Pxo7xqW/Sn+V/pqUpNvCYQQjGO/dw9t4G2/HxXHhYI6ERyDMqfzQMX69/fX217dtgzRIg7Tx40XnspsJTGC6dcs21TbVNnXcuJLQktCS0EOHRMdi7J/xCIQ5hT4pfVL6pHh5tYtrF9cuLi1Nr4WD1tJaWnv9uo1sZKPhw7lwMEfGIxCma8E7g3cG7+zSpf7P9X+u//PBg9pdSRKdy26hEAqhf/sbdsbO2DkiwrLYstiy+MIF0bEY+yn8GC/TpaAxQWOCxnTvXu9W71bvlpWl3e3ZU3Quu/0efg+/Lytz6evS16VvRIR24t/XX4uOxdj94ALCdMU4xTjFOOU3v2mIbIhsiDx0CFbACljh6ys6l90ICKioyBBkCDIERUUV+xX7FftduyY6FmP24CkspgvGs8azxrPh4TSdptP01FTtbvv2onPZbR2sg3VpaW7+bv5u/hMmcMc40zNeRGcOzfhfxv8y/tczz9j62PrY+jTuiqu/wkGf0qf06bZtMBtmw+znnuPCwZwBj0CYQ9J2xZ02jU7RKTr1l7+gCU1o0t8Z4xqzWVEURVFee0275jPGmXPgEQhzKD90jMsgg7xli+4Kx/cd49SNulG3N9/UCse8edoHuXAw58IjEOYAEOW18lp57Z/+BNtgG2xbsEB0IntpI6X6esNgw2DD4Fde0U78+/BD0bkYa0n6eWfHnIrW+Ofu7hHqEeoRunUrREM0RE+aJDpX09y+jZVYiZWxsVrhaOxHYcy58RQWe6D6rey3st/KX/3K447HHY87+/djNEajHgvHXJgLc7/9FsuxHMtHjFD8FD/FjwsHa11cRAdgrUNIRUhFSMXDD9Ntuk23MzNxFa7CVUOHis5ltwEwAAZUVsJe2At7f/tb5ZJySblksYiOxZgIvAbCWpR2VKyPj+Gu4a7hbuOeTn37is5ltzAIg7AvvoBcyIXckSMVVVEV9e9/Fx2LMZF4Cou1iMDqwOrA6t698TP8DD8rLNTu6q9w0BJaQktOndIKx6BBXDgY+z88AmHNytjT2NPYMyiI2lJbapuRAQgI+MgjonPZbSWshJVHj3rEesR6xI4de/LayWsnr1VXi47FmCPhEQhrFsZ0Y7ox/be/tSXbkm3JR47otnBEQzREf/QRLIAFsCAykgsHYz+ORyDsF5E6S52lzlOmoD/6o39j34Obm+hc9qKdtJN2rlunPqE+oT7R2DFus4nOxZgj4xEIaxJZkiVZmjsX/xv/G/87OVm7q6PC0XjGuAUsYFm2TCscc+dqH+TCwdj94BEIswOiTDLJtHSpdlb30qWiE9mLkiiJkhoawApWsM6apUar0Wr05s2iczGmR1xA2E+KjY2NjY11cSmXy+VyOSkJ9sAe2PPSS6Jz2W0WzIJZd+9iKZZi6eTJFrPFbDF/8onoWIzpGRcQ9m/1MPcw9zA/9FA7j3Ye7Tw++gg34Sbc9NxzonPZLQiCIOjGDaqmaqp+5hl1h7pD3ZGXJzoWY86A10DYv+hP/ak/eXu3/7r91+2/zs7Wa+GgAiqggsuXDVcNVw1XhwzhwsFY8+OtTBgAAJhiTbGm2EcfhTbQBtpkZ+OH+CF+GBwsOpe96ASdoBPl5aSSSurQocp8Zb4yv6xMdC7GnBEXkFZO698ICKAqqqKqY8dwG27DbX36iM5lt8EwGAYrSn1ZfVl92dChZyaemXhmYkWF6FiMOTPezr2VkiRJkiRZpm/pW/r24EE4CSfhZOfOonM1zWefucx0meky89lnlZ5KT6Xnd9+JTsRYa8BrIK2MdlTskCFQDMVQfOwYrIE1sEaHhSMKoiBq3z4vi5fFyzJqVHHP4p7FXDgYe6D4KaxWQlorrZXWjh0LwRAMwTt3YjzGY7yHh+hcdkuFVEjdsEE7f4Mb/xgTiUcgTk7KlDKlzFdfRU/0RM+9e/VaOGgLbaEtiYla4Zg9W7vLhYMxkXgNxEnJyXKynJyQAItgESxasUJ0Hns1doyjFa1onT1b7af2U/tt3Cg6F2Ps//BTWE6isWPc44DHAY8D77+PC3EhLnzrLdG57PZ9x7i21ciUKepkdbI6uXGvLcaYI+E1EJ3rk9InpU+Ku7tnmWeZZ9n27ZAGaZA2frzoXHaLh3iIr6mBGIiBmJgYxVvxVryzs0XHYoz9OC4gOqUVDi8vz0TPRM/Exj2dRowQnctetJpW0+orVwyLDIsMiyIjLbmWXEtuSYnoXIyxn8drIDoTvDN4Z/DOLl3qE+sT6xMPHtTuSpLoXPaiREqkxEuXbHtte217IyLUXDVXzb14UXQuxtj946ewdELbo+o//qPukbpH6h7JzdXu6rBw5FAO5Zw717C3YW/D3rCw0+bT5tNmLhyM6RFPYTk403rTetP6J5+0dbB1sHXIyoL34D14z9dXdC57UQIlUEJODq7AFbgiOlpRFVVRb94UnYsx1nRcQByUNFIaKY0MCdEWxdPTMQRDMKRjR9G57EUZlEEZqanude517nWTJhX6F/oX+tfWis7FGPvleA3EwQSWB5YHlo8ZA92gG3TbvRuDMAiDPD1F57JbDMRAzNatbbu07dK2y0sv5WAO5mB9vehYjLHmwyMQByEfk4/Jx6ZOpcE0mAZ/8AGa0IQmV90V+MaOca3xT4d9KIyx+8YFRDDpiHREOjJvHqZgCqasXg0KKKCgfv5cZJBBJoJwCIfwBQuUycpkZfJ774mOxRhrebp7h+scEOUb8g35RmIiDINhMGzhQtGJ7GYEIxjv3cNbeAtvTZtmmWyZbJn88ceiYzHGHhwuIA9IOIVTOLm6Vv+5+s/Vf960SSscL7wgOpfdTGAC061btqm2qbap48aVhJaEloQeOiQ6FmPsweM+kBamnb/Rpk31N9XfVH+Tmoq7cBfu0l/hoLW0ltZev24jG9lo+HAuHIwx/cy168zTHZ/u+HTHtm3v/O7O7+787vBh+Aw+g89CQkTnsttCWAgLv/rKMNAw0DAwIuKU3ym/U37/8z+iYzHGxOMC0iIQ5Xw5X85PTYU5MAfmjBkjOlHTnD1re8j2kO2hkSNL8kvyS/IrK0UnYow5Di4gzcw4xTjFOGXiRPqCvqAvdu0SncduBARUVGQ4YDhgOBAVpY04rl0THYsx5nh4DaSZUSfqRJ3+8z9F57DbOlgH69LS3FLdUt1ShwzhwsEY+zlcQJqJMd2YbkwPCIBcyIXcfv1E57lvH8FH8NGWLV4DvAZ4DYiJ4a1GGGP3ix/jbSbUg3pQj759Reewj9ms9FJ6Kb1ee+3774JEJ2KM6QePQJoJlVIplXboIDrHj/q+Y1w7Y/yNNxRFURRl3rzv03PhYIzZjQtIMyEjGcnoeGsGdIpO0an6ejyP5/H8jBmWSkulpXL1atG5GGP65yI6gLN4rPyx8sfK6+qomqqpuvGdvUDfd4wbrAarwRoTY1lvWW9Zv2eP6FiMMefBI5BmUpxWnFacdukSLIElsOTMGVE5qJAKqfDaNfqKvqKvfvtby8uWly0vZ2aK/vkwxpwPF5BmRlfoCl1ZvlzMV7dawRd8wXfoUPWQekg9VFgo+ufBGHNe3EjYIhCly9Jl6fK+fRiFURgVHd1iXyoMwiDsiy9wOS7H5RERljaWNpY2FRWifwKMMefHI5AWQeT5pOeTnk8+/zy8C+/Cu7m5zf4lvv+8hgWGBYYFYWFcOBhjDxovoreQitqK2orae/d8Pvf53Ofzjz4CBAR0c6NiKqZiWcbNuBk3u7nd7+fT/r/aWqzHeqxftQpWwkpY+cILloWWhZaFNTWiv1/GWOvDU1gPWGBoYGhgqI+Py1cuX7l8NWEC7aW9tHfIELpO1+m6v3/jf4cP48P4cEUFvUPv0DvHjtF39B19l5LCmxoyxhzF/wKeYeMy/zPC/wAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAxNy0xMi0xNVQxNTo1NzoyNyswODowMKIRvi8AAAAldEVYdGRhdGU6bW9kaWZ5ADIwMTctMTItMTVUMTU6NTc6MjcrMDg6MDDTTAaTAAAATXRFWHRzdmc6YmFzZS11cmkAZmlsZTovLy9ob21lL2FkbWluL2ljb24tZm9udC90bXAvaWNvbl9jazFiemEwemo5ampkY3hyL3JpZ2h0LnN2Z7O3J80AAAAASUVORK5CYII='); + background-size: contain; + content: ' '; + inset: 0; +} + +.icon-refresh::before { + position: absolute; + z-index: 9999; + display: block; + width: 16px; + height: 16px; + margin: auto; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAYAAAD9yHLdAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAAAAAAAA+UO7fwAAAAlwSFlzAAAASAAAAEgARslrPgAAMQpJREFUeNrt3XlcVHX3B/Bz7rCISi6IC+ijkpZpIswMyBLgluVuKm4pqWmEuG/hUpr5uFYoiuaSFrklZvroo+jPFRURZgYVxZ1K3HIXUBSGe35/XC9PWpYL8J2B8/6H1wwGn3sb5sz93u/3fAEYY4wxxhhjjDHGGGOMMcYYY4wxxhhjjDHGGGOMMcYYY4wxxhhjjDHGGGOMMcYYY4wxxhhjjDHGGGOM/QUUHYCx59F0ddPVTVdXq5YXkxeTF1O3Ll7H63jdzY3eoDfojTp1UIta1FatCm/D2/C2kxPchttwu0oVyIRMyKxShVpSS2pZuTIkQzIklyuHv+Av+IudHURBFERJkvJbKlQo+IWhEAqhsgz2YA/2d+8WPP/oMXWkjtTx4UMMwAAMuH4d2kE7aHf9OoVQCIX8/jvuxJ2489o1WkJLaMmlS+AHfuB37hwmYAImnDtnNBlNRlNGhvJDiUSfX/ZygiiIgqhMmayJWROzJgYF4Xbcjtv9/akX9aJerq7QE3pCTwcHiIEYiMnMxNpYG2ufOYNTcApOOXDAcNZw1nA2KUn0cTwrLiBMKO+z3me9z9asKa+V18prtVr5tHxaPv3mmzgaR+Nod3cYCANhYMOGyr9+9VXla9myonMXFoqmaIp+8ADDMRzDz56FTtAJOh07RgmUQAkGA17Da3jNYMjrldcrr1dKyrGxx8YeG3vvnujc7I8QdbG6WF3skCFUjapRtYkTcSSOxJHVqr3Yz0tNVb6OH280Go1G43//K/oIn3rkogOwkgzR09bT1tPW3V3jrHHWOLdoIRtkg2zw84PTcBpO+/jgGByDY2rWFJ3U0tEiWkSL8vNxDa7BNSdOkAM5kMOuXTgYB+PgnTvz1uStyVuzbx8XmOKh0+q0Oq2tLW2hLbRl9WrsgB2wQ7duhf17aAWtoBWzZpncTe4m94gI0cf9JC4g7KU0oSbUhCpW1FTTVNNUa98eTGACU9u2uAf34J6WLWEuzIW5VauKzlni6UEP+txcZYju0CGoDtWh+pYt+QH5AfkB69cfxaN4FH/9VXTMkkJ3UXdRd3HBAuWKMTy8yH8hAQENH64MeUZFiT5+FRcQ9kwaN27cuHHjSpVsbW1tbW2DgxEREbt2Vb7bvLny1dZWdE721+gz+ow+S06W+kn9pH7r1+fdyruVd2vdOi4sz0f7rvZd7bs+Pvgv/Bf+KyEBjGAEIxb5+yjNp/k0PytLE6mJ1ES+9lpybHJscuzVq6LPBxcQ9hjlJqCNTbY+W5+tb98eFsEiWNS3LxyDY3CsXTvlsb296JzsJT2aHEBdqAt12bULMzADM5YsgQ/hQ/hw0yblk25enuiYlka7XLtcu3zTJozGaIzu2LG4fz85kzM5jxxpijPFmeLmzhV9PriAlHKefp5+nn4uLtgQG2LDQYOwDJbBMh99BIfgEBxycRGdjxUvOkSH6NDVq7gEl+CSFSvMx83Hzcejo49+c/Sbo99cuiQ6nyjKPY8qVchABjJcuYJe6IVeNjbFHqQNtIE2W7YYpxmnGad16CD6vEiiA7DipfwhNG6sS9Wl6lJ/+EF6KD2UHv76K6ZgCqZMmcKFo3RDX/RF3+rVYQWsgBXjx9uQDdlQero2XZuuTV+2zOui10Wvi6+9JjpncaMP6AP6ICBAWOFQc0RQBEXUqSP6fKj4CqSEKxizvY7X8fqkSaADHejati2usVtWwqhDX+2pPbXfsIFqU22qPW1aSl5KXkre0aOi4xUV3QPdA92Df/8b/MEf/CdMEJvmwgVlem/t2qLPC1+BlDAe8R7xHvENGypXGuvWFdzsAwCAdu24cLCXshgWw2JJUqetSv2l/lL/lBT19abfot+i3+LmJjpmYaMbdINu1K0rOgf4gi/4irsCehIXECvX5OMmHzf52NVVO087TzsvJkZzSnNKcyo1FRAQMDiYCwYrUurr69HrTa4iV5GrnDihu6O7o7sze7Y6e090zJeFC3ABLnjRhYGFiICALGe2IxcQK+OT4ZPhk+HgoNPpdDrdp5/agA3YwOnTGIMxGNO3r/oJUXROVjopK+rLlIGW0BJajh1rF2gXaBd4+rRut263bndIyKN/ZX0faHbADthRrpzoGCCDDDIXEPacPL/0/NLzy8DAXKdcp1ynlBTl2alT1Z5OovMx9pcSIRESnZ1hLIyFsd9/rxunG6cbt2+fOtQqOt4zQ0BAC3jj9gIvEHgT/0lcQCyUcqVRubJypfHdd9IZ6Yx0Zu9epWnf66+LzsfYC9kFu2BXQIDmjOaM5ozJpCMd6WjKFLU1iOh4Fo+vQNjfUWdN5Z7OPZ172mBQnv3gA76XwUoUdUGqHvSgnzwZpsJUmJqQoP9C/4X+C/6A9DTkTu7kzlcg7JHg4ODg4GCNRv0kpvwhHTiAn+An+IkFzPpgrDhMhskwWa+nS3SJLhmNWq1Wq9V+9JHoWJYGwzAMwzQa5ZH4e53CA5RW+vv6+/r7tWqlD0oflD5o9271k9jjLxDGShl1nxZERFy8WHtVe1V7deNGtWmn6HiWol5Uvah6UeKHsizmUqi00J3SndKd6tRJNskm2bR8OY7H8Ti+cmXRuUobSqIkSsrJUVYW37sHw2E4DH+Gwn0QDsLBihV5SLF4YDtsh+06dbLxt/G38U9OVu6VdOmi9OpS980oerSX9tJeRGyGzbCZ6LMCUPZh2YdlH6pDWQ8fisrBfwDFRNtH20fbZ8IELItlsey0afwG9ILCIAzCHj6kntSTep49C6thNaw+fRpDMARDTp/GbtgNu506BTNgBsy4cEFpQXHrltnb7G32vnXLYaLDRIeJt24l1kqslVgrJ+d5f/3jzSadneVj8jH5WNWqmmhNtCbaxYXqUT2q5+xMy2k5La9ZU9mBsHFj/Ba/xW8bNVKuNF9/HQxgAIOdnejTaXW8wAu87t3DbMzG7IEDDSsNKw0r164t6l+rzdJmabNMJqWAeHqKPg1mg9lgNlSqpHRTvnNHVA5+Aysij88qWbhQmQY4cKDoXJaODtABOpCeDtfgGlw7cADfw/fwvf37lfN34ICbm5ubm9vZs7GxsbGxsfn5ovM+L/V1kT83f27+3Pr1bZbYLLFZ4u5OJ+kknQwMpP20n/a3aMGz7Z4RAQF99ZVyRTJunPKkLBf2r9Fu0W7RbklJwck4GSd7eIg+bOW4nZ2V475xQ1QMHsIqZMoWra+8kt8zv2d+z9hY5dnWrUXnshjhEA7h2dlwAS7Aha1boTW0htYbN5pjzDHmmPj4ow5HHY46PL3rq9IDSPRBvLiCNumBEAiBaWnKs+rXtWuhLJSFsn/oknwOz+G5Fi0wHuMxvkUL6A29oXeHDkpBrVJF9PEIh4CAo0frknRJuqS6dW1r2NawrdGnz4teYVoLZYtjSVI2cBOXgwtIIVH/4M3VzdXN1bdsUXo7iL/UFev+faX99O7d0AJaQIvY2JwbOTdybmzYkDYlbUralOxsmAJTYIronJYnJSElISXh8mXl0cqV0AAaQIOVK9VZe+nn08+nn/f1LWhZQ0BAvXuX2sISBmEQ1qVLHuVRHu3Z4z7HfY77nI4dlS1+r1172R+P+ZiP+Tzk/CQuIC9JWejXoIHyyXrnTmgGzaCZq6voXMVN3fEOHdERHRcsKN+8fPPyzdet24f7cB8+eADTYBpME53S+j0+dHfggPpVmZUzblyF7yt8X+H7Nm0gEiIhMjQUVsJKWPnOO6XmnhsCAjZtalvHto5tnQMHlL/PNm2UK9fz50XHKyz2SfZJ9knip/GW/BdUEfFM8EzwTKhXT1otrZZW79tXavbReLT3tjLdctMmnIpTceqSJYb2hvaG9jt3io7HHlfwOh0qDZWGDh2q3IT+8MPS0gKHIimSIn//HbpBN+jWurXpmuma6dqxY8/7c3QjdSN1I48ehXiIh3h3d9HHJblJbpJbjRqit7blAvKcvDt4d/DuULeueb15vXn9vn3oh37oV6uW6FxFpWC6axZmYdaCBTZbbbbabP3qq8O9D/c+3Pv330XnY89H2RDKySn/Qv6F/AsffYRDcAgOGTWqpA99USIlUuLNm+iDPujTurVyRWJ65rsHllZAZHvZXrZ3dX18qLP48RDWM1IX/pkTzAnmhF27SmrhoGRKpmSzGebDfJi/Zk2+lC/lS599drTi0YpHK/76q+h87OUk10yumVzz5k3l0YwZDdc1XNdw3fz5DjkOOQ454eFUn+pT/YkTcSgOxaGOjqLzFhalcDg5KY9271b+ntu2NZQ1lDWUVffL+RvxEA/xljMEKLvL7rI7IiRAAvxz+iIjfAzN0qn7bdBb9Ba9tWdPiWsxogMd6IigA3SADuvWaS5rLmsuN2pkGm4abhoeEqLMM+fCUVKldU/rntY9O9v4gfED4wezZtEYGkNjGjSAYAiG4KVLCz5QlCgVKtBaWktrt29X7pE0b/6P/0kgBEIgkejkKvvR9qPtR4svaMIDWCp1Ixw7WztbO9uEBOUSv0ED0bkKjT/4g/+5c8rK6o8+Ui7p9+wRHYtZFrXtunRdui5dX7oUp+N0nO7nJzpXYVGHaKVvpW+lb7t2NXxk+Mjw0bZtT/47pdCo904aNxadW5l1V7u2Mi38wgVRMfgK5AnqSmPb8bbjbcevW1dSCof6SZKaUTNqNnu27VjbsbZj3d25cLC/cyTwSOCRwLQ0U1dTV1PXgAByJmdyHjlS+e79+6LzvSz0Rm/0dnAgIxnJuHGjsrPne++p31c6SAQEwAgYASMsYEfCR+Tecm+5N1+BWBztae1p7emoKOyNvbH30KGi8xQOkwnLYTksN3CgId4Qb4hXN6Ri7MUon8hffVV5tHSp8vUZhoIsXMGQ3VbYCluPH7eYledPUFqZ1K0reoiZC8gjavtotQuo6DwvTL2nYQADGL7+uryxvLG8MSJCWY9R0saymWVA1LvoXfQuI0bIF+WL8sXZs5UmlZazb0VJo3HRuGhc3NySNidtTtr8yy+icpT6ISx9qj5VnxoUpBSOBQtE53lRdJgO0+HMTPkr+Sv5q27dlLHRMWO4cLCiR2S4bLhsuBwZSV/T1/R1y5Z0iA7RIXHrE1jxKLUFRNlfoE4dpVvr+vXKs+L767+Y1FTNVc1VzVUvrxTHFMcUxw0bRCdipVPKmJQxKWPi45V7bTodTaAJNOEZpsmy55IXlBeUF1T4TSOfV6krIGovIRu9jd5G/8MPVruAahksg2U//qg88PFR5vefOSM6FmMA/+vl9SD/Qf6D/ObN6Uf6kX785hvRuUoKzWDNYM1g8QWk1I1Rnrc/b3/e/pNPlGaHb70lOs9z2wSbYFN0tLGmsaax5rBhypPiX0iM/RVlnUlurvIoLEz3ve573fe//gpREAVRM2eKzmet8lvlt8pvJX47g1JzBaIP1AfqAz09ldlIkyeLzvO8aAWtoBWzZimFY8gQ5VkuHMy6FCxYnEbTaFp4OIRCKITy6/h52bjauNq4ij9vJb6AKF1K7e3pHt2je99/by07wdEiWkSL8vPhM/gMPgsLM7mb3E3uERGiczFWGExtTG1MbRYuhMWwGBb37as8m5cnOpe1eOj90PuhNxeQIlehZ4WeFXqql8oWsIL0n6ifyE7BKTj1wQfGTsZOxk48dsxKJmUh6+rVFEIhFNKjR8EHJ/a3bNfYrrFdwwWkyHh+6fml55eBgbARNsJG9V6B5aOVtJJWDhtmCjGFmEJWrRKdh7HioPRe+/lnyIRMyBw9WnQeS2e7yXaT7SYuIIVOnWUl1ZfqS/WjopRLZPEbr/wT8iRP8pwyxRRvijfFR0eLzsOYCKZWplamVvPm0WbaTJvV6fXsSXmd8jrldRJ/pWbxb6zPKz09PT09/aOPYCpMhalNmojO848ezaoyLTMtMy37/HPRcRizBPI5+Zx8bvBg8AEf8Ll+XXQeS1PmtzK/lfmNr0AKjU+GT4ZPRuXKysYxX3whOs8/WgSLYNGGDY9Px2WMAahNHK9fV3b6DA8XncfSZEVkRWRFcAEpNHmYh3k4derjG8dYHppBM2jGmTOaSppKmkr9+yvPin8hMGaJlJY8sbE8pPW4SmMqjak0hoewXpq+j76Pvs+bb5ILuZBLaKjoPE8VDuEQnp0tl5HLyGXeey+pflL9pPqZmaJjMWYNzKvMq8yrwsOVfTBu3BCdR7RsXbYuWye+gFj9SnQ6SSfp5FdfWXr3TzKTmcwffqjuryA6D2N/5BXsFewVXL268qh6dfm8fF4+b2+PU3EqTnV0pMk0mSaXL6+8gf9Fz7gFsAAW2NjQEBpCQ/6wFe7H8DF8fOcOfoPf4DfPsKMfAgLev4+f4+f4+cOHT36belJP6rluHfwIP8KPgweLPm/F7lG3beMS4xLjEvHrZqy2nbtOq9PqtE2bKi+4xETReZ6G3qF36J3ISNN003TT9FGjROdhpZPSPLRiRRudjc5GFxKi/N107qxcGXt5QTREQ3T58qJzsn+gBz3oc3ONi42LjYvt7UXHsdohLNpKW2nr+PGiczzVRJgIE9PSMqtnVs+sbsE5WYmm3abdpt02eLDmoOag5uD580rhmDdP+W7z5lw4rExTaApN1d5i4lnskM/TFNzz+Iw+o886dhSd508erSSXt8vb5e0DB55bfG7xucV/vhRnrCjpZutm62ZHR8MkmASTSuFQT0mlAQ1oxA9dqazuCoReo9fotYgIMIIRjOL3BP6TztAZOkdFpSxOWZyy+NAh0XFY6aIM7Q4bVmrvEZRwVJfqUl3LuQKxmgKi36Lfot/i5kaTaBJN6tFDdJ4/GQtjYeyvv+bszdmbs/fTT0XHYaWLUjgqVFCGOHhBaollYUNYVlNA5GPyMfnYuHEWO9tqNsyG2aGhyv4H2dmi47DShcpTeSrfpw8kQRIkVawoOg8rIjLIIPMQ1jPzPut91vvsK6/gT/gT/qS2fbY0O3YoC5527BCdhJVO2AybYbOWLUXnYEWMgIC4gDwzcw9zD3OPnj2VR2XLis5T4LGNcHiWFRNMBzrQubmJjsGKFgZgAAbwENYzwxbYAluoLT8syFW4ClfXrFH2MzCZRMdhpRu1ptbU+g8L+FgJxlcg/8gj3iPeI75hQ9gDe2CPj4/oPAXCIAzCHj7UJGuSNcl8s5xZBpyEk3DS7duic7Ai1hyaQ3O+AvlHmhRNiibFAq88FsEiWLR8edLmpM1Jm3/5RXQcxgAAoA/0gT7nzomOwYrYHtgDe65eFR1DZXEFRJmOaGurbLBkQTfNH93zkDZJm6RNc+eKjsPYH+FwHI7DeRJHiXcQDsJBy+mlZ3EFRF4vr5fXv/sujsSROLJaNdF5CiyGxbB427bkmsk1k2ueOSM6DmN/ZH/C/oT9idhYZT+cmzdF52FFQ+or9ZX6xsaKzlGQR3SAPwXqJnWTullgi5JdsAt2qT2EGLMsB28evHnwZlYWtISW0HLyZNF5WFHYuDE5Njk2OfbIEdFJVBZWQBBhNIyG0W3aiE6iomk0jaadOGGsaKxorLhzp+g8jP0dU7wp3hQfHU0hFEIhP/wgOg97Sf7gD/7nzklukpvkFhYmOs6TLKaA6AP1gfpADw/4Gr6Gr11dRedRKbNboqOVR8+wnwFjFsA03DTcNLxfP+XRzJm0iBbRIvEbELFn1BJaQsv9+8255lxzbrNmypWH5dw8V1lMAVFaMLRtKzrG4/LylJWfljPmyNizk2VlndL48VKUFCVFeXjQJtpEm9asocN0mA7zjpjCPdogSpmeq+5r9P77xtnG2cbZzZod/eboN0e/uXRJdMynsZhuttqftD9pfzp4EKfjdJzu5yc6D0RCJETGxRkDjYHGQMsZUmOsMDRc13Bdw3V2duViy8WWi23Y0DzLPMs8q1YtTT9NP00/Z2c5W86Ws//ccw5H42gcXb48mMEM5r/YmTAKoiCqXDlaQStohZ3d8+bCnbgTd5Ypo3S1dnB40eOjZbSMlt27Bz2hJ/QshHUTs2E2zM7MxLfxbXz7+a/kcASOwBFEspPsJDtdvy6Nk8ZJ465exbbYFtsmJSmTc6xv8oPwAuKT4ZPhk1G5cu6V3Cu5V65dwzAMwzCNRnQumANzYM4HHxhbGFsYW8TEiI7DGGOWRnhXW/N483jz+Nat8SSexJPiCwdFUzRFP3hg42TjZOO0caPoPIwxZqmE3wMhLWlJazmtSjAcwzE8Li6pflL9pPo8RswYY08jvIDAG/AGvOHtLTqGSpm2+3//JzoHY4xZOmEFJIiCKIhsbJQuoh4eok+Eit6it+itPXtE52CMMUsn7B7I/e73u9/v/uabmI7pmP7isy0KzQgYASOuXUtxTHFMcTx1SnQcxhizdMKuQPL75PfJ7+PlJfoEqCiLsihr9+5Hj3jBIGOM/QNx90BOwAk4odOJPgEFJ2KptFRaunev6ByMMWYthBUQvIk38aZeL/oEqEgiiaTkZNE5GGPMWggrIDSLZtGs+vVFn4DH9zbnex+MMfasir2AqCvPsSk2xaavvCL6BMBxOA7H09OVnkH374uOwxhj1qLYC4j5ffP75vdr1xZ94AUOwkE4ePy46BiMMWZtir2AyF3lrnLXOnVEH7iKfMmXfE+cEJ2DMcasTfHfA2kEjaDRv/4l+sALTsCv0q/Sr6dPi87BGGPWptgLCLqjO7pb0BXISlpJKy1voxbGGLN0xX8F0gbaQBvLKSDSIGmQNOj6ddE5GGPM2hR/ASEgoBo1RB+4StnA6sYN0TkYY8zaFHsBoZk0k2ZWqiT6wFXZKdkp2SnXronOwRhj1qb4r0DKQBkoU6GC6ANX3L2b1j2te1r3QtjykjHGSpniLyB2YAd2llJAeOEgY4y9qOKfhbUcl+Nye3vRBw6+4Au+3HWXMcZeVLEVkODg4ODgYI0GjGAEI6LoA+cCwhhjL6fYCkhKQEpASoCNsA2sGGOMFa5iKyB21e2q21XnT/yMMVZSFFsBUWY75eWBDnSgs4BCcggOwSELGEpjjDErVcw30YnAG7zBW/y0WepDfahP5cqiczDGmLUq/mm8RjCCMSdH9IFjOIZjeJky/k7+Tv5Ojo6i8zDGmLUp/gISBEEQdOeO6ANXPajzoM6DOlWris7BGGPWpvgLyByYA3Nu3RJ94CpyJmdydnYWnYMxxqxN8ffC2k7bafvNm6IPvOAE+Ev+kj9fgTDG2PMq/pXoC3EhLrSc5oWyXtbL+po1RedgjDFrU/xDWJWhMlS+cEH0gauwMTbGxg0bis7BGGPWpvgLyApYASsyMkQfuIrKUlkq26iR6ByMMWZtir+AAACABV2BfIqf4qdcQBhj7HkVewGR58vz5fmnT4s+8AKJkAiJzs4e8R7xHvE8G4sxxp5VsReQepH1IutF/vILRVM0RT94IPoEFJyIddI6aV3jxqJzMMaYtdAU9y9MS0tLS0sjcnF0cXRx7N4dfoPf4Ldq1USfCGm7tF3afvbsZfNl82Xz/v2i8zDGmKUTdA8EAKpAFahiMok+ASoaQSNoRIsWonMwxpi1EFZA6Cf6iX46dEj0CSjI05k6U2c/P58MnwyfDAcH0XkYY8zSCdvgCQEBwXIKiNpcMdc31zfX19dXeXb3btG5GGPMUgm7AjGajCaj6cQJ5dHdu6JPRIEBMAAGNG8uOgZjjFk6cfdAAABAlpWvhw+LPhEFMiADMt55R3QMxhizdIILCAAYwAAGCxrKmopTcaqXl8cwj2Eew+rXF52HMcYslfgCchfuwt2DB0XHeJLGXeOuce/RQ3QOxhizVMILyN2YuzF3Y+Lj6TAdpsOZmaLzFFgIC2Hh+++LjsEYY5aq2BcSPunWtlvbbm3Lz3eRXCQXydMTzsAZOGMBvakQELBKlZpv1Xyr5lubNl1Ou5x2Oe3qVdGxGGPMUgi/AinQGlpD640bRcd4krxUXiov7d1bdA7GGLM0llNAhsAQGLJ1K+hBD/rcXNFxVHScjtPxDz90n+M+x31OuXKi8zDGmKUQPoSlunLlypUrVx4+dIl0iXSJ9PeH7bAdtterJzoXxmEcxjk4SD2lnlLPS5eurLqy6sqq5GTRuRhjRcfrotdFr4uvvVa9SvUq1av4+ro2c23m2qxBg2oPqz2s9tDRMcAnwCfA5/ff1d5+ovOKImwl+tPgcByOwzduJIkkkt59V3SeglzZmI3ZI0Yoj775RvmqrmNhjFmj4ODg4OBgjSb9fPr59PP9+9NMmkkzx46VO8md5E6vvaZ0zAAgICAAkEACCQDS09PT09Pv3tVO107XTl+7Vr4qX5WvfvXVkagjUUeizp4VfVzFBUUHeJIyVFS1qu0523O25zIylHUidnaic6kohEIopEsX03DTcNPwn38WnYcx9vx0Wp1Wp61ShSIogiLWr8dZOAtnBQW93E/Ny4NBMAgGzZgBS2AJLJk2Tem4kZcn+niLisUVEJUuRZeiS1m7FgbCQBhoOesxaBftol0HD5oqmiqaKr71lug8jLFnpwxNOTnJHeWOcscDB5TZlg0aFPovagNtoM2WLXer3q16t2q3bueGnRt2btjDh6KPv7BZzk30J3mAB3ioQ0WWA1tiS2zp76+7qLuou9i2reg8jLF/pg5VyWlympy2YUORFQ7VNtgG29q3f6XtK21faTt3rujjLyoWW0CMaEQj7tsHARAAASdPis7zJEqlVEqdPVt9YYrOwxh7uvT26e3T248ZA+NhPIwPDCyu34uzcTbODg319PP08/TT60Wfh8Jm8W98NSrWqFijoq0t3sf7eN+Cbqrvxt24u2rVW7du3bp169IlZRaZ0Sg6F2Psf7wWeC3wWtCokTIpZ80a5Z6qTfFNHroCV+AKIprRjGZJUt4nNm8WfV4Ki8VegajyLuVdyrv0/feUREmUlJMjOs+fzIW5MHfqVH8nfyd/J0dH0XEYYwBBFERBZGMj15HryHW++w4WwSJYZG8vNlXJu2dq8QUkNTU1NTX19m2IhEiIXLNGdJ4n4UgciSOrVXtw6cGlB5ciIkTnYYwBZK/OXp29etgwmAyTYbL4oSNKpmRKrl1bdI7CZvEFRCVfkC/IF2bOVP5HmM2i8/yJP/iD/9ix+kB9oD7Q01N0HMZKoybUhJpQnTqwH/bD/qlTRecpkAzJkIwWO+v1RVlNAVEX6OAMnIEzVq4Uneev2dqSjnSk++67husarmu4znLWrzBW8iHa7LfZb7N/0SLlDdtyWg/halyNqy9eFJ2jsFlNASmwATbAhmnTlAcWuEAnHuIh3t29TL0y9crU+/RT0XEYKw309fX19fXDw2EkjISRljPZpkAf6AN9jh0THaOwWV0BMRqNRqPx/HnqRb2o1w8/iM7zVB7gAR4REV51vep61fXyEh2HsZJI30ffR9/nzTflU/Ip+dTs2aLzPA2GYiiG7tghOkdhs7oCorLZZ7PPZp/lXomgF3qhl41N/on8E/knfvjB+6z3We+zr7wiOhdjJYHaHZvSKI3SYmPRG73R28FBdK4/CYMwCHv40DzPPM88b8MG0XEKm9UWkKTNSZuTNv/yC8RCLMSuWCE6z9NgAAZgwOuvmx3NjmbHmJhHz5a4m2mMFSebXja9bHotXVrkK8pfEt2je3Rv3bojgUcCjwRevy46T2Gz2gKiyvsp76e8nz79FIbBMBh2+7boPE+D7bAdtuvUSZukTdIm8b0Rxl6EvpK+kr7SuHHYCTthp169ROd5GlpEi2hRfj4NoAE0YMYM0XmKSon5JKzT6XQ6XViY8mjhQtF5nioUQiFUlukG3aAbnTqZJpgmmCZs2SI6FmOWTDtBO0E74Z13oDN0hs7//S+GYRiGWXALIQICWrZM6cY7aJDoOEWlxBQQhSRpN2k3aTclJuJUnIpTLf3m9d27+Aa+gW+89ZZhpWGlYeXx46ITMWZJ1FYksqPsKDvu3w9REAVRlSqJzvU0lEiJlHjzJjbFpti0QQOlgNy4ITpXUbH6IazHyTJ8Dp/D52Fh6iWk6ER/r0IFeofeoXd27dJ/of9C/8Xrr4tOxJglaPJxk4+bfOzqKq+QV8grtm619MJRYCtsha3jx5f0wqGy3EvAF3TlkRquNVxruDo74xk8g2e8vUXneqpESITEcuWoP/Wn/u3aVS1btWzVsuvX/2743fC7IStLdDzGipNPhk+GT0blyuAADuCwZ4+yolz81tb/hCbQBJqQkGB6z/Se6b2hQx89W+K3ui1xBUTlkumS6ZKZkAB+4Ad+ISFwES7CRcttdog7cSfurFRJ6i/1l/q/+67LWZezLmfXrVPK4f37ovMxVpSUHQIrVJCvydfka9u2QQzEQIzltwRSm7xiCIZgSLt2yt9ryZtt9TQlbAjrf5RLyLt35SA5SA4KCVFvXovO9Y/+Df+GfzdsqExP3L7dI94j3iPe2Vl0LMaKglo4oAW0gBZxcbAH9sAeHx/RuZ7ZQTgIBz/7TFngfOqU6DjFrcQWEFVKcEpwSvCuXeRDPuQzZ47oPM9Hq5UeSA+kBwcOeHfw7uDdoW5d0YkYKwwFhQMAALZvt7bCoW5t/er8V+e/Oj8yUnQeUUrYLKynU/cHyI7LjsuOi4+HSTAJJvn6is71rOgQHaJDV69KraRWUqu2bQ3xhnhDfEqK6FyMPQ9lun2NGsojdfq6Vis61zPzBm/wvnPHvNC80LzQ0/MoHsWj+OuvomOJUuKvQFT7cB/uQ7MZ8zEf8/v0ocN0mA5nZorO9azQF33Rt3p16kf9qF98vO6O7o7uzttvi87F2LPwzPLM8sx64w3lnuShQ8qzVlQ4HsEszMKssLDSXjhUJfYm+tNcXn159eXVt2/XqFejXo16GRl4GA/j4S5dROd6ZsmQDMl2dpAGaZDWo0eNcjXK1Sh3+/aV3678duW35GTR8Rj7Ix3pSEfNmuFaXItrd+yA9bAe1levLjrXi1m0yLjduN24fdYs0UksRakZwnoa5ZJaXbmurmS3VqtX53yS80nOJ6Ghad3Tuqd1z84WnYiVTrpVulW6VaNGKV2zZ81Sm4uKzvW81Om5D/If5D/Ib95c+bvKzRWdy1KUmiGspylvKG8obxg2DN6Bd+Cd7dtF53k5vXs72DjYONgcPlwwZMBYMVA2UCtfXpeiS9GlrF0LX8PX8PVXX1lr4QBf8AXfy5dxOk7H6d26ceH4a6W+gKj3RjT9Nf01/bt3p320j/ZZcUuRR9OApVgpVopNStJqtVqt9qOPlG9yF2BWuLTvat/Vvuvj44AO6IAmEwyEgTCwRw/RuV6Uuq4DEiABErp0UabnXrkiOpel4jeUJ6gtFGwCbAJsAg4fVj5JubqKzlU4DhzAztgZOw8caPjU8Knh09OnRSdi1qVgNmNMdkx2zOjRSouRL75QvmtrKzrfC3u0TkzuJfeSewUHpzimOKY4lrz9OwobF5CnUHcSlCvLleXKe/cqz5YtKzrXyyr4hPVoAZQ6jz02NjY2NtbSe4cxUTxDPUM9Q319sQN2wA4LF+JknIyTPTxE5yoseAWv4JVRowyXDZcNl0vvuo7nxQXkH+hO6U7pTnXqBO/D+/B+bKzyrBV/0noC7aW9tDclheIojuLGjlUXXorOxcTyuuh10euik1N+bn5ufu6sWTgTZ+LMAQPACEYwlqCh0P7QH/rPmGEcYhxiHDJhgug41qbkvBCKmH6Yfph+WNeudJAO0sE1a5RnS04hedyOHVgOy2G5iAhesFg6KLMRy5ZVNmYbOpRepVfp1XHjcDgOx+GVK4vOV9ioA3WgDgsWmKaYppimqM0P2fPiAvKclNlNXbpIzaRmUrO1a5VnS2AhUXuHLYbFsHjtWnm+PF+eP3lyil+KX4rfuXOi47GXUy+qXlS9KHv7ivMrzq84f9Ag+YR8Qj4xcaK6YFV0vqJCsRRLsd9+a3IzuZnc1I2eSn7X3KJS6mdhPa/Hb6699x6EQRiEPXwoOlehWwyLYbH06PXRu7d0XDouHT99Wrtau1q7+v/+zzPdM90zvUMH5fslaEijhFJ7T2l3andqdw4fXsGpglMFp/Pn6RV6hV6ZP7+kFw6IhViIXbJEKRzqrEQuHC+L//BfknLp37kz6EEP+h9/BAMYwGBnJzpXcaHP6XP6/MgRuA/34f68eZlXM69mXl2z5tywc8PODSuBhdVKKAWjaVNl5feAARAMwRDcp4/yXeufDPLMtsE22DZ3rrGqsaqx6qhRypNcOAoLF5BCohSSdu0gHMIhfO1aiIZoiC5fXnSuYjcMhsGw27fpOl2n6z//jANxIA5cu9ZtkNsgt0G7d/Nsr8Klv6+/r79fq5ZskA2yoUcPvIE38Eb//gXbApQ2j4ZeqQE1oAaffGIKNAWaAr/8UnSskooLSCHzCvYK9gr28MgfnD84f/DmzTgGx+CYmjVF5xKNIimSIn//Hd3QDd3WrwdXcAXX9etzYnNic2ITEnil79/TVtVW1VZ1d1dWRnfsCCfhJJzs3BmyIAuytNoSNzvqhdy/L++V98p7+/bldRzFo5S/4IqOp5+nn6efi4s0QZogTdi0CSbDZJis14vOZZnu36fRNJpGJyRIA6QB0oC9e+EW3IJbe/aUcyjnUM4hKUntGCA6aWFTF+Zl2mXaZdo1aiStllZLqwMDyZ/8yT8wEHfhLtwVGAhzYS7MrVpVdF5Lo25zoHld87rm9Y4dk39J/iX5F24qWly4gBQxdXokhVIohcbE4GJcjIu7dhWdy2p4gRd43btHs2gWzTpxQlnwdeKE0uTu5EnpXeld6d3jx/MG5Q3KG3TypNJm+7fflP+4+Me63ee4z3GfU66c3VG7o3ZH69bNn5o/NX9q3bo4GAfj4FdfhVbQClo1boxrcA2u8fBQJmE0agSLYBEssrcXfbqtS2oqEBBQ+/bKDqQXLohOVNpwASlWiLoFugW6Bf/+NxyDY3AsIoKHHgoXJVMyJZvNYAYzmG/cgFzIhdz/fcUojMKoa9cgBEIg5M6dZ/65QECg0WAwBmOwkxO0hJbQ0slJ+blVqkAe5EFelSqQCImQyFsQF5l20A7a/fyzpq+mr6Zvv35J9ZPqJ9W3nn19Shp+4xKkYEOoltASWn7/vfKsulMbYwwAgKIpmqIfPIBsyIbsiAhTK1MrU6t580TnYgouIIJ5xHvEe8Q7O2t2aHZodixfrkw7bN9edC7GRKJpNI2mnTiBE3EiTuzVSxmiSk0VnYs9jguIRUFU2q8PGoSIiKg2dStF8/ZZ6aQDHeiIIAIiIGLpUltbW1tb2xEjEmsl1kqslZMjOh77a1xALJQ6bRPSIR3SV63CIAzCoDffFJ2LsULlB37g99tvShv1jz9WWuXExYmOxZ4NtzKxUKZrpmuma8eOYSAGYqBWq8xCGjGCDtNhOsw3DZk1y8tTvkZF5QTkBOQEvPkmFw7rxFcgVkZdX4I9sAf2mDkTT+AJPNGnD8/mYpZvz578yPzI/MghQ44EHgk8EpiWJjoRezn8hmPl9Kn6VH1qUBD1o37Ub/585dnGjUXnYqXcoz3FoQt0gS7jxxtbGFsYW8TEiI7FChcPYVk5Q2NDY0PjffuUhQo6nTrUBT7gAz7Xr4vOx0oHdUU4jIJRMGr0aDgEh+BQ/fpcOEo2vgIpodQV0TaeNp42ngMHKiu4J0zglhisUIyAETDi2jWQQQb566+VvdHnzzcajUaj8f590fFY8eACUkp4n/U+6332lVfMn5g/MX8SGoou6IIuI0YonxRdXETnYxZuFIyCUZcugR3Ygd2sWeW7le9WvtvSpUqPsgcPRMdjYvAQVimhtnwwbTBtMG2YMycnMCcwJ7BuXWXr2g8/LNjXgzEAUDok7N+PNbAG1ggJuXvz7s27N1991RhsDDYGz5/PhYMB8BUIe4JnqGeoZ6ivLzbEhtgwLAyaQlNoGhyM4RiO4WXKiM7HChfNo3k079YtfA1fw9diYmQH2UF2WLJEaYd+8qTofMyycQFhf6sJNaEmVLGiTZxNnE1c166URVmU1bcv3sE7eCcg4PGtb5lly8tT2ubv26c0m/zuO8e+jn0d+/70E19RsBfBBYS9EHUnPPov/Zf+27mzsg6lc2eaTtNpemAgeqEXetnYiM5ZOt29C8tgGSyLi4McyIGcTZtyQ3NDc0Pj4lJTU1NTU2/fFp2QlQxcQFih8snwyfDJqFw51y3XLdft7bexMTbGxq1awTgYB+NatYI5MAfm1KkjOqfVerRlK1SBKlDl1Ck6Rsfo2O7dOAJH4Ij//CdnR86OnB379vEOj6w4cAFhxUq/Rb9Fv8XNTR4gD5AH+PmhCU1o8vGBTtAJOvn6Kv9KXQhpays6b7FT95RHQsLERGgADaBBYiJshI2w8fBhjMM4jEtMVLrT3r0rOi4r3biAMIui0+q0Oq2tLV2ki3TxjTfgS/gSvmzcGDMxEzMbNYIgCIKgWrXgB/gBfqhdW5k95uqKq3AVrnJ1tZid/fSgB31urrID4W+/QTWoBtXOnwc3cAO38+dhH+yDfenpShfa8+el8lJ5qfzJk8k1k2sm1zx7Vvkhxb+jImPPgwsIK1G8gr2CvYKrVzdfMl8yX6pZU1ouLZeWu7pCb+gNve3sKIIiKKJcOZgJM2GmnZ2UJWVJWXZ2NIkm0aRy5ZQFcYjkS77kW768ci8nK0uZrXT7Ni7ABbggKwuGwlAYmpmpdJHNytL8R/MfzX+ysiAO4iDuxo26H9T9oO4Hly/HxsbGxsbm54s+L4wxxhhjjDHGGGOMMcYYY4wxxhhjjDHGGGOMMcYYY4wxxhhjjDHGGGOMMcYYY4wxxhhjjDHGGGOMMcYYKzb/D4DEm9oGCaFQAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE3LTEyLTE1VDE1OjU3OjI3KzA4OjAwohG+LwAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxNy0xMi0xNVQxNTo1NzoyNyswODowMNNMBpMAAABPdEVYdHN2ZzpiYXNlLXVyaQBmaWxlOi8vL2hvbWUvYWRtaW4vaWNvbi1mb250L3RtcC9pY29uX2NrMWJ6YTB6ajlqamRjeHIvcmVmcmVzaC5zdmejF0ikAAAAAElFTkSuQmCC'); + background-size: contain; + content: ' '; + inset: 0; +} +</style> diff --git a/src/components/Verifition/src/Verify/VerifyPoints.vue b/src/components/Verifition/src/Verify/VerifyPoints.vue new file mode 100644 index 0000000..9d04f29 --- /dev/null +++ b/src/components/Verifition/src/Verify/VerifyPoints.vue @@ -0,0 +1,250 @@ +<template> + <div style="position: relative"> + <div class="verify-img-out"> + <div + :style="{ + width: setSize.imgWidth, + height: setSize.imgHeight, + 'background-size': setSize.imgWidth + ' ' + setSize.imgHeight, + 'margin-bottom': vSpace + 'px' + }" + class="verify-img-panel" + > + <div v-show="showRefresh" class="verify-refresh" style="z-index: 3" @click="refresh"> + <i class="iconfont icon-refresh"></i> + </div> + <img + ref="canvas" + :src="'data:image/png;base64,' + pointBackImgBase" + alt="" + style="display: block; width: 100%; height: 100%" + @click="bindingClick ? canvasClick($event) : undefined" + /> + + <div + v-for="(tempPoint, index) in tempPoints" + :key="index" + :style="{ + 'background-color': '#1abd6c', + color: '#fff', + 'z-index': 9999, + width: '20px', + height: '20px', + 'text-align': 'center', + 'line-height': '20px', + 'border-radius': '50%', + position: 'absolute', + top: parseInt(tempPoint.y - 10) + 'px', + left: parseInt(tempPoint.x - 10) + 'px' + }" + class="point-area" + > + {{ index + 1 }} + </div> + </div> + </div> + <!-- 'height': this.barSize.height, --> + <div + :style="{ + width: setSize.imgWidth, + color: barAreaColor, + 'border-color': barAreaBorderColor, + 'line-height': barSize.height + }" + class="verify-bar-area" + > + <span class="verify-msg">{{ text }}</span> + </div> + </div> +</template> +<script setup type="text/babel"> +/** + * VerifyPoints + * @description 点选 + * */ +import { resetSize } from './../utils/util' +import { aesEncrypt } from './../utils/ase' +import { getCode, reqCheck } from '@/api/login' +import { getCurrentInstance, nextTick, onMounted, reactive, ref, toRefs } from 'vue' + +const props = defineProps({ + //弹出式pop,固定fixed + mode: { + type: String, + default: 'fixed' + }, + captchaType: { + type: String + }, + //间隔 + vSpace: { + type: Number, + default: 5 + }, + imgSize: { + type: Object, + default() { + return { + width: '310px', + height: '155px' + } + } + }, + barSize: { + type: Object, + default() { + return { + width: '310px', + height: '40px' + } + } + } +}) + +const { t } = useI18n() +const { mode, captchaType } = toRefs(props) +const { proxy } = getCurrentInstance() +let secretKey = ref(''), //后端返回的ase加密秘钥 + checkNum = ref(3), //默认需要点击的字数 + fontPos = reactive([]), //选中的坐标信息 + checkPosArr = reactive([]), //用户点击的坐标 + num = ref(1), //点击的记数 + pointBackImgBase = ref(''), //后端获取到的背景图片 + poinTextList = reactive([]), //后端返回的点击字体顺序 + backToken = ref(''), //后端返回的token值 + setSize = reactive({ + imgHeight: 0, + imgWidth: 0, + barHeight: 0, + barWidth: 0 + }), + tempPoints = reactive([]), + text = ref(''), + barAreaColor = ref(undefined), + barAreaBorderColor = ref(undefined), + showRefresh = ref(true), + bindingClick = ref(true) + +const init = () => { + //加载页面 + fontPos.splice(0, fontPos.length) + checkPosArr.splice(0, checkPosArr.length) + num.value = 1 + getPictrue() + nextTick(() => { + let { imgHeight, imgWidth, barHeight, barWidth } = resetSize(proxy) + setSize.imgHeight = imgHeight + setSize.imgWidth = imgWidth + setSize.barHeight = barHeight + setSize.barWidth = barWidth + proxy.$parent.$emit('ready', proxy) + }) +} +onMounted(() => { + // 禁止拖拽 + init() + proxy.$el.onselectstart = function () { + return false + } +}) +const canvas = ref(null) +const canvasClick = (e) => { + checkPosArr.push(getMousePos(canvas, e)) + if (num.value == checkNum.value) { + num.value = createPoint(getMousePos(canvas, e)) + //按比例转换坐标值 + let arr = pointTransfrom(checkPosArr, setSize) + checkPosArr.length = 0 + checkPosArr.push(...arr) + //等创建坐标执行完 + setTimeout(() => { + // var flag = this.comparePos(this.fontPos, this.checkPosArr); + //发送后端请求 + var captchaVerification = secretKey.value + ? aesEncrypt(backToken.value + '---' + JSON.stringify(checkPosArr), secretKey.value) + : backToken.value + '---' + JSON.stringify(checkPosArr) + let data = { + captchaType: captchaType.value, + pointJson: secretKey.value + ? aesEncrypt(JSON.stringify(checkPosArr), secretKey.value) + : JSON.stringify(checkPosArr), + token: backToken.value + } + reqCheck(data).then((res) => { + if (res.repCode == '0000') { + barAreaColor.value = '#4cae4c' + barAreaBorderColor.value = '#5cb85c' + text.value = t('captcha.success') + bindingClick.value = false + if (mode.value == 'pop') { + setTimeout(() => { + proxy.$parent.clickShow = false + refresh() + }, 1500) + } + proxy.$parent.$emit('success', { captchaVerification }) + } else { + proxy.$parent.$emit('error', proxy) + barAreaColor.value = '#d9534f' + barAreaBorderColor.value = '#d9534f' + text.value = t('captcha.fail') + setTimeout(() => { + refresh() + }, 700) + } + }) + }, 400) + } + if (num.value < checkNum.value) { + num.value = createPoint(getMousePos(canvas, e)) + } +} +//获取坐标 +const getMousePos = function (obj, e) { + var x = e.offsetX + var y = e.offsetY + return { x, y } +} +//创建坐标点 +const createPoint = function (pos) { + tempPoints.push(Object.assign({}, pos)) + return num.value + 1 +} +const refresh = async function () { + tempPoints.splice(0, tempPoints.length) + barAreaColor.value = '#000' + barAreaBorderColor.value = '#ddd' + bindingClick.value = true + fontPos.splice(0, fontPos.length) + checkPosArr.splice(0, checkPosArr.length) + num.value = 1 + await getPictrue() + showRefresh.value = true +} + +// 请求背景图片和验证图片 +const getPictrue = async () => { + let data = { + captchaType: captchaType.value + } + const res = await getCode(data) + if (res.repCode == '0000') { + pointBackImgBase.value = res.repData.originalImageBase64 + backToken.value = res.repData.token + secretKey.value = res.repData.secretKey + poinTextList.value = res.repData.wordList + text.value = t('captcha.point') + '【' + poinTextList.value.join(',') + '】' + } else { + text.value = res.repMsg + } +} +//坐标转换函数 +const pointTransfrom = function (pointArr, imgSize) { + var newPointArr = pointArr.map((p) => { + let x = Math.round((310 * p.x) / parseInt(imgSize.imgWidth)) + let y = Math.round((155 * p.y) / parseInt(imgSize.imgHeight)) + return { x, y } + }) + return newPointArr +} +</script> diff --git a/src/components/Verifition/src/Verify/VerifySlide.vue b/src/components/Verifition/src/Verify/VerifySlide.vue new file mode 100644 index 0000000..f3c7bfe --- /dev/null +++ b/src/components/Verifition/src/Verify/VerifySlide.vue @@ -0,0 +1,376 @@ +<template> + <div style="position: relative"> + <div + v-if="type === '2'" + :style="{ height: parseInt(setSize.imgHeight) + vSpace + 'px' }" + class="verify-img-out" + > + <div :style="{ width: setSize.imgWidth, height: setSize.imgHeight }" class="verify-img-panel"> + <img + :src="'data:image/png;base64,' + backImgBase" + alt="" + style="display: block; width: 100%; height: 100%" + /> + <div v-show="showRefresh" class="verify-refresh" @click="refresh"> + <i class="iconfont icon-refresh"></i> + </div> + <transition name="tips"> + <span v-if="tipWords" :class="passFlag ? 'suc-bg' : 'err-bg'" class="verify-tips"> + {{ tipWords }} + </span> + </transition> + </div> + </div> + <!-- 公共部分 --> + <div + :style="{ width: setSize.imgWidth, height: barSize.height, 'line-height': barSize.height }" + class="verify-bar-area" + > + <span class="verify-msg" v-text="text"></span> + <div + :style="{ + width: leftBarWidth !== undefined ? leftBarWidth : barSize.height, + height: barSize.height, + 'border-color': leftBarBorderColor, + transaction: transitionWidth + }" + class="verify-left-bar" + > + <span class="verify-msg" v-text="finishText"></span> + <div + :style="{ + width: barSize.height, + height: barSize.height, + 'background-color': moveBlockBackgroundColor, + left: moveBlockLeft, + transition: transitionLeft + }" + class="verify-move-block" + @mousedown="start" + @touchstart="start" + > + <i :class="['verify-icon iconfont', iconClass]" :style="{ color: iconColor }"></i> + <div + v-if="type === '2'" + :style="{ + width: Math.floor((parseInt(setSize.imgWidth) * 47) / 310) + 'px', + height: setSize.imgHeight, + top: '-' + (parseInt(setSize.imgHeight) + vSpace) + 'px', + 'background-size': setSize.imgWidth + ' ' + setSize.imgHeight + }" + class="verify-sub-block" + > + <img + :src="'data:image/png;base64,' + blockBackImgBase" + alt="" + style="display: block; width: 100%; height: 100%; -webkit-user-drag: none" + /> + </div> + </div> + </div> + </div> + </div> +</template> +<script setup type="text/babel"> +/** + * VerifySlide + * @description 滑块 + * */ +import { aesEncrypt } from './../utils/ase' +import { resetSize } from './../utils/util' +import { getCode, reqCheck } from '@/api/login' + +const props = defineProps({ + captchaType: { + type: String + }, + type: { + type: String, + default: '1' + }, + //弹出式pop,固定fixed + mode: { + type: String, + default: 'fixed' + }, + vSpace: { + type: Number, + default: 5 + }, + explain: { + type: String, + default: '' + }, + imgSize: { + type: Object, + default() { + return { + width: '310px', + height: '155px' + } + } + }, + blockSize: { + type: Object, + default() { + return { + width: '50px', + height: '50px' + } + } + }, + barSize: { + type: Object, + default() { + return { + width: '310px', + height: '30px' + } + } + } +}) + +const { t } = useI18n() +const { mode, captchaType, type, blockSize, explain } = toRefs(props) +const { proxy } = getCurrentInstance() +let secretKey = ref(''), //后端返回的ase加密秘钥 + passFlag = ref(''), //是否通过的标识 + backImgBase = ref(''), //验证码背景图片 + blockBackImgBase = ref(''), //验证滑块的背景图片 + backToken = ref(''), //后端返回的唯一token值 + startMoveTime = ref(''), //移动开始的时间 + endMovetime = ref(''), //移动结束的时间 + tipWords = ref(''), + text = ref(''), + finishText = ref(''), + setSize = reactive({ + imgHeight: 0, + imgWidth: 0, + barHeight: 0, + barWidth: 0 + }), + moveBlockLeft = ref(undefined), + leftBarWidth = ref(undefined), + // 移动中样式 + moveBlockBackgroundColor = ref(undefined), + leftBarBorderColor = ref('#ddd'), + iconColor = ref(undefined), + iconClass = ref('icon-right'), + status = ref(false), //鼠标状态 + isEnd = ref(false), //是够验证完成 + showRefresh = ref(true), + transitionLeft = ref(''), + transitionWidth = ref(''), + startLeft = ref(0) + +const barArea = computed(() => { + return proxy.$el.querySelector('.verify-bar-area') +}) +const init = () => { + if (explain.value === '') { + text.value = t('captcha.slide') + } else { + text.value = explain.value + } + getPictrue() + nextTick(() => { + let { imgHeight, imgWidth, barHeight, barWidth } = resetSize(proxy) + setSize.imgHeight = imgHeight + setSize.imgWidth = imgWidth + setSize.barHeight = barHeight + setSize.barWidth = barWidth + proxy.$parent.$emit('ready', proxy) + }) + + window.removeEventListener('touchmove', function (e) { + move(e) + }) + window.removeEventListener('mousemove', function (e) { + move(e) + }) + + //鼠标松开 + window.removeEventListener('touchend', function () { + end() + }) + window.removeEventListener('mouseup', function () { + end() + }) + + window.addEventListener('touchmove', function (e) { + move(e) + }) + window.addEventListener('mousemove', function (e) { + move(e) + }) + + //鼠标松开 + window.addEventListener('touchend', function () { + end() + }) + window.addEventListener('mouseup', function () { + end() + }) +} +watch(type, () => { + init() +}) +onMounted(() => { + // 禁止拖拽 + init() + proxy.$el.onselectstart = function () { + return false + } +}) +//鼠标按下 +const start = (e) => { + e = e || window.event + if (!e.touches) { + //兼容PC端 + var x = e.clientX + } else { + //兼容移动端 + var x = e.touches[0].pageX + } + startLeft.value = Math.floor(x - barArea.value.getBoundingClientRect().left) + startMoveTime.value = +new Date() //开始滑动的时间 + if (isEnd.value == false) { + text.value = '' + moveBlockBackgroundColor.value = '#337ab7' + leftBarBorderColor.value = '#337AB7' + iconColor.value = '#fff' + e.stopPropagation() + status.value = true + } +} +//鼠标移动 +const move = (e) => { + e = e || window.event + if (status.value && isEnd.value == false) { + if (!e.touches) { + //兼容PC端 + var x = e.clientX + } else { + //兼容移动端 + var x = e.touches[0].pageX + } + var bar_area_left = barArea.value.getBoundingClientRect().left + var move_block_left = x - bar_area_left //小方块相对于父元素的left值 + if ( + move_block_left >= + barArea.value.offsetWidth - parseInt(parseInt(blockSize.value.width) / 2) - 2 + ) { + move_block_left = + barArea.value.offsetWidth - parseInt(parseInt(blockSize.value.width) / 2) - 2 + } + if (move_block_left <= 0) { + move_block_left = parseInt(parseInt(blockSize.value.width) / 2) + } + //拖动后小方块的left值 + moveBlockLeft.value = move_block_left - startLeft.value + 'px' + leftBarWidth.value = move_block_left - startLeft.value + 'px' + } +} + +//鼠标松开 +const end = () => { + endMovetime.value = +new Date() + //判断是否重合 + if (status.value && isEnd.value == false) { + var moveLeftDistance = parseInt((moveBlockLeft.value || '0').replace('px', '')) + moveLeftDistance = (moveLeftDistance * 310) / parseInt(setSize.imgWidth) + let data = { + captchaType: captchaType.value, + pointJson: secretKey.value + ? aesEncrypt(JSON.stringify({ x: moveLeftDistance, y: 5.0 }), secretKey.value) + : JSON.stringify({ x: moveLeftDistance, y: 5.0 }), + token: backToken.value + } + reqCheck(data).then((res) => { + if (res.repCode == '0000') { + moveBlockBackgroundColor.value = '#5cb85c' + leftBarBorderColor.value = '#5cb85c' + iconColor.value = '#fff' + iconClass.value = 'icon-check' + showRefresh.value = false + isEnd.value = true + if (mode.value == 'pop') { + setTimeout(() => { + proxy.$parent.clickShow = false + refresh() + }, 1500) + } + passFlag.value = true + tipWords.value = `${((endMovetime.value - startMoveTime.value) / 1000).toFixed(2)}s + ${t('captcha.success')}` + var captchaVerification = secretKey.value + ? aesEncrypt( + backToken.value + '---' + JSON.stringify({ x: moveLeftDistance, y: 5.0 }), + secretKey.value + ) + : backToken.value + '---' + JSON.stringify({ x: moveLeftDistance, y: 5.0 }) + setTimeout(() => { + tipWords.value = '' + proxy.$parent.closeBox() + proxy.$parent.$emit('success', { captchaVerification }) + }, 1000) + } else { + moveBlockBackgroundColor.value = '#d9534f' + leftBarBorderColor.value = '#d9534f' + iconColor.value = '#fff' + iconClass.value = 'icon-close' + passFlag.value = false + setTimeout(function () { + refresh() + }, 1000) + proxy.$parent.$emit('error', proxy) + tipWords.value = t('captcha.fail') + setTimeout(() => { + tipWords.value = '' + }, 1000) + } + }) + status.value = false + } +} + +const refresh = async () => { + showRefresh.value = true + finishText.value = '' + + transitionLeft.value = 'left .3s' + moveBlockLeft.value = 0 + + leftBarWidth.value = undefined + transitionWidth.value = 'width .3s' + + leftBarBorderColor.value = '#ddd' + moveBlockBackgroundColor.value = '#fff' + iconColor.value = '#000' + iconClass.value = 'icon-right' + isEnd.value = false + + await getPictrue() + setTimeout(() => { + transitionWidth.value = '' + transitionLeft.value = '' + text.value = explain.value + }, 300) +} + +// 请求背景图片和验证图片 +const getPictrue = async () => { + let data = { + captchaType: captchaType.value + } + const res = await getCode(data) + if (res.repCode == '0000') { + backImgBase.value = res.repData.originalImageBase64 + blockBackImgBase.value = res.repData.jigsawImageBase64 + backToken.value = res.repData.token + secretKey.value = res.repData.secretKey + } else { + tipWords.value = res.repMsg + } +} +</script> diff --git a/src/components/Verifition/src/Verify/index.ts b/src/components/Verifition/src/Verify/index.ts new file mode 100644 index 0000000..0daa63a --- /dev/null +++ b/src/components/Verifition/src/Verify/index.ts @@ -0,0 +1,4 @@ +import VerifySlide from './VerifySlide.vue' +import VerifyPoints from './VerifyPoints.vue' + +export { VerifySlide, VerifyPoints } diff --git a/src/components/Verifition/src/utils/ase.ts b/src/components/Verifition/src/utils/ase.ts new file mode 100644 index 0000000..d2e6b98 --- /dev/null +++ b/src/components/Verifition/src/utils/ase.ts @@ -0,0 +1,14 @@ +import CryptoJS from 'crypto-js' +/** + * @word 要加密的内容 + * @keyWord String 服务器随机返回的关键字 + * */ +export function aesEncrypt(word, keyWord = 'XwKsGlMcdPMEhR1B') { + const key = CryptoJS.enc.Utf8.parse(keyWord) + const srcs = CryptoJS.enc.Utf8.parse(word) + const encrypted = CryptoJS.AES.encrypt(srcs, key, { + mode: CryptoJS.mode.ECB, + padding: CryptoJS.pad.Pkcs7 + }) + return encrypted.toString() +} diff --git a/src/components/Verifition/src/utils/util.ts b/src/components/Verifition/src/utils/util.ts new file mode 100644 index 0000000..15c1627 --- /dev/null +++ b/src/components/Verifition/src/utils/util.ts @@ -0,0 +1,97 @@ +export function resetSize(vm) { + let img_width, img_height, bar_width, bar_height //图片的宽度、高度,移动条的宽度、高度 + const EmployeeWindow = window as any + const parentWidth = vm.$el.parentNode.offsetWidth || EmployeeWindow.offsetWidth + const parentHeight = vm.$el.parentNode.offsetHeight || EmployeeWindow.offsetHeight + if (vm.imgSize.width.indexOf('%') != -1) { + img_width = (parseInt(vm.imgSize.width) / 100) * parentWidth + 'px' + } else { + img_width = vm.imgSize.width + } + + if (vm.imgSize.height.indexOf('%') != -1) { + img_height = (parseInt(vm.imgSize.height) / 100) * parentHeight + 'px' + } else { + img_height = vm.imgSize.height + } + + if (vm.barSize.width.indexOf('%') != -1) { + bar_width = (parseInt(vm.barSize.width) / 100) * parentWidth + 'px' + } else { + bar_width = vm.barSize.width + } + + if (vm.barSize.height.indexOf('%') != -1) { + bar_height = (parseInt(vm.barSize.height) / 100) * parentHeight + 'px' + } else { + bar_height = vm.barSize.height + } + + return { imgWidth: img_width, imgHeight: img_height, barWidth: bar_width, barHeight: bar_height } +} + +export const _code_chars = [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z' +] +export const _code_color1 = ['#fffff0', '#f0ffff', '#f0fff0', '#fff0f0'] +export const _code_color2 = ['#FF0033', '#006699', '#993366', '#FF9900', '#66CC66', '#FF33CC'] diff --git a/src/components/VerticalButtonGroup/index.vue b/src/components/VerticalButtonGroup/index.vue new file mode 100644 index 0000000..9c78ea2 --- /dev/null +++ b/src/components/VerticalButtonGroup/index.vue @@ -0,0 +1,44 @@ +<template> + <el-button-group v-bind="$attrs"> + <slot></slot> + </el-button-group> +</template> + +<script setup lang="ts"> +/** + * 垂直按钮组 + * Element官方的按钮组只支持水平显示,通过重写样式实现垂直布局 + */ +defineOptions({ name: 'VerticalButtonGroup' }) +</script> + +<style scoped lang="scss"> +.el-button-group { + display: inline-flex; + flex-direction: column; +} + +.el-button-group > :deep(.el-button:first-child) { + border-bottom-color: var(--el-button-divide-border-color); + border-top-right-radius: var(--el-border-radius-base); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.el-button-group > :deep(.el-button:last-child) { + border-top-color: var(--el-button-divide-border-color); + border-top-right-radius: 0; + border-bottom-left-radius: var(--el-border-radius-base); + border-top-left-radius: 0; +} + +.el-button-group :deep(.el-button--primary:not(:first-child, :last-child)) { + border-top-color: var(--el-button-divide-border-color); + border-bottom-color: var(--el-button-divide-border-color); +} + +.el-button-group > :deep(.el-button:not(:last-child)) { + margin-right: 0; + margin-bottom: -1px; +} +</style> diff --git a/src/components/XButton/index.ts b/src/components/XButton/index.ts new file mode 100644 index 0000000..be0f0d4 --- /dev/null +++ b/src/components/XButton/index.ts @@ -0,0 +1,4 @@ +import XButton from './src/XButton.vue' +import XTextButton from './src/XTextButton.vue' + +export { XButton, XTextButton } diff --git a/src/components/XButton/src/XButton.vue b/src/components/XButton/src/XButton.vue new file mode 100644 index 0000000..40cba1a --- /dev/null +++ b/src/components/XButton/src/XButton.vue @@ -0,0 +1,50 @@ +<script lang="ts" setup> +import { PropType } from 'vue' +import { propTypes } from '@/utils/propTypes' + +defineOptions({ name: 'XButton' }) + +const props = defineProps({ + modelValue: propTypes.bool.def(false), + loading: propTypes.bool.def(false), + preIcon: propTypes.string.def(''), + postIcon: propTypes.string.def(''), + title: propTypes.string.def(''), + type: propTypes.oneOf(['', 'primary', 'success', 'warning', 'danger', 'info']).def(''), + link: propTypes.bool.def(false), + circle: propTypes.bool.def(false), + round: propTypes.bool.def(false), + plain: propTypes.bool.def(false), + onClick: { type: Function as PropType<(...args) => any>, default: null } +}) +const getBindValue = computed(() => { + const delArr: string[] = ['title', 'preIcon', 'postIcon', 'onClick'] + const attrs = useAttrs() + const obj = { ...attrs, ...props } + for (const key in obj) { + if (delArr.indexOf(key) !== -1) { + delete obj[key] + } + } + return obj +}) +</script> + +<template> + <el-button v-bind="getBindValue" @click="onClick"> + <Icon v-if="preIcon" :icon="preIcon" class="mr-1px" /> + {{ title ? title : '' }} + <Icon v-if="postIcon" :icon="postIcon" class="mr-1px" /> + </el-button> +</template> +<style lang="scss" scoped> +:deep(.el-button.is-text) { + padding: 8px 4px; + margin-left: 0; +} + +:deep(.el-button.is-link) { + padding: 8px 4px; + margin-left: 0; +} +</style> diff --git a/src/components/XButton/src/XTextButton.vue b/src/components/XButton/src/XTextButton.vue new file mode 100644 index 0000000..b1a922b --- /dev/null +++ b/src/components/XButton/src/XTextButton.vue @@ -0,0 +1,49 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { PropType } from 'vue' + +defineOptions({ name: 'XTextButton' }) + +const props = defineProps({ + modelValue: propTypes.bool.def(false), + loading: propTypes.bool.def(false), + preIcon: propTypes.string.def(''), + postIcon: propTypes.string.def(''), + title: propTypes.string.def(''), + type: propTypes.oneOf(['', 'primary', 'success', 'warning', 'danger', 'info']).def('primary'), + circle: propTypes.bool.def(false), + round: propTypes.bool.def(false), + plain: propTypes.bool.def(false), + onClick: { type: Function as PropType<(...args) => any>, default: null } +}) +const getBindValue = computed(() => { + const delArr: string[] = ['title', 'preIcon', 'postIcon', 'onClick'] + const attrs = useAttrs() + const obj = { ...attrs, ...props } + for (const key in obj) { + if (delArr.indexOf(key) !== -1) { + delete obj[key] + } + } + return obj +}) +</script> + +<template> + <el-button link v-bind="getBindValue" @click="onClick"> + <Icon v-if="preIcon" :icon="preIcon" class="mr-1px" /> + {{ title ? title : '' }} + <Icon v-if="postIcon" :icon="postIcon" class="mr-1px" /> + </el-button> +</template> +<style lang="scss" scoped> +:deep(.el-button.is-text) { + padding: 8px 4px; + margin-left: 0; +} + +:deep(.el-button.is-link) { + padding: 8px 4px; + margin-left: 0; +} +</style> diff --git a/src/components/bpmnProcessDesigner/package/designer/ProcessDesigner.vue b/src/components/bpmnProcessDesigner/package/designer/ProcessDesigner.vue new file mode 100644 index 0000000..6cbe11f --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/ProcessDesigner.vue @@ -0,0 +1,704 @@ +<template> + <div class="my-process-designer"> + <div class="my-process-designer__header" style="z-index: 999; display: table-row-group"> + <slot name="control-header"></slot> + <template v-if="!$slots['control-header']"> + <ElButtonGroup key="file-control"> + <XButton preIcon="ep:folder-opened" title="打开文件" @click="refFile.click()" /> + <el-tooltip effect="light" placement="bottom"> + <template #content> + <div style="color: #409eff"> + <!-- <el-button link @click="downloadProcessAsXml()">下载为XML文件</el-button> --> + <XTextButton title="下载为XML文件" @click="downloadProcessAsXml()" /> + <br /> + + <!-- <el-button link @click="downloadProcessAsSvg()">下载为SVG文件</el-button> --> + <XTextButton title="下载为SVG文件" @click="downloadProcessAsSvg()" /> + <br /> + + <!-- <el-button link @click="downloadProcessAsBpmn()">下载为BPMN文件</el-button> --> + <XTextButton title="下载为BPMN文件" @click="downloadProcessAsBpmn()" /> + </div> + </template> + <XButton title="下载文件" preIcon="ep:download" /> + </el-tooltip> + <el-tooltip effect="light"> + <XButton preIcon="ep:view" title="浏览" /> + <template #content> + <!-- <el-button link @click="previewProcessXML">预览XML</el-button> --> + <XTextButton title="预览XML" @click="previewProcessXML" /> + <br /> + <!-- <el-button link @click="previewProcessJson">预览JSON</el-button> --> + <XTextButton title="预览JSON" @click="previewProcessJson" /> + </template> + </el-tooltip> + <el-tooltip + v-if="props.simulation" + effect="light" + :content="simulationStatus ? '退出模拟' : '开启模拟'" + > + <XButton preIcon="ep:cpu" title="模拟" @click="processSimulation" /> + </el-tooltip> + </ElButtonGroup> + <ElButtonGroup key="align-control"> + <el-tooltip effect="light" content="向左对齐"> + <!-- <el-button + class="align align-left" + icon="el-icon-s-data" + @click="elementsAlign('left')" + /> --> + <XButton + preIcon="fa:align-left" + class="align align-bottom" + @click="elementsAlign('left')" + /> + </el-tooltip> + <el-tooltip effect="light" content="向右对齐"> + <!-- <el-button + class="align align-right" + icon="el-icon-s-data" + @click="elementsAlign('right')" + /> --> + <XButton + preIcon="fa:align-left" + class="align align-top" + @click="elementsAlign('right')" + /> + </el-tooltip> + <el-tooltip effect="light" content="向上对齐"> + <!-- <el-button + class="align align-top" + icon="el-icon-s-data" + @click="elementsAlign('top')" + /> --> + <XButton + preIcon="fa:align-left" + class="align align-left" + @click="elementsAlign('top')" + /> + </el-tooltip> + <el-tooltip effect="light" content="向下对齐"> + <!-- <el-button + class="align align-bottom" + icon="el-icon-s-data" + @click="elementsAlign('bottom')" + /> --> + <XButton + preIcon="fa:align-left" + class="align align-right" + @click="elementsAlign('bottom')" + /> + </el-tooltip> + <el-tooltip effect="light" content="水平居中"> + <!-- <el-button + class="align align-center" + icon="el-icon-s-data" + @click="elementsAlign('center')" + /> --> + <!-- class="align align-center" --> + <XButton + preIcon="fa:align-left" + class="align align-center" + @click="elementsAlign('center')" + /> + </el-tooltip> + <el-tooltip effect="light" content="垂直居中"> + <!-- <el-button + class="align align-middle" + icon="el-icon-s-data" + @click="elementsAlign('middle')" + /> --> + <XButton + preIcon="fa:align-left" + class="align align-middle" + @click="elementsAlign('middle')" + /> + </el-tooltip> + </ElButtonGroup> + <ElButtonGroup key="scale-control"> + <el-tooltip effect="light" content="缩小视图"> + <!-- <el-button + :disabled="defaultZoom < 0.2" + icon="el-icon-zoom-out" + @click="processZoomOut()" + /> --> + <XButton + preIcon="ep:zoom-out" + @click="processZoomOut()" + :disabled="defaultZoom < 0.2" + /> + </el-tooltip> + <el-button>{{ Math.floor(defaultZoom * 10 * 10) + '%' }}</el-button> + <el-tooltip effect="light" content="放大视图"> + <!-- <el-button + :disabled="defaultZoom > 4" + icon="el-icon-zoom-in" + @click="processZoomIn()" + /> --> + <XButton preIcon="ep:zoom-in" @click="processZoomIn()" :disabled="defaultZoom > 4" /> + </el-tooltip> + <el-tooltip effect="light" content="重置视图并居中"> + <!-- <el-button icon="el-icon-c-scale-to-original" @click="processReZoom()" /> --> + <XButton preIcon="ep:scale-to-original" @click="processReZoom()" /> + </el-tooltip> + </ElButtonGroup> + <ElButtonGroup key="stack-control"> + <el-tooltip effect="light" content="撤销"> + <!-- <el-button :disabled="!revocable" icon="el-icon-refresh-left" @click="processUndo()" /> --> + <XButton preIcon="ep:refresh-left" @click="processUndo()" :disabled="!revocable" /> + </el-tooltip> + <el-tooltip effect="light" content="恢复"> + <!-- <el-button + :disabled="!recoverable" + icon="el-icon-refresh-right" + @click="processRedo()" + /> --> + <XButton preIcon="ep:refresh-right" @click="processRedo()" :disabled="!recoverable" /> + </el-tooltip> + <el-tooltip effect="light" content="重新绘制"> + <!-- <el-button icon="el-icon-refresh" @click="processRestart" /> --> + <XButton preIcon="ep:refresh" @click="processRestart()" /> + </el-tooltip> + </ElButtonGroup> + <XButton + preIcon="ep:plus" + title="保存模型" + @click="processSave" + :type="props.headerButtonType" + :disabled="simulationStatus" + /> + </template> + <!-- 用于打开本地文件--> + <input + type="file" + id="files" + ref="refFile" + style="display: none" + accept=".xml, .bpmn" + @change="importLocalFile" + /> + </div> + <div class="my-process-designer__container"> + <div + class="my-process-designer__canvas" + ref="bpmnCanvas" + id="bpmnCanvas" + style="width: 1680px; height: 800px" + ></div> + <!-- <div id="js-properties-panel" class="panel"></div> --> + <!-- <div class="my-process-designer__canvas" ref="bpmn-canvas"></div> --> + </div> + <Dialog + title="预览" + v-model="previewModelVisible" + width="80%" + :scroll="true" + max-height="600px" + > + <!-- append-to-body --> + <div v-highlight> + <code class="hljs"> + <!-- 高亮代码块 --> + {{ previewResult }} + </code> + </div> + </Dialog> + </div> +</template> + +<script lang="ts" setup> +// import 'bpmn-js/dist/assets/diagram-js.css' // 左边工具栏以及编辑节点的样式 +// import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css' +// import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-codes.css' +// import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css' +// import 'bpmn-js-properties-panel/dist/assets/bpmn-js-properties-panel.css' // 右侧框样式 +import { ElMessage, ElMessageBox } from 'element-plus' +import BpmnModeler from 'bpmn-js/lib/Modeler' +import DefaultEmptyXML from './plugins/defaultEmpty' +// 翻译方法 +import customTranslate from './plugins/translate/customTranslate' +import translationsCN from './plugins/translate/zh' +// 模拟流转流程 +import tokenSimulation from 'bpmn-js-token-simulation' +// 标签解析构建器 +// import bpmnPropertiesProvider from "bpmn-js-properties-panel/lib/provider/bpmn"; +// import propertiesPanelModule from 'bpmn-js-properties-panel' +// import propertiesProviderModule from 'bpmn-js-properties-panel/lib/provider/camunda' +// 标签解析 Moddle +import camundaModdleDescriptor from './plugins/descriptor/camundaDescriptor.json' +import activitiModdleDescriptor from './plugins/descriptor/activitiDescriptor.json' +import flowableModdleDescriptor from './plugins/descriptor/flowableDescriptor.json' +// 标签解析 Extension +import camundaModdleExtension from './plugins/extension-moddle/camunda' +import activitiModdleExtension from './plugins/extension-moddle/activiti' +import flowableModdleExtension from './plugins/extension-moddle/flowable' +// 引入json转换与高亮 +// import xml2js from 'xml-js' +// import xml2js from 'fast-xml-parser' +import { XmlNode, XmlNodeType, parseXmlString } from 'steady-xml' +// 代码高亮插件 +// import hljs from 'highlight.js/lib/highlight' +// import 'highlight.js/styles/github-gist.css' +// hljs.registerLanguage('xml', 'highlight.js/lib/languages/xml') +// hljs.registerLanguage('json', 'highlight.js/lib/languages/json') +// const eventName = reactive({ +// name: '' +// }) + +defineOptions({ name: 'MyProcessDesigner' }) + +const bpmnCanvas = ref() +const refFile = ref() +const emit = defineEmits([ + 'destroy', + 'init-finished', + 'save', + 'commandStack-changed', + 'input', + 'change', + 'canvas-viewbox-changed', + // eventName.name + 'element-click' +]) + +const props = defineProps({ + value: String, // xml 字符串 + // valueWatch: true, // xml 字符串的 watch 状态 + processId: String, // 流程 key 标识 + processName: String, // 流程 name 名字 + formId: Number, // 流程 form 表单编号 + translations: { + // 自定义的翻译文件 + type: Object, + default: () => {} + }, + additionalModel: [Object, Array], // 自定义model + moddleExtension: { + // 自定义moddle + type: Object, + default: () => {} + }, + onlyCustomizeAddi: { + type: Boolean, + default: false + }, + onlyCustomizeModdle: { + type: Boolean, + default: false + }, + simulation: { + type: Boolean, + default: true + }, + keyboard: { + type: Boolean, + default: true + }, + prefix: { + type: String, + default: 'camunda' + }, + events: { + type: Array, + default: () => ['element.click'] + }, + headerButtonSize: { + type: String, + default: 'small', + validator: (value: string) => ['default', 'medium', 'small', 'mini'].indexOf(value) !== -1 + }, + headerButtonType: { + type: String, + default: 'primary', + validator: (value: string) => + ['default', 'primary', 'success', 'warning', 'danger', 'info'].indexOf(value) !== -1 + } +}) + +provide('configGlobal', props) +let bpmnModeler: any = null +const defaultZoom = ref(1) +const previewModelVisible = ref(false) +const simulationStatus = ref(false) +const previewResult = ref('') +const previewType = ref('xml') +const recoverable = ref(false) +const revocable = ref(false) +const additionalModules = computed(() => { + console.log(props.additionalModel, 'additionalModel') + const Modules: any[] = [] + // 仅保留用户自定义扩展模块 + if (props.onlyCustomizeAddi) { + if (Object.prototype.toString.call(props.additionalModel) == '[object Array]') { + return props.additionalModel || [] + } + return [props.additionalModel] + } + + // 插入用户自定义扩展模块 + if (Object.prototype.toString.call(props.additionalModel) == '[object Array]') { + Modules.push(...(props.additionalModel as any[])) + } else { + props.additionalModel && Modules.push(props.additionalModel) + } + + // 翻译模块 + const TranslateModule = { + translate: ['value', customTranslate(props.translations || translationsCN)] + } + Modules.push(TranslateModule) + + // 模拟流转模块 + if (props.simulation) { + Modules.push(tokenSimulation) + } + + // 根据需要的流程类型设置扩展元素构建模块 + // if (this.prefix === "bpmn") { + // Modules.push(bpmnModdleExtension); + // } + console.log(props.prefix, 'props.prefix ') + if (props.prefix === 'camunda') { + Modules.push(camundaModdleExtension) + } + if (props.prefix === 'flowable') { + Modules.push(flowableModdleExtension) + } + if (props.prefix === 'activiti') { + Modules.push(activitiModdleExtension) + } + + return Modules +}) +const moddleExtensions = computed(() => { + console.log(props.onlyCustomizeModdle, 'props.onlyCustomizeModdle') + console.log(props.moddleExtension, 'props.moddleExtension') + console.log(props.prefix, 'props.prefix') + const Extensions: any = {} + // 仅使用用户自定义模块 + if (props.onlyCustomizeModdle) { + return props.moddleExtension || null + } + + // 插入用户自定义模块 + if (props.moddleExtension) { + for (let key in props.moddleExtension) { + Extensions[key] = props.moddleExtension[key] + } + } + + // 根据需要的 "流程类型" 设置 对应的解析文件 + if (props.prefix === 'activiti') { + Extensions.activiti = activitiModdleDescriptor + } + if (props.prefix === 'flowable') { + Extensions.flowable = flowableModdleDescriptor + } + if (props.prefix === 'camunda') { + Extensions.camunda = camundaModdleDescriptor + } + return Extensions +}) +console.log(additionalModules, 'additionalModules()') +console.log(moddleExtensions, 'moddleExtensions()') +const initBpmnModeler = () => { + if (bpmnModeler) return + let data = document.getElementById('bpmnCanvas') + console.log(data, 'data') + console.log(props.keyboard, 'props.keyboard') + console.log(additionalModules, 'additionalModules()') + console.log(moddleExtensions, 'moddleExtensions()') + + bpmnModeler = new BpmnModeler({ + // container: this.$refs['bpmn-canvas'], + // container: getCurrentInstance(), + // container: needClass, + // container: bpmnCanvas.value, + container: data, + // width: '100%', + // 添加控制板 + // propertiesPanel: { + // parent: '#js-properties-panel' + // }, + keyboard: props.keyboard ? { bindTo: document } : null, + // additionalModules: additionalModules.value, + additionalModules: additionalModules.value, + moddleExtensions: moddleExtensions.value + + // additionalModules: [ + // additionalModules.value + // propertiesPanelModule, + // propertiesProviderModule + // propertiesProviderModule + // ], + // moddleExtensions: { camunda: moddleExtensions.value } + }) + + // bpmnModeler.createDiagram() + + // console.log(bpmnModeler, 'bpmnModeler111111') + emit('init-finished', bpmnModeler) + initModelListeners() +} + +const initModelListeners = () => { + const EventBus = bpmnModeler.get('eventBus') + console.log(EventBus, 'EventBus') + // 注册需要的监听事件, 将. 替换为 - , 避免解析异常 + props.events.forEach((event: any) => { + EventBus.on(event, function (eventObj) { + let eventName = event.replace(/\./g, '-') + // eventName.name = eventName + let element = eventObj ? eventObj.element : null + console.log(eventName, 'eventName') + console.log(element, 'element') + emit('element-click', element, eventObj) + // emit(eventName, element, eventObj) + }) + }) + // 监听图形改变返回xml + EventBus.on('commandStack.changed', async (event) => { + try { + recoverable.value = bpmnModeler.get('commandStack').canRedo() + revocable.value = bpmnModeler.get('commandStack').canUndo() + let { xml } = await bpmnModeler.saveXML({ format: true }) + emit('commandStack-changed', event) + emit('input', xml) + emit('change', xml) + } catch (e: any) { + console.error(`[Process Designer Warn]: ${e.message || e}`) + } + }) + // 监听视图缩放变化 + bpmnModeler.on('canvas.viewbox.changed', ({ viewbox }) => { + emit('canvas-viewbox-changed', { viewbox }) + const { scale } = viewbox + defaultZoom.value = Math.floor(scale * 100) / 100 + }) +} +/* 创建新的流程图 */ +const createNewDiagram = async (xml) => { + console.log(xml, 'xml') + // 将字符串转换成图显示出来 + let newId = props.processId || `Process_${new Date().getTime()}` + let newName = props.processName || `业务流程_${new Date().getTime()}` + let xmlString = xml || DefaultEmptyXML(newId, newName, props.prefix) + try { + // console.log(xmlString, 'xmlString') + // console.log(this.bpmnModeler.importXML); + let { warnings } = await bpmnModeler.importXML(xmlString) + console.log(warnings, 'warnings') + if (warnings && warnings.length) { + warnings.forEach((warn) => console.warn(warn)) + } + } catch (e: any) { + console.error(`[Process Designer Warn]: ${e.message || e}`) + } +} + +// 下载流程图到本地 +const downloadProcess = async (type) => { + try { + // 按需要类型创建文件并下载 + if (type === 'xml' || type === 'bpmn') { + const { err, xml } = await bpmnModeler.saveXML() + // 读取异常时抛出异常 + if (err) { + console.error(`[Process Designer Warn ]: ${err.message || err}`) + } + let { href, filename } = setEncoded(type.toUpperCase(), xml) + downloadFunc(href, filename) + } else { + const { err, svg } = await bpmnModeler.saveSVG() + // 读取异常时抛出异常 + if (err) { + return console.error(err) + } + let { href, filename } = setEncoded('SVG', svg) + downloadFunc(href, filename) + } + } catch (e: any) { + console.error(`[Process Designer Warn ]: ${e.message || e}`) + } + // 文件下载方法 + function downloadFunc(href, filename) { + if (href && filename) { + let a = document.createElement('a') + a.download = filename //指定下载的文件名 + a.href = href // URL对象 + a.click() // 模拟点击 + URL.revokeObjectURL(a.href) // 释放URL 对象 + } + } +} + +// 根据所需类型进行转码并返回下载地址 +const setEncoded = (type, data) => { + const filename = 'diagram' + const encodedData = encodeURIComponent(data) + return { + filename: `${filename}.${type}`, + href: `data:application/${ + type === 'svg' ? 'text/xml' : 'bpmn20-xml' + };charset=UTF-8,${encodedData}`, + data: data + } +} + +// 加载本地文件 +const importLocalFile = () => { + const file = refFile.value.files[0] + const reader = new FileReader() + reader.readAsText(file) + reader.onload = function () { + let xmlStr = this.result + createNewDiagram(xmlStr) + } +} +/* ------------------------------------------------ refs methods ------------------------------------------------------ */ +const downloadProcessAsXml = () => { + downloadProcess('xml') +} +const downloadProcessAsBpmn = () => { + downloadProcess('bpmn') +} +const downloadProcessAsSvg = () => { + downloadProcess('svg') +} +const processSimulation = () => { + simulationStatus.value = !simulationStatus.value + console.log(bpmnModeler.get('toggleMode', 'strict'), "bpmnModeler.get('toggleMode')") + props.simulation && bpmnModeler.get('toggleMode', 'strict').toggleMode() +} +const processRedo = () => { + bpmnModeler.get('commandStack').redo() +} +const processUndo = () => { + bpmnModeler.get('commandStack').undo() +} +const processZoomIn = (zoomStep = 0.1) => { + let newZoom = Math.floor(defaultZoom.value * 100 + zoomStep * 100) / 100 + if (newZoom > 4) { + throw new Error('[Process Designer Warn ]: The zoom ratio cannot be greater than 4') + } + defaultZoom.value = newZoom + bpmnModeler.get('canvas').zoom(defaultZoom.value) +} +const processZoomOut = (zoomStep = 0.1) => { + let newZoom = Math.floor(defaultZoom.value * 100 - zoomStep * 100) / 100 + if (newZoom < 0.2) { + throw new Error('[Process Designer Warn ]: The zoom ratio cannot be less than 0.2') + } + defaultZoom.value = newZoom + bpmnModeler.get('canvas').zoom(defaultZoom.value) +} +// const processZoomTo = (newZoom = 1) => { +// if (newZoom < 0.2) { +// throw new Error('[Process Designer Warn ]: The zoom ratio cannot be less than 0.2') +// } +// if (newZoom > 4) { +// throw new Error('[Process Designer Warn ]: The zoom ratio cannot be greater than 4') +// } +// defaultZoom = newZoom +// bpmnModeler.get('canvas').zoom(newZoom) +// } +const processReZoom = () => { + defaultZoom.value = 1 + bpmnModeler.get('canvas').zoom('fit-viewport', 'auto') +} +const processRestart = () => { + recoverable.value = false + revocable.value = false + createNewDiagram(null) +} +const elementsAlign = (align) => { + const Align = bpmnModeler.get('alignElements') + const Selection = bpmnModeler.get('selection') + const SelectedElements = Selection.get() + if (!SelectedElements || SelectedElements.length <= 1) { + ElMessage.warning('请按住 Shift 键选择多个元素对齐') + // alert('请按住 Ctrl 键选择多个元素对齐 + return + } + ElMessageBox.confirm('自动对齐可能造成图形变形,是否继续?', '警告', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }).then(() => { + Align.trigger(SelectedElements, align) + }) +} +/*----------------------------- 方法结束 ---------------------------------*/ +const previewProcessXML = () => { + console.log(bpmnModeler.saveXML, 'bpmnModeler') + bpmnModeler.saveXML({ format: true }).then(({ xml }) => { + // console.log(xml, 'xml111111') + previewResult.value = xml + previewType.value = 'xml' + previewModelVisible.value = true + }) +} +const previewProcessJson = () => { + bpmnModeler.saveXML({ format: true }).then(({ xml }) => { + // console.log(xml, 'xml') + + // const rootNode = parseXmlString(xml) + // console.log(rootNode, 'rootNoderootNode') + const rootNodes = new XmlNode(XmlNodeType.Root, parseXmlString(xml)) + // console.log(rootNodes, 'rootNodesrootNodesrootNodes') + // console.log(rootNodes.parent.toJsObject(), 'rootNodes.toJSON()') + // console.log(JSON.stringify(rootNodes.parent.toJsObject()), 'rootNodes.toJSON()') + // console.log(JSON.stringify(rootNodes.parent.toJSON()), 'rootNodes.toJSON()') + + // const parser = new xml2js.XMLParser() + // let jObj = parser.parse(xml) + // console.log(jObj, 'jObjjObjjObjjObjjObj') + // const builder = new xml2js.XMLBuilder(xml) + // const xmlContent = builder + // console.log(xmlContent, 'xmlContent') + // console.log(xml2js, 'convertconvertconvert') + previewResult.value = rootNodes.parent?.toJSON() as unknown as string + // previewResult.value = jObj + // previewResult.value = convert.xml2json(xml, {explicitArray : false},{ spaces: 2 }) + previewType.value = 'json' + previewModelVisible.value = true + }) +} +/* ------------------------------------------------ 芋道源码 methods ------------------------------------------------------ */ +const processSave = async () => { + // console.log(bpmnModeler, 'bpmnModelerbpmnModelerbpmnModelerbpmnModeler') + const { err, xml } = await bpmnModeler.saveXML() + // console.log(err, 'errerrerrerrerr') + // console.log(xml, 'xmlxmlxmlxmlxml') + // 读取异常时抛出异常 + if (err) { + // this.$modal.msgError('保存模型失败,请重试!') + alert('保存模型失败,请重试!') + return + } + // 触发 save 事件 + emit('save', xml) +} +/** 高亮显示 */ +// const highlightedCode = (previewType, previewResult) => { +// console.log(previewType, 'previewType, previewResult') +// console.log(previewResult, 'previewType, previewResult') +// console.log(hljs.highlight, 'hljs.highlight') +// const result = hljs.highlight(previewType, previewResult.value || '', true) +// return result.value || ' ' +// } +onBeforeMount(() => { + console.log(props, 'propspropspropsprops') +}) +onMounted(() => { + initBpmnModeler() + createNewDiagram(props.value) +}) +onBeforeUnmount(() => { + // this.$once('hook:beforeDestroy', () => { + // }) + if (bpmnModeler) bpmnModeler.destroy() + emit('destroy', bpmnModeler) + bpmnModeler = null +}) +</script> diff --git a/src/components/bpmnProcessDesigner/package/designer/ProcessViewer.vue b/src/components/bpmnProcessDesigner/package/designer/ProcessViewer.vue new file mode 100644 index 0000000..485b979 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/ProcessViewer.vue @@ -0,0 +1,664 @@ +<template> + <div class="my-process-designer"> + <div class="my-process-designer__container"> + <div class="my-process-designer__canvas" style="height: 760px" ref="bpmnCanvas"></div> + </div> + </div> +</template> + +<script lang="ts" setup> +import BpmnViewer from 'bpmn-js/lib/Viewer' +import DefaultEmptyXML from './plugins/defaultEmpty' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import { isEmpty } from '@/utils/is' + +defineOptions({ name: 'MyProcessViewer' }) + +const props = defineProps({ + value: { + // BPMN XML 字符串 + type: String, + default: '' + }, + prefix: { + // 使用哪个引擎 + type: String, + default: 'camunda' + }, + activityData: { + // 活动的数据。传递时,可高亮流程 + type: Array, + default: () => [] + }, + processInstanceData: { + // 流程实例的数据。传递时,可展示流程发起人等信息 + type: Object, + default: () => {} + }, + taskData: { + // 任务实例的数据。传递时,可展示 UserTask 审核相关的信息 + type: Array, + default: () => [] + } +}) + +provide('configGlobal', props) + +const emit = defineEmits(['destroy']) + +let bpmnModeler + +const xml = ref('') +const activityLists = ref<any[]>([]) +const processInstance = ref<any>(undefined) +const taskList = ref<any[]>([]) +const bpmnCanvas = ref() +// const element = ref() +const elementOverlayIds = ref<any>(null) +const overlays = ref<any>(null) + +const initBpmnModeler = () => { + if (bpmnModeler) return + bpmnModeler = new BpmnViewer({ + container: bpmnCanvas.value, + bpmnRenderer: {} + }) +} + +/* 创建新的流程图 */ +const createNewDiagram = async (xml) => { + // 将字符串转换成图显示出来 + let newId = `Process_${new Date().getTime()}` + let newName = `业务流程_${new Date().getTime()}` + let xmlString = xml || DefaultEmptyXML(newId, newName, props.prefix) + try { + let { warnings } = await bpmnModeler.importXML(xmlString) + if (warnings && warnings.length) { + warnings.forEach((warn) => console.warn(warn)) + } + // 高亮流程图 + await highlightDiagram() + const canvas = bpmnModeler.get('canvas') + canvas.zoom('fit-viewport', 'auto') + } catch (e) { + console.error(e) + // console.error(`[Process Designer Warn]: ${e?.message || e}`); + } +} + +/* 高亮流程图 */ +// TODO 芋艿:如果多个 endActivity 的话,目前的逻辑可能有一定的问题。https://www.jdon.com/workflow/multi-events.html +const highlightDiagram = async () => { + const activityList = activityLists.value + if (activityList.length === 0) { + return + } + // 参考自 https://gitee.com/tony2y/RuoYi-flowable/blob/master/ruoyi-ui/src/components/Process/index.vue#L222 实现 + // 再次基础上,增加不同审批结果的颜色等等 + let canvas = bpmnModeler.get('canvas') + let todoActivity: any = activityList.find((m: any) => !m.endTime) // 找到待办的任务 + let endActivity: any = activityList[activityList.length - 1] // 获得最后一个任务 + let findProcessTask = false //是否已经高亮了进行中的任务 + //进行中高亮之后的任务 key 集合,用于过滤掉 taskList 进行中后面的任务,避免进行中后面的数据 Hover 还有数据 + let removeTaskDefinitionKeyList = [] + // debugger + bpmnModeler.getDefinitions().rootElements[0].flowElements?.forEach((n: any) => { + let activity: any = activityList.find((m: any) => m.key === n.id) // 找到对应的活动 + if (!activity) { + return + } + if (n.$type === 'bpmn:UserTask') { + // 用户任务 + // 处理用户任务的高亮 + const task: any = taskList.value.find((m: any) => m.id === activity.taskId) // 找到活动对应的 taskId + if (!task) { + return + } + // 进行中的任务已经高亮过了,则不高亮后面的任务了 + if (findProcessTask) { + removeTaskDefinitionKeyList.push(n.id) + return + } + // 高亮任务 + canvas.addMarker(n.id, getResultCss(task.status)) + //标记是否高亮了进行中任务 + if (task.status === 1) { + findProcessTask = true + } + // 如果非通过,就不走后面的线条了 + if (task.status !== 2) { + return + } + // 处理 outgoing 出线 + const outgoing = getActivityOutgoing(activity) + outgoing?.forEach((nn: any) => { + // debugger + let targetActivity: any = activityList.find((m: any) => m.key === nn.targetRef.id) + // 如果目标活动存在,则根据该活动是否结束,进行【bpmn:SequenceFlow】连线的高亮设置 + if (targetActivity) { + canvas.addMarker(nn.id, targetActivity.endTime ? 'highlight' : 'highlight-todo') + } else if (nn.targetRef.$type === 'bpmn:ExclusiveGateway') { + // TODO 芋艿:这个流程,暂时没走到过 + canvas.addMarker(nn.id, activity.endTime ? 'highlight' : 'highlight-todo') + canvas.addMarker(nn.targetRef.id, activity.endTime ? 'highlight' : 'highlight-todo') + } else if (nn.targetRef.$type === 'bpmn:EndEvent') { + // TODO 芋艿:这个流程,暂时没走到过 + if (!todoActivity && endActivity.key === n.id) { + canvas.addMarker(nn.id, 'highlight') + canvas.addMarker(nn.targetRef.id, 'highlight') + } + if (!activity.endTime) { + canvas.addMarker(nn.id, 'highlight-todo') + canvas.addMarker(nn.targetRef.id, 'highlight-todo') + } + } + }) + } else if (n.$type === 'bpmn:ExclusiveGateway') { + // 排它网关 + // 设置【bpmn:ExclusiveGateway】排它网关的高亮 + canvas.addMarker(n.id, getActivityHighlightCss(activity)) + // 查找需要高亮的连线 + let matchNN: any = undefined + let matchActivity: any = undefined + n.outgoing?.forEach((nn: any) => { + let targetActivity = activityList.find((m: any) => m.key === nn.targetRef.id) + if (!targetActivity) { + return + } + // 特殊判断 endEvent 类型的原因,ExclusiveGateway 可能后续连有 2 个路径: + // 1. 一个是 UserTask => EndEvent + // 2. 一个是 EndEvent + // 在选择路径 1 时,其实 EndEvent 可能也存在,导致 1 和 2 都高亮,显然是不正确的。 + // 所以,在 matchActivity 为 EndEvent 时,需要进行覆盖~~ + if (!matchActivity || matchActivity.type === 'endEvent') { + matchNN = nn + matchActivity = targetActivity + } + }) + if (matchNN && matchActivity) { + canvas.addMarker(matchNN.id, getActivityHighlightCss(matchActivity)) + } + } else if (n.$type === 'bpmn:ParallelGateway') { + // 并行网关 + // 设置【bpmn:ParallelGateway】并行网关的高亮 + canvas.addMarker(n.id, getActivityHighlightCss(activity)) + n.outgoing?.forEach((nn: any) => { + // 获得连线是否有指向目标。如果有,则进行高亮 + const targetActivity = activityList.find((m: any) => m.key === nn.targetRef.id) + if (targetActivity) { + canvas.addMarker(nn.id, getActivityHighlightCss(targetActivity)) // 高亮【bpmn:SequenceFlow】连线 + // 高亮【...】目标。其中 ... 可以是 bpm:UserTask、也可以是其它的。当然,如果是 bpm:UserTask 的话,其实不做高亮也没问题,因为上面有逻辑做了这块。 + canvas.addMarker(nn.targetRef.id, getActivityHighlightCss(targetActivity)) + } + }) + } else if (n.$type === 'bpmn:StartEvent') { + // 开始节点 + canvas.addMarker(n.id, 'highlight') + n.outgoing?.forEach((nn) => { + // outgoing 例如说【bpmn:SequenceFlow】连线 + // 获得连线是否有指向目标。如果有,则进行高亮 + let targetActivity = activityList.find((m: any) => m.key === nn.targetRef.id) + if (targetActivity) { + canvas.addMarker(nn.id, 'highlight') // 高亮【bpmn:SequenceFlow】连线 + canvas.addMarker(n.id, 'highlight') // 高亮【bpmn:StartEvent】开始节点(自己) + } + }) + } else if (n.$type === 'bpmn:EndEvent') { + // 结束节点 + if (!processInstance.value || processInstance.value.status === 1) { + return + } + canvas.addMarker(n.id, getResultCss(processInstance.value.status)) + } else if (n.$type === 'bpmn:ServiceTask') { + //服务任务 + if (activity.startTime > 0 && activity.endTime === 0) { + //进入执行,标识进行色 + canvas.addMarker(n.id, getResultCss(1)) + } + if (activity.endTime > 0) { + // 执行完成,节点标识完成色, 所有outgoing标识完成色。 + canvas.addMarker(n.id, getResultCss(2)) + const outgoing = getActivityOutgoing(activity) + outgoing?.forEach((out) => { + canvas.addMarker(out.id, getResultCss(2)) + }) + } + } else if (n.$type === 'bpmn:SequenceFlow') { + let targetActivity = activityList.find((m: any) => m.key === n.targetRef.id) + if (targetActivity) { + canvas.addMarker(n.id, getActivityHighlightCss(targetActivity)) + } + } + }) + if (!isEmpty(removeTaskDefinitionKeyList)) { + taskList.value = taskList.value.filter( + (item) => !removeTaskDefinitionKeyList.includes(item.taskDefinitionKey) + ) + } +} + +const getActivityHighlightCss = (activity) => { + return activity.endTime ? 'highlight' : 'highlight-todo' +} + +const getResultCss = (status) => { + if (status === 1) { + // 审批中 + return 'highlight-todo' + } else if (status === 2) { + // 已通过 + return 'highlight' + } else if (status === 3) { + // 不通过 + return 'highlight-reject' + } else if (status === 4) { + // 已取消 + return 'highlight-cancel' + } else if (status === 5) { + // 退回 + return 'highlight-return' + } else if (status === 6) { + // 委派 + return 'highlight-todo' + } else if (status === 7) { + // 审批通过中 + return 'highlight-todo' + } else if (status === 0) { + // 待审批 + return 'highlight-todo' + } + return '' +} + +const getActivityOutgoing = (activity) => { + // 如果有 outgoing,则直接使用它 + if (activity.outgoing && activity.outgoing.length > 0) { + return activity.outgoing + } + // 如果没有,则遍历获得起点为它的【bpmn:SequenceFlow】节点们。原因是:bpmn-js 的 UserTask 拿不到 outgoing + const flowElements = bpmnModeler.getDefinitions().rootElements[0].flowElements + const outgoing: any[] = [] + flowElements.forEach((item: any) => { + if (item.$type !== 'bpmn:SequenceFlow') { + return + } + if (item.sourceRef.id === activity.key) { + outgoing.push(item) + } + }) + return outgoing +} +const initModelListeners = () => { + const EventBus = bpmnModeler.get('eventBus') + // 注册需要的监听事件 + EventBus.on('element.hover', function (eventObj) { + let element = eventObj ? eventObj.element : null + elementHover(element) + }) + EventBus.on('element.out', function (eventObj) { + let element = eventObj ? eventObj.element : null + elementOut(element) + }) +} +// 流程图的元素被 hover +const elementHover = (element) => { + element.value = element + !elementOverlayIds.value && (elementOverlayIds.value = {}) + !overlays.value && (overlays.value = bpmnModeler.get('overlays')) + // 展示信息 + // console.log(activityLists.value, 'activityLists.value') + // console.log(element.value, 'element.value') + const activity = activityLists.value.find((m) => m.key === element.value.id) + // console.log(activity, 'activityactivityactivityactivity') + if (!activity) { + return + } + if (!elementOverlayIds.value[element.value.id] && element.value.type !== 'bpmn:Process') { + let html = `<div class="element-overlays"> + <p>Elemet id: ${element.value.id}</p> + <p>Elemet type: ${element.value.type}</p> + </div>` // 默认值 + if (element.value.type === 'bpmn:StartEvent' && processInstance.value) { + html = `<p>发起人:${processInstance.value.startUser.nickname}</p> + <p>部门:${processInstance.value.startUser.deptName}</p> + <p>创建时间:${formatDate(processInstance.value.createTime)}` + } else if (element.value.type === 'bpmn:UserTask') { + let task = taskList.value.find((m) => m.id === activity.taskId) // 找到活动对应的 taskId + if (!task) { + return + } + let optionData = getIntDictOptions(DICT_TYPE.BPM_TASK_STATUS) + let dataResult = '' + optionData.forEach((element) => { + if (element.value == task.status) { + dataResult = element.label + } + }) + html = `<p>审批人:${task.assigneeUser.nickname}</p> + <p>部门:${task.assigneeUser.deptName}</p> + <p>结果:${dataResult}</p> + <p>创建时间:${formatDate(task.createTime)}</p>` + // html = `<p>审批人:${task.assigneeUser.nickname}</p> + // <p>部门:${task.assigneeUser.deptName}</p> + // <p>结果:${getIntDictOptions( + // DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT, + // task.status + // )}</p> + // <p>创建时间:${formatDate(task.createTime)}</p>` + if (task.endTime) { + html += `<p>结束时间:${formatDate(task.endTime)}</p>` + } + if (task.reason) { + html += `<p>审批建议:${task.reason}</p>` + } + } else if (element.value.type === 'bpmn:ServiceTask' && processInstance.value) { + if (activity.startTime > 0) { + html = `<p>创建时间:${formatDate(activity.startTime)}</p>` + } + if (activity.endTime > 0) { + html += `<p>结束时间:${formatDate(activity.endTime)}</p>` + } + console.log(html) + } else if (element.value.type === 'bpmn:EndEvent' && processInstance.value) { + let optionData = getIntDictOptions(DICT_TYPE.BPM_TASK_STATUS) + let dataResult = '' + optionData.forEach((element) => { + if (element.value == processInstance.value.status) { + dataResult = element.label + } + }) + html = `<p>结果:${dataResult}</p>` + // html = `<p>结果:${getIntDictOptions( + // DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT, + // processInstance.value.status + // )}</p>` + if (processInstance.value.endTime) { + html += `<p>结束时间:${formatDate(processInstance.value.endTime)}</p>` + } + } + // console.log(html, 'html111111111111111') + elementOverlayIds.value[element.value.id] = toRaw(overlays.value)?.add(element.value, { + position: { left: 0, bottom: 0 }, + html: `<div class="element-overlays">${html}</div>` + }) + } +} + +// 流程图的元素被 out +const elementOut = (element) => { + toRaw(overlays.value).remove({ element }) + elementOverlayIds.value[element.id] = null +} + +onMounted(() => { + xml.value = props.value + activityLists.value = props.activityData + // 初始化 + initBpmnModeler() + createNewDiagram(xml.value) + // 初始模型的监听器 + initModelListeners() +}) + +onBeforeUnmount(() => { + // this.$once('hook:beforeDestroy', () => { + // }) + if (bpmnModeler) bpmnModeler.destroy() + emit('destroy', bpmnModeler) + bpmnModeler = null +}) + +watch( + () => props.value, + (newValue) => { + xml.value = newValue + createNewDiagram(xml.value) + } +) +watch( + () => props.activityData, + (newActivityData) => { + activityLists.value = newActivityData + createNewDiagram(xml.value) + } +) +watch( + () => props.processInstanceData, + (newProcessInstanceData) => { + processInstance.value = newProcessInstanceData + createNewDiagram(xml.value) + } +) +watch( + () => props.taskData, + (newTaskListData) => { + taskList.value = newTaskListData + createNewDiagram(xml.value) + } +) +</script> + +<style lang="scss"> +/** 处理中 */ +.highlight-todo.djs-connection > .djs-visual > path { + stroke: #1890ff !important; + stroke-dasharray: 4px !important; + fill-opacity: 0.2 !important; +} + +.highlight-todo.djs-shape .djs-visual > :nth-child(1) { + fill: #1890ff !important; + stroke: #1890ff !important; + stroke-dasharray: 4px !important; + fill-opacity: 0.2 !important; +} + +:deep(.highlight-todo.djs-connection > .djs-visual > path) { + stroke: #1890ff !important; + stroke-dasharray: 4px !important; + fill-opacity: 0.2 !important; + marker-end: url('#sequenceflow-end-_E7DFDF-_E7DFDF-803g1kf6zwzmcig1y2ulm5egr'); +} + +:deep(.highlight-todo.djs-shape .djs-visual > :nth-child(1)) { + fill: #1890ff !important; + stroke: #1890ff !important; + stroke-dasharray: 4px !important; + fill-opacity: 0.2 !important; +} + +/** 通过 */ +.highlight.djs-shape .djs-visual > :nth-child(1) { + fill: green !important; + stroke: green !important; + fill-opacity: 0.2 !important; +} + +.highlight.djs-shape .djs-visual > :nth-child(2) { + fill: green !important; +} + +.highlight.djs-shape .djs-visual > path { + fill: green !important; + fill-opacity: 0.2 !important; + stroke: green !important; +} + +.highlight.djs-connection > .djs-visual > path { + stroke: green !important; +} + +.highlight:not(.djs-connection) .djs-visual > :nth-child(1) { + fill: green !important; /* color elements as green */ +} + +:deep(.highlight.djs-shape .djs-visual > :nth-child(1)) { + fill: green !important; + stroke: green !important; + fill-opacity: 0.2 !important; +} + +:deep(.highlight.djs-shape .djs-visual > :nth-child(2)) { + fill: green !important; +} + +:deep(.highlight.djs-shape .djs-visual > path) { + fill: green !important; + fill-opacity: 0.2 !important; + stroke: green !important; +} + +:deep(.highlight.djs-connection > .djs-visual > path) { + stroke: green !important; +} + +.djs-element.highlight > .djs-visual > path { + stroke: green !important; +} + +/** 不通过 */ +.highlight-reject.djs-shape .djs-visual > :nth-child(1) { + fill: red !important; + stroke: red !important; + fill-opacity: 0.2 !important; +} + +.highlight-reject.djs-shape .djs-visual > :nth-child(2) { + fill: red !important; +} + +.highlight-reject.djs-shape .djs-visual > path { + fill: red !important; + fill-opacity: 0.2 !important; + stroke: red !important; +} + +.highlight-reject.djs-connection > .djs-visual > path { + stroke: red !important; + marker-end: url(#sequenceflow-end-white-success) !important; +} + +.highlight-reject:not(.djs-connection) .djs-visual > :nth-child(1) { + fill: red !important; /* color elements as green */ +} + +:deep(.highlight-reject.djs-shape .djs-visual > :nth-child(1)) { + fill: red !important; + stroke: red !important; + fill-opacity: 0.2 !important; +} + +:deep(.highlight-reject.djs-shape .djs-visual > :nth-child(2)) { + fill: red !important; +} + +:deep(.highlight-reject.djs-shape .djs-visual > path) { + fill: red !important; + fill-opacity: 0.2 !important; + stroke: red !important; +} + +:deep(.highlight-reject.djs-connection > .djs-visual > path) { + stroke: red !important; +} + +/** 已取消 */ +.highlight-cancel.djs-shape .djs-visual > :nth-child(1) { + fill: grey !important; + stroke: grey !important; + fill-opacity: 0.2 !important; +} + +.highlight-cancel.djs-shape .djs-visual > :nth-child(2) { + fill: grey !important; +} + +.highlight-cancel.djs-shape .djs-visual > path { + fill: grey !important; + fill-opacity: 0.2 !important; + stroke: grey !important; +} + +.highlight-cancel.djs-connection > .djs-visual > path { + stroke: grey !important; +} + +.highlight-cancel:not(.djs-connection) .djs-visual > :nth-child(1) { + fill: grey !important; /* color elements as green */ +} + +:deep(.highlight-cancel.djs-shape .djs-visual > :nth-child(1)) { + fill: grey !important; + stroke: grey !important; + fill-opacity: 0.2 !important; +} + +:deep(.highlight-cancel.djs-shape .djs-visual > :nth-child(2)) { + fill: grey !important; +} + +:deep(.highlight-cancel.djs-shape .djs-visual > path) { + fill: grey !important; + fill-opacity: 0.2 !important; + stroke: grey !important; +} + +:deep(.highlight-cancel.djs-connection > .djs-visual > path) { + stroke: grey !important; +} + +/** 回退 */ +.highlight-return.djs-shape .djs-visual > :nth-child(1) { + fill: #e6a23c !important; + stroke: #e6a23c !important; + fill-opacity: 0.2 !important; +} + +.highlight-return.djs-shape .djs-visual > :nth-child(2) { + fill: #e6a23c !important; +} + +.highlight-return.djs-shape .djs-visual > path { + fill: #e6a23c !important; + fill-opacity: 0.2 !important; + stroke: #e6a23c !important; +} + +.highlight-return.djs-connection > .djs-visual > path { + stroke: #e6a23c !important; +} + +.highlight-return:not(.djs-connection) .djs-visual > :nth-child(1) { + fill: #e6a23c !important; /* color elements as green */ +} + +:deep(.highlight-return.djs-shape .djs-visual > :nth-child(1)) { + fill: #e6a23c !important; + stroke: #e6a23c !important; + fill-opacity: 0.2 !important; +} + +:deep(.highlight-return.djs-shape .djs-visual > :nth-child(2)) { + fill: #e6a23c !important; +} + +:deep(.highlight-return.djs-shape .djs-visual > path) { + fill: #e6a23c !important; + fill-opacity: 0.2 !important; + stroke: #e6a23c !important; +} + +:deep(.highlight-return.djs-connection > .djs-visual > path) { + stroke: #e6a23c !important; +} + +.element-overlays { + width: 200px; + padding: 8px; + color: #fafafa; + background: rgb(0 0 0 / 60%); + border-radius: 4px; + box-sizing: border-box; +} +</style> diff --git a/src/components/bpmnProcessDesigner/package/designer/index.ts b/src/components/bpmnProcessDesigner/package/designer/index.ts new file mode 100644 index 0000000..8522846 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/index.ts @@ -0,0 +1,8 @@ +import MyProcessDesigner from './ProcessDesigner.vue' + +MyProcessDesigner.install = function (Vue) { + Vue.component(MyProcessDesigner.name, MyProcessDesigner) +} + +// 流程图的设计器,可编辑 +export default MyProcessDesigner diff --git a/src/components/bpmnProcessDesigner/package/designer/index2.ts b/src/components/bpmnProcessDesigner/package/designer/index2.ts new file mode 100644 index 0000000..ebe8ca7 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/index2.ts @@ -0,0 +1,8 @@ +import MyProcessViewer from './ProcessViewer.vue' + +MyProcessViewer.install = function (Vue) { + Vue.component(MyProcessViewer.name, MyProcessViewer) +} + +// 流程图的查看器,不可编辑 +export default MyProcessViewer diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/contentPadProvider.js b/src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/contentPadProvider.js new file mode 100644 index 0000000..8783493 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/contentPadProvider.js @@ -0,0 +1,423 @@ +import { assign, forEach, isArray } from 'min-dash' + +import { is } from 'bpmn-js/lib/util/ModelUtil' + +import { isExpanded, isEventSubProcess } from 'bpmn-js/lib/util/DiUtil' + +import { isAny } from 'bpmn-js/lib/features/modeling/util/ModelingUtil' + +import { getChildLanes } from 'bpmn-js/lib/features/modeling/util/LaneUtil' + +import { hasPrimaryModifier } from 'diagram-js/lib/util/Mouse' + +/** + * A provider for BPMN 2.0 elements context pad + */ +export default function ContextPadProvider( + config, + injector, + eventBus, + contextPad, + modeling, + elementFactory, + connect, + create, + popupMenu, + canvas, + rules, + translate +) { + config = config || {} + + contextPad.registerProvider(this) + + this._contextPad = contextPad + + this._modeling = modeling + + this._elementFactory = elementFactory + this._connect = connect + this._create = create + this._popupMenu = popupMenu + this._canvas = canvas + this._rules = rules + this._translate = translate + + if (config.autoPlace !== false) { + this._autoPlace = injector.get('autoPlace', false) + } + + eventBus.on('create.end', 250, function (event) { + const context = event.context, + shape = context.shape + + if (!hasPrimaryModifier(event) || !contextPad.isOpen(shape)) { + return + } + + const entries = contextPad.getEntries(shape) + + if (entries.replace) { + entries.replace.action.click(event, shape) + } + }) +} + +ContextPadProvider.$inject = [ + 'config.contextPad', + 'injector', + 'eventBus', + 'contextPad', + 'modeling', + 'elementFactory', + 'connect', + 'create', + 'popupMenu', + 'canvas', + 'rules', + 'translate', + 'elementRegistry' +] + +ContextPadProvider.prototype.getContextPadEntries = function (element) { + const contextPad = this._contextPad, + modeling = this._modeling, + elementFactory = this._elementFactory, + connect = this._connect, + create = this._create, + popupMenu = this._popupMenu, + canvas = this._canvas, + rules = this._rules, + autoPlace = this._autoPlace, + translate = this._translate + + const actions = {} + + if (element.type === 'label') { + return actions + } + + const businessObject = element.businessObject + + function startConnect(event, element) { + connect.start(event, element) + } + + function removeElement() { + modeling.removeElements([element]) + } + + function getReplaceMenuPosition(element) { + const Y_OFFSET = 5 + + const diagramContainer = canvas.getContainer(), + pad = contextPad.getPad(element).html + + const diagramRect = diagramContainer.getBoundingClientRect(), + padRect = pad.getBoundingClientRect() + + const top = padRect.top - diagramRect.top + const left = padRect.left - diagramRect.left + + const pos = { + x: left, + y: top + padRect.height + Y_OFFSET + } + + return pos + } + + /** + * Create an append action + * + * @param {string} type + * @param {string} className + * @param {string} [title] + * @param {Object} [options] + * + * @return {Object} descriptor + */ + function appendAction(type, className, title, options) { + if (typeof title !== 'string') { + options = title + title = translate('Append {type}', { type: type.replace(/^bpmn:/, '') }) + } + + function appendStart(event, element) { + const shape = elementFactory.createShape(assign({ type: type }, options)) + create.start(event, shape, { + source: element + }) + } + + const append = autoPlace + ? function (event, element) { + const shape = elementFactory.createShape(assign({ type: type }, options)) + + autoPlace.append(element, shape) + } + : appendStart + + return { + group: 'model', + className: className, + title: title, + action: { + dragstart: appendStart, + click: append + } + } + } + + function splitLaneHandler(count) { + return function (event, element) { + // actual split + modeling.splitLane(element, count) + + // refresh context pad after split to + // get rid of split icons + contextPad.open(element, true) + } + } + + if (isAny(businessObject, ['bpmn:Lane', 'bpmn:Participant']) && isExpanded(businessObject)) { + const childLanes = getChildLanes(element) + + assign(actions, { + 'lane-insert-above': { + group: 'lane-insert-above', + className: 'bpmn-icon-lane-insert-above', + title: translate('Add Lane above'), + action: { + click: function (event, element) { + modeling.addLane(element, 'top') + } + } + } + }) + + if (childLanes.length < 2) { + if (element.height >= 120) { + assign(actions, { + 'lane-divide-two': { + group: 'lane-divide', + className: 'bpmn-icon-lane-divide-two', + title: translate('Divide into two Lanes'), + action: { + click: splitLaneHandler(2) + } + } + }) + } + + if (element.height >= 180) { + assign(actions, { + 'lane-divide-three': { + group: 'lane-divide', + className: 'bpmn-icon-lane-divide-three', + title: translate('Divide into three Lanes'), + action: { + click: splitLaneHandler(3) + } + } + }) + } + } + + assign(actions, { + 'lane-insert-below': { + group: 'lane-insert-below', + className: 'bpmn-icon-lane-insert-below', + title: translate('Add Lane below'), + action: { + click: function (event, element) { + modeling.addLane(element, 'bottom') + } + } + } + }) + } + + if (is(businessObject, 'bpmn:FlowNode')) { + if (is(businessObject, 'bpmn:EventBasedGateway')) { + assign(actions, { + 'append.receive-task': appendAction( + 'bpmn:ReceiveTask', + 'bpmn-icon-receive-task', + translate('Append ReceiveTask') + ), + 'append.message-intermediate-event': appendAction( + 'bpmn:IntermediateCatchEvent', + 'bpmn-icon-intermediate-event-catch-message', + translate('Append MessageIntermediateCatchEvent'), + { eventDefinitionType: 'bpmn:MessageEventDefinition' } + ), + 'append.timer-intermediate-event': appendAction( + 'bpmn:IntermediateCatchEvent', + 'bpmn-icon-intermediate-event-catch-timer', + translate('Append TimerIntermediateCatchEvent'), + { eventDefinitionType: 'bpmn:TimerEventDefinition' } + ), + 'append.condition-intermediate-event': appendAction( + 'bpmn:IntermediateCatchEvent', + 'bpmn-icon-intermediate-event-catch-condition', + translate('Append ConditionIntermediateCatchEvent'), + { eventDefinitionType: 'bpmn:ConditionalEventDefinition' } + ), + 'append.signal-intermediate-event': appendAction( + 'bpmn:IntermediateCatchEvent', + 'bpmn-icon-intermediate-event-catch-signal', + translate('Append SignalIntermediateCatchEvent'), + { eventDefinitionType: 'bpmn:SignalEventDefinition' } + ) + }) + } else if ( + isEventType(businessObject, 'bpmn:BoundaryEvent', 'bpmn:CompensateEventDefinition') + ) { + assign(actions, { + 'append.compensation-activity': appendAction( + 'bpmn:Task', + 'bpmn-icon-task', + translate('Append compensation activity'), + { + isForCompensation: true + } + ) + }) + } else if ( + !is(businessObject, 'bpmn:EndEvent') && + !businessObject.isForCompensation && + !isEventType(businessObject, 'bpmn:IntermediateThrowEvent', 'bpmn:LinkEventDefinition') && + !isEventSubProcess(businessObject) + ) { + assign(actions, { + 'append.end-event': appendAction( + 'bpmn:EndEvent', + 'bpmn-icon-end-event-none', + translate('Append EndEvent') + ), + 'append.gateway': appendAction( + 'bpmn:ExclusiveGateway', + 'bpmn-icon-gateway-none', + translate('Append Gateway') + ), + 'append.append-task': appendAction( + 'bpmn:UserTask', + 'bpmn-icon-user-task', + translate('Append Task') + ), + 'append.intermediate-event': appendAction( + 'bpmn:IntermediateThrowEvent', + 'bpmn-icon-intermediate-event-none', + translate('Append Intermediate/Boundary Event') + ) + }) + } + } + + if (!popupMenu.isEmpty(element, 'bpmn-replace')) { + // Replace menu entry + assign(actions, { + replace: { + group: 'edit', + className: 'bpmn-icon-screw-wrench', + title: '修改类型', + action: { + click: function (event, element) { + const position = assign(getReplaceMenuPosition(element), { + cursor: { x: event.x, y: event.y } + }) + + popupMenu.open(element, 'bpmn-replace', position) + } + } + } + }) + } + + if ( + isAny(businessObject, [ + 'bpmn:FlowNode', + 'bpmn:InteractionNode', + 'bpmn:DataObjectReference', + 'bpmn:DataStoreReference' + ]) + ) { + assign(actions, { + 'append.text-annotation': appendAction('bpmn:TextAnnotation', 'bpmn-icon-text-annotation'), + + connect: { + group: 'connect', + className: 'bpmn-icon-connection-multi', + title: translate( + 'Connect using ' + + (businessObject.isForCompensation ? '' : 'Sequence/MessageFlow or ') + + 'Association' + ), + action: { + click: startConnect, + dragstart: startConnect + } + } + }) + } + + if (isAny(businessObject, ['bpmn:DataObjectReference', 'bpmn:DataStoreReference'])) { + assign(actions, { + connect: { + group: 'connect', + className: 'bpmn-icon-connection-multi', + title: translate('Connect using DataInputAssociation'), + action: { + click: startConnect, + dragstart: startConnect + } + } + }) + } + + if (is(businessObject, 'bpmn:Group')) { + assign(actions, { + 'append.text-annotation': appendAction('bpmn:TextAnnotation', 'bpmn-icon-text-annotation') + }) + } + + // delete element entry, only show if allowed by rules + let deleteAllowed = rules.allowed('elements.delete', { elements: [element] }) + + if (isArray(deleteAllowed)) { + // was the element returned as a deletion candidate? + deleteAllowed = deleteAllowed[0] === element + } + + if (deleteAllowed) { + assign(actions, { + delete: { + group: 'edit', + className: 'bpmn-icon-trash', + title: translate('Remove'), + action: { + click: removeElement + } + } + }) + } + + return actions +} + +// helpers ///////// + +function isEventType(eventBo, type, definition) { + const isType = eventBo.$instanceOf(type) + let isDefinition = false + + const definitions = eventBo.eventDefinitions || [] + forEach(definitions, function (def) { + if (def.$type === definition) { + isDefinition = true + } + }) + + return isType && isDefinition +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/index.js b/src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/index.js new file mode 100644 index 0000000..80009ef --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/content-pad/index.js @@ -0,0 +1,6 @@ +import CustomContextPadProvider from './contentPadProvider' + +export default { + __init__: ['contextPadProvider'], + contextPadProvider: ['type', CustomContextPadProvider] +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/defaultEmpty.js b/src/components/bpmnProcessDesigner/package/designer/plugins/defaultEmpty.js new file mode 100644 index 0000000..f3bc894 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/defaultEmpty.js @@ -0,0 +1,24 @@ +export default (key, name, type) => { + if (!type) type = 'camunda' + const TYPE_TARGET = { + activiti: 'http://activiti.org/bpmn', + camunda: 'http://bpmn.io/schema/bpmn', + flowable: 'http://flowable.org/bpmn' + } + return `<?xml version="1.0" encoding="UTF-8"?> +<bpmn2:definitions + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns:bpmn2="http://www.omg.org/spec/BPMN/20100524/MODEL" + xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" + xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" + xmlns:di="http://www.omg.org/spec/DD/20100524/DI" + id="diagram_${key}" + targetNamespace="${TYPE_TARGET[type]}"> + <bpmn2:process id="${key}" name="${name}" isExecutable="true"> + </bpmn2:process> + <bpmndi:BPMNDiagram id="BPMNDiagram_1"> + <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="${key}"> + </bpmndi:BPMNPlane> + </bpmndi:BPMNDiagram> +</bpmn2:definitions>` +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/activitiDescriptor.json b/src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/activitiDescriptor.json new file mode 100644 index 0000000..94ba8f6 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/activitiDescriptor.json @@ -0,0 +1,1004 @@ +{ + "name": "Activiti", + "uri": "http://activiti.org/bpmn", + "prefix": "activiti", + "xml": { + "tagAlias": "lowerCase" + }, + "associations": [], + "types": [ + { + "name": "Definitions", + "isAbstract": true, + "extends": ["bpmn:Definitions"], + "properties": [ + { + "name": "diagramRelationId", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "InOutBinding", + "superClass": ["Element"], + "isAbstract": true, + "properties": [ + { + "name": "source", + "isAttr": true, + "type": "String" + }, + { + "name": "sourceExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "target", + "isAttr": true, + "type": "String" + }, + { + "name": "businessKey", + "isAttr": true, + "type": "String" + }, + { + "name": "local", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "variables", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "In", + "superClass": ["InOutBinding"], + "meta": { + "allowedIn": ["bpmn:CallActivity"] + } + }, + { + "name": "Out", + "superClass": ["InOutBinding"], + "meta": { + "allowedIn": ["bpmn:CallActivity"] + } + }, + { + "name": "AsyncCapable", + "isAbstract": true, + "extends": ["bpmn:Activity", "bpmn:Gateway", "bpmn:Event"], + "properties": [ + { + "name": "async", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "asyncBefore", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "asyncAfter", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "exclusive", + "isAttr": true, + "type": "Boolean", + "default": true + } + ] + }, + { + "name": "JobPriorized", + "isAbstract": true, + "extends": ["bpmn:Process", "activiti:AsyncCapable"], + "properties": [ + { + "name": "jobPriority", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "SignalEventDefinition", + "isAbstract": true, + "extends": ["bpmn:SignalEventDefinition"], + "properties": [ + { + "name": "async", + "isAttr": true, + "type": "Boolean", + "default": false + } + ] + }, + { + "name": "ErrorEventDefinition", + "isAbstract": true, + "extends": ["bpmn:ErrorEventDefinition"], + "properties": [ + { + "name": "errorCodeVariable", + "isAttr": true, + "type": "String" + }, + { + "name": "errorMessageVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Error", + "isAbstract": true, + "extends": ["bpmn:Error"], + "properties": [ + { + "name": "activiti:errorMessage", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "PotentialStarter", + "superClass": ["Element"], + "properties": [ + { + "name": "resourceAssignmentExpression", + "type": "bpmn:ResourceAssignmentExpression" + } + ] + }, + { + "name": "FormSupported", + "isAbstract": true, + "extends": ["bpmn:StartEvent", "bpmn:UserTask"], + "properties": [ + { + "name": "formHandlerClass", + "isAttr": true, + "type": "String" + }, + { + "name": "formKey", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "TemplateSupported", + "isAbstract": true, + "extends": ["bpmn:Process", "bpmn:FlowElement"], + "properties": [ + { + "name": "modelerTemplate", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Initiator", + "isAbstract": true, + "extends": ["bpmn:StartEvent"], + "properties": [ + { + "name": "initiator", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "ScriptTask", + "isAbstract": true, + "extends": ["bpmn:ScriptTask"], + "properties": [ + { + "name": "resultVariable", + "isAttr": true, + "type": "String" + }, + { + "name": "resource", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Process", + "isAbstract": true, + "extends": ["bpmn:Process"], + "properties": [ + { + "name": "candidateStarterGroups", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateStarterUsers", + "isAttr": true, + "type": "String" + }, + { + "name": "versionTag", + "isAttr": true, + "type": "String" + }, + { + "name": "historyTimeToLive", + "isAttr": true, + "type": "String" + }, + { + "name": "isStartableInTasklist", + "isAttr": true, + "type": "Boolean", + "default": true + }, + { + "name": "executionListener", + "isAbstract": true, + "type": "Expression" + } + ] + }, + { + "name": "EscalationEventDefinition", + "isAbstract": true, + "extends": ["bpmn:EscalationEventDefinition"], + "properties": [ + { + "name": "escalationCodeVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "FormalExpression", + "isAbstract": true, + "extends": ["bpmn:FormalExpression"], + "properties": [ + { + "name": "resource", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "multiinstance_type", + "superClass": ["Element"] + }, + { + "name": "multiinstance_condition", + "superClass": ["Element"] + }, + { + "name": "Assignable", + "extends": ["bpmn:UserTask"], + "properties": [ + { + "name": "assignee", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateUsers", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateGroups", + "isAttr": true, + "type": "String" + }, + { + "name": "dueDate", + "isAttr": true, + "type": "String" + }, + { + "name": "followUpDate", + "isAttr": true, + "type": "String" + }, + { + "name": "priority", + "isAttr": true, + "type": "String" + }, + { + "name": "multiinstance_condition", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateStrategy", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateParam", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "CallActivity", + "extends": ["bpmn:CallActivity"], + "properties": [ + { + "name": "calledElementBinding", + "isAttr": true, + "type": "String", + "default": "latest" + }, + { + "name": "calledElementVersion", + "isAttr": true, + "type": "String" + }, + { + "name": "calledElementVersionTag", + "isAttr": true, + "type": "String" + }, + { + "name": "calledElementTenantId", + "isAttr": true, + "type": "String" + }, + { + "name": "caseRef", + "isAttr": true, + "type": "String" + }, + { + "name": "caseBinding", + "isAttr": true, + "type": "String", + "default": "latest" + }, + { + "name": "caseVersion", + "isAttr": true, + "type": "String" + }, + { + "name": "caseTenantId", + "isAttr": true, + "type": "String" + }, + { + "name": "variableMappingClass", + "isAttr": true, + "type": "String" + }, + { + "name": "variableMappingDelegateExpression", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "ServiceTaskLike", + "extends": [ + "bpmn:ServiceTask", + "bpmn:BusinessRuleTask", + "bpmn:SendTask", + "bpmn:MessageEventDefinition" + ], + "properties": [ + { + "name": "expression", + "isAttr": true, + "type": "String" + }, + { + "name": "class", + "isAttr": true, + "type": "String" + }, + { + "name": "delegateExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "resultVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "DmnCapable", + "extends": ["bpmn:BusinessRuleTask"], + "properties": [ + { + "name": "decisionRef", + "isAttr": true, + "type": "String" + }, + { + "name": "decisionRefBinding", + "isAttr": true, + "type": "String", + "default": "latest" + }, + { + "name": "decisionRefVersion", + "isAttr": true, + "type": "String" + }, + { + "name": "mapDecisionResult", + "isAttr": true, + "type": "String", + "default": "resultList" + }, + { + "name": "decisionRefTenantId", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "ExternalCapable", + "extends": ["activiti:ServiceTaskLike"], + "properties": [ + { + "name": "type", + "isAttr": true, + "type": "String" + }, + { + "name": "topic", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "TaskPriorized", + "extends": ["bpmn:Process", "activiti:ExternalCapable"], + "properties": [ + { + "name": "taskPriority", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Properties", + "superClass": ["Element"], + "meta": { + "allowedIn": ["*"] + }, + "properties": [ + { + "name": "values", + "type": "Property", + "isMany": true + } + ] + }, + { + "name": "Property", + "superClass": ["Element"], + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "value", + "type": "String", + "isAttr": true + } + ] + }, + { + "name": "Connector", + "superClass": ["Element"], + "meta": { + "allowedIn": ["activiti:ServiceTaskLike"] + }, + "properties": [ + { + "name": "inputOutput", + "type": "InputOutput" + }, + { + "name": "connectorId", + "type": "String" + } + ] + }, + { + "name": "InputOutput", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:FlowNode", "activiti:Connector"] + }, + "properties": [ + { + "name": "inputOutput", + "type": "InputOutput" + }, + { + "name": "connectorId", + "type": "String" + }, + { + "name": "inputParameters", + "isMany": true, + "type": "InputParameter" + }, + { + "name": "outputParameters", + "isMany": true, + "type": "OutputParameter" + } + ] + }, + { + "name": "InputOutputParameter", + "properties": [ + { + "name": "name", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + }, + { + "name": "definition", + "type": "InputOutputParameterDefinition" + } + ] + }, + { + "name": "InputOutputParameterDefinition", + "isAbstract": true + }, + { + "name": "List", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "items", + "isMany": true, + "type": "InputOutputParameterDefinition" + } + ] + }, + { + "name": "Map", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "entries", + "isMany": true, + "type": "Entry" + } + ] + }, + { + "name": "Entry", + "properties": [ + { + "name": "key", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + }, + { + "name": "definition", + "type": "InputOutputParameterDefinition" + } + ] + }, + { + "name": "Value", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "id", + "isAttr": true, + "type": "String" + }, + { + "name": "name", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + } + ] + }, + { + "name": "Script", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "scriptFormat", + "isAttr": true, + "type": "String" + }, + { + "name": "resource", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + } + ] + }, + { + "name": "Field", + "superClass": ["Element"], + "meta": { + "allowedIn": [ + "activiti:ServiceTaskLike", + "activiti:ExecutionListener", + "activiti:TaskListener" + ] + }, + "properties": [ + { + "name": "name", + "isAttr": true, + "type": "String" + }, + { + "name": "expression", + "type": "String" + }, + { + "name": "stringValue", + "isAttr": true, + "type": "String" + }, + { + "name": "string", + "type": "String" + } + ] + }, + { + "name": "InputParameter", + "superClass": ["InputOutputParameter"] + }, + { + "name": "OutputParameter", + "superClass": ["InputOutputParameter"] + }, + { + "name": "Collectable", + "isAbstract": true, + "extends": ["bpmn:MultiInstanceLoopCharacteristics"], + "superClass": ["activiti:AsyncCapable"], + "properties": [ + { + "name": "collection", + "isAttr": true, + "type": "String" + }, + { + "name": "elementVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "FailedJobRetryTimeCycle", + "superClass": ["Element"], + "meta": { + "allowedIn": ["activiti:AsyncCapable", "bpmn:MultiInstanceLoopCharacteristics"] + }, + "properties": [ + { + "name": "body", + "isBody": true, + "type": "String" + } + ] + }, + { + "name": "ExecutionListener", + "superClass": ["Element"], + "meta": { + "allowedIn": [ + "bpmn:Task", + "bpmn:ServiceTask", + "bpmn:UserTask", + "bpmn:BusinessRuleTask", + "bpmn:ScriptTask", + "bpmn:ReceiveTask", + "bpmn:ManualTask", + "bpmn:ExclusiveGateway", + "bpmn:SequenceFlow", + "bpmn:ParallelGateway", + "bpmn:InclusiveGateway", + "bpmn:EventBasedGateway", + "bpmn:StartEvent", + "bpmn:IntermediateCatchEvent", + "bpmn:IntermediateThrowEvent", + "bpmn:EndEvent", + "bpmn:BoundaryEvent", + "bpmn:CallActivity", + "bpmn:SubProcess", + "bpmn:Process" + ] + }, + "properties": [ + { + "name": "expression", + "isAttr": true, + "type": "String" + }, + { + "name": "class", + "isAttr": true, + "type": "String" + }, + { + "name": "delegateExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "event", + "isAttr": true, + "type": "String" + }, + { + "name": "script", + "type": "Script" + }, + { + "name": "fields", + "type": "Field", + "isMany": true + } + ] + }, + { + "name": "TaskListener", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:UserTask"] + }, + "properties": [ + { + "name": "expression", + "isAttr": true, + "type": "String" + }, + { + "name": "class", + "isAttr": true, + "type": "String" + }, + { + "name": "delegateExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "event", + "isAttr": true, + "type": "String" + }, + { + "name": "script", + "type": "Script" + }, + { + "name": "fields", + "type": "Field", + "isMany": true + } + ] + }, + { + "name": "FormProperty", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:StartEvent", "bpmn:UserTask"] + }, + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "type", + "type": "String", + "isAttr": true + }, + { + "name": "required", + "type": "String", + "isAttr": true + }, + { + "name": "readable", + "type": "String", + "isAttr": true + }, + { + "name": "writable", + "type": "String", + "isAttr": true + }, + { + "name": "variable", + "type": "String", + "isAttr": true + }, + { + "name": "expression", + "type": "String", + "isAttr": true + }, + { + "name": "datePattern", + "type": "String", + "isAttr": true + }, + { + "name": "default", + "type": "String", + "isAttr": true + }, + { + "name": "values", + "type": "Value", + "isMany": true + } + ] + }, + { + "name": "FormProperty", + "superClass": ["Element"], + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "label", + "type": "String", + "isAttr": true + }, + { + "name": "type", + "type": "String", + "isAttr": true + }, + { + "name": "datePattern", + "type": "String", + "isAttr": true + }, + { + "name": "defaultValue", + "type": "String", + "isAttr": true + }, + { + "name": "properties", + "type": "Properties" + }, + { + "name": "validation", + "type": "Validation" + }, + { + "name": "values", + "type": "Value", + "isMany": true + } + ] + }, + { + "name": "Validation", + "superClass": ["Element"], + "properties": [ + { + "name": "constraints", + "type": "Constraint", + "isMany": true + } + ] + }, + { + "name": "Constraint", + "superClass": ["Element"], + "properties": [ + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "config", + "type": "String", + "isAttr": true + } + ] + }, + { + "name": "ConditionalEventDefinition", + "isAbstract": true, + "extends": ["bpmn:ConditionalEventDefinition"], + "properties": [ + { + "name": "variableName", + "isAttr": true, + "type": "String" + }, + { + "name": "variableEvent", + "isAttr": true, + "type": "String" + } + ] + } + ], + "emumerations": [] +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/camundaDescriptor.json b/src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/camundaDescriptor.json new file mode 100644 index 0000000..8322561 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/camundaDescriptor.json @@ -0,0 +1,1020 @@ +{ + "name": "Camunda", + "uri": "http://camunda.org/schema/1.0/bpmn", + "prefix": "camunda", + "xml": { + "tagAlias": "lowerCase" + }, + "associations": [], + "types": [ + { + "name": "Definitions", + "isAbstract": true, + "extends": ["bpmn:Definitions"], + "properties": [ + { + "name": "diagramRelationId", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "InOutBinding", + "superClass": ["Element"], + "isAbstract": true, + "properties": [ + { + "name": "source", + "isAttr": true, + "type": "String" + }, + { + "name": "sourceExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "target", + "isAttr": true, + "type": "String" + }, + { + "name": "businessKey", + "isAttr": true, + "type": "String" + }, + { + "name": "local", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "variables", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "In", + "superClass": ["InOutBinding"], + "meta": { + "allowedIn": ["bpmn:CallActivity", "bpmn:SignalEventDefinition"] + } + }, + { + "name": "Out", + "superClass": ["InOutBinding"], + "meta": { + "allowedIn": ["bpmn:CallActivity"] + } + }, + { + "name": "AsyncCapable", + "isAbstract": true, + "extends": ["bpmn:Activity", "bpmn:Gateway", "bpmn:Event"], + "properties": [ + { + "name": "async", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "asyncBefore", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "asyncAfter", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "exclusive", + "isAttr": true, + "type": "Boolean", + "default": true + } + ] + }, + { + "name": "JobPriorized", + "isAbstract": true, + "extends": ["bpmn:Process", "camunda:AsyncCapable"], + "properties": [ + { + "name": "jobPriority", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "SignalEventDefinition", + "isAbstract": true, + "extends": ["bpmn:SignalEventDefinition"], + "properties": [ + { + "name": "async", + "isAttr": true, + "type": "Boolean", + "default": false + } + ] + }, + { + "name": "ErrorEventDefinition", + "isAbstract": true, + "extends": ["bpmn:ErrorEventDefinition"], + "properties": [ + { + "name": "errorCodeVariable", + "isAttr": true, + "type": "String" + }, + { + "name": "errorMessageVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Error", + "isAbstract": true, + "extends": ["bpmn:Error"], + "properties": [ + { + "name": "camunda:errorMessage", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "PotentialStarter", + "superClass": ["Element"], + "properties": [ + { + "name": "resourceAssignmentExpression", + "type": "bpmn:ResourceAssignmentExpression" + } + ] + }, + { + "name": "FormSupported", + "isAbstract": true, + "extends": ["bpmn:StartEvent", "bpmn:UserTask"], + "properties": [ + { + "name": "formHandlerClass", + "isAttr": true, + "type": "String" + }, + { + "name": "formKey", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "TemplateSupported", + "isAbstract": true, + "extends": ["bpmn:Process", "bpmn:FlowElement"], + "properties": [ + { + "name": "modelerTemplate", + "isAttr": true, + "type": "String" + }, + { + "name": "modelerTemplateVersion", + "isAttr": true, + "type": "Integer" + } + ] + }, + { + "name": "Initiator", + "isAbstract": true, + "extends": ["bpmn:StartEvent"], + "properties": [ + { + "name": "initiator", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "ScriptTask", + "isAbstract": true, + "extends": ["bpmn:ScriptTask"], + "properties": [ + { + "name": "resultVariable", + "isAttr": true, + "type": "String" + }, + { + "name": "resource", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Process", + "isAbstract": true, + "extends": ["bpmn:Process"], + "properties": [ + { + "name": "candidateStarterGroups", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateStarterUsers", + "isAttr": true, + "type": "String" + }, + { + "name": "versionTag", + "isAttr": true, + "type": "String" + }, + { + "name": "historyTimeToLive", + "isAttr": true, + "type": "String" + }, + { + "name": "isStartableInTasklist", + "isAttr": true, + "type": "Boolean", + "default": true + } + ] + }, + { + "name": "EscalationEventDefinition", + "isAbstract": true, + "extends": ["bpmn:EscalationEventDefinition"], + "properties": [ + { + "name": "escalationCodeVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "FormalExpression", + "isAbstract": true, + "extends": ["bpmn:FormalExpression"], + "properties": [ + { + "name": "resource", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Assignable", + "extends": ["bpmn:UserTask"], + "properties": [ + { + "name": "assignee", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateUsers", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateGroups", + "isAttr": true, + "type": "String" + }, + { + "name": "dueDate", + "isAttr": true, + "type": "String" + }, + { + "name": "followUpDate", + "isAttr": true, + "type": "String" + }, + { + "name": "priority", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateStrategy", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateParam", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "CallActivity", + "extends": ["bpmn:CallActivity"], + "properties": [ + { + "name": "calledElementBinding", + "isAttr": true, + "type": "String", + "default": "latest" + }, + { + "name": "calledElementVersion", + "isAttr": true, + "type": "String" + }, + { + "name": "calledElementVersionTag", + "isAttr": true, + "type": "String" + }, + { + "name": "calledElementTenantId", + "isAttr": true, + "type": "String" + }, + { + "name": "caseRef", + "isAttr": true, + "type": "String" + }, + { + "name": "caseBinding", + "isAttr": true, + "type": "String", + "default": "latest" + }, + { + "name": "caseVersion", + "isAttr": true, + "type": "String" + }, + { + "name": "caseTenantId", + "isAttr": true, + "type": "String" + }, + { + "name": "variableMappingClass", + "isAttr": true, + "type": "String" + }, + { + "name": "variableMappingDelegateExpression", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "ServiceTaskLike", + "extends": [ + "bpmn:ServiceTask", + "bpmn:BusinessRuleTask", + "bpmn:SendTask", + "bpmn:MessageEventDefinition" + ], + "properties": [ + { + "name": "expression", + "isAttr": true, + "type": "String" + }, + { + "name": "class", + "isAttr": true, + "type": "String" + }, + { + "name": "delegateExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "resultVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "DmnCapable", + "extends": ["bpmn:BusinessRuleTask"], + "properties": [ + { + "name": "decisionRef", + "isAttr": true, + "type": "String" + }, + { + "name": "decisionRefBinding", + "isAttr": true, + "type": "String", + "default": "latest" + }, + { + "name": "decisionRefVersion", + "isAttr": true, + "type": "String" + }, + { + "name": "mapDecisionResult", + "isAttr": true, + "type": "String", + "default": "resultList" + }, + { + "name": "decisionRefTenantId", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "ExternalCapable", + "extends": ["camunda:ServiceTaskLike"], + "properties": [ + { + "name": "type", + "isAttr": true, + "type": "String" + }, + { + "name": "topic", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "TaskPriorized", + "extends": ["bpmn:Process", "camunda:ExternalCapable"], + "properties": [ + { + "name": "taskPriority", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Properties", + "superClass": ["Element"], + "meta": { + "allowedIn": ["*"] + }, + "properties": [ + { + "name": "values", + "type": "Property", + "isMany": true + } + ] + }, + { + "name": "Property", + "superClass": ["Element"], + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "value", + "type": "String", + "isAttr": true + } + ] + }, + { + "name": "Connector", + "superClass": ["Element"], + "meta": { + "allowedIn": ["camunda:ServiceTaskLike"] + }, + "properties": [ + { + "name": "inputOutput", + "type": "InputOutput" + }, + { + "name": "connectorId", + "type": "String" + } + ] + }, + { + "name": "InputOutput", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:FlowNode", "camunda:Connector"] + }, + "properties": [ + { + "name": "inputOutput", + "type": "InputOutput" + }, + { + "name": "connectorId", + "type": "String" + }, + { + "name": "inputParameters", + "isMany": true, + "type": "InputParameter" + }, + { + "name": "outputParameters", + "isMany": true, + "type": "OutputParameter" + } + ] + }, + { + "name": "InputOutputParameter", + "properties": [ + { + "name": "name", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + }, + { + "name": "definition", + "type": "InputOutputParameterDefinition" + } + ] + }, + { + "name": "InputOutputParameterDefinition", + "isAbstract": true + }, + { + "name": "List", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "items", + "isMany": true, + "type": "InputOutputParameterDefinition" + } + ] + }, + { + "name": "Map", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "entries", + "isMany": true, + "type": "Entry" + } + ] + }, + { + "name": "Entry", + "properties": [ + { + "name": "key", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + }, + { + "name": "definition", + "type": "InputOutputParameterDefinition" + } + ] + }, + { + "name": "Value", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "id", + "isAttr": true, + "type": "String" + }, + { + "name": "name", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + } + ] + }, + { + "name": "Script", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "scriptFormat", + "isAttr": true, + "type": "String" + }, + { + "name": "resource", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + } + ] + }, + { + "name": "Field", + "superClass": ["Element"], + "meta": { + "allowedIn": [ + "camunda:ServiceTaskLike", + "camunda:ExecutionListener", + "camunda:TaskListener" + ] + }, + "properties": [ + { + "name": "name", + "isAttr": true, + "type": "String" + }, + { + "name": "expression", + "type": "String" + }, + { + "name": "stringValue", + "isAttr": true, + "type": "String" + }, + { + "name": "string", + "type": "String" + } + ] + }, + { + "name": "InputParameter", + "superClass": ["InputOutputParameter"] + }, + { + "name": "OutputParameter", + "superClass": ["InputOutputParameter"] + }, + { + "name": "Collectable", + "isAbstract": true, + "extends": ["bpmn:MultiInstanceLoopCharacteristics"], + "superClass": ["camunda:AsyncCapable"], + "properties": [ + { + "name": "collection", + "isAttr": true, + "type": "String" + }, + { + "name": "elementVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "FailedJobRetryTimeCycle", + "superClass": ["Element"], + "meta": { + "allowedIn": ["camunda:AsyncCapable", "bpmn:MultiInstanceLoopCharacteristics"] + }, + "properties": [ + { + "name": "body", + "isBody": true, + "type": "String" + } + ] + }, + { + "name": "ExecutionListener", + "superClass": ["Element"], + "meta": { + "allowedIn": [ + "bpmn:Task", + "bpmn:ServiceTask", + "bpmn:UserTask", + "bpmn:BusinessRuleTask", + "bpmn:ScriptTask", + "bpmn:ReceiveTask", + "bpmn:ManualTask", + "bpmn:ExclusiveGateway", + "bpmn:SequenceFlow", + "bpmn:ParallelGateway", + "bpmn:InclusiveGateway", + "bpmn:EventBasedGateway", + "bpmn:StartEvent", + "bpmn:IntermediateCatchEvent", + "bpmn:IntermediateThrowEvent", + "bpmn:EndEvent", + "bpmn:BoundaryEvent", + "bpmn:CallActivity", + "bpmn:SubProcess", + "bpmn:Process" + ] + }, + "properties": [ + { + "name": "expression", + "isAttr": true, + "type": "String" + }, + { + "name": "class", + "isAttr": true, + "type": "String" + }, + { + "name": "delegateExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "event", + "isAttr": true, + "type": "String" + }, + { + "name": "script", + "type": "Script" + }, + { + "name": "fields", + "type": "Field", + "isMany": true + } + ] + }, + { + "name": "TaskListener", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:UserTask"] + }, + "properties": [ + { + "name": "expression", + "isAttr": true, + "type": "String" + }, + { + "name": "class", + "isAttr": true, + "type": "String" + }, + { + "name": "delegateExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "event", + "isAttr": true, + "type": "String" + }, + { + "name": "script", + "type": "Script" + }, + { + "name": "fields", + "type": "Field", + "isMany": true + }, + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "eventDefinitions", + "type": "bpmn:TimerEventDefinition", + "isMany": true + } + ] + }, + { + "name": "FormProperty", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:StartEvent", "bpmn:UserTask"] + }, + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "type", + "type": "String", + "isAttr": true + }, + { + "name": "required", + "type": "String", + "isAttr": true + }, + { + "name": "readable", + "type": "String", + "isAttr": true + }, + { + "name": "writable", + "type": "String", + "isAttr": true + }, + { + "name": "variable", + "type": "String", + "isAttr": true + }, + { + "name": "expression", + "type": "String", + "isAttr": true + }, + { + "name": "datePattern", + "type": "String", + "isAttr": true + }, + { + "name": "default", + "type": "String", + "isAttr": true + }, + { + "name": "values", + "type": "Value", + "isMany": true + } + ] + }, + { + "name": "FormData", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:StartEvent", "bpmn:UserTask"] + }, + "properties": [ + { + "name": "fields", + "type": "FormField", + "isMany": true + }, + { + "name": "businessKey", + "type": "String", + "isAttr": true + } + ] + }, + { + "name": "FormField", + "superClass": ["Element"], + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "label", + "type": "String", + "isAttr": true + }, + { + "name": "type", + "type": "String", + "isAttr": true + }, + { + "name": "datePattern", + "type": "String", + "isAttr": true + }, + { + "name": "defaultValue", + "type": "String", + "isAttr": true + }, + { + "name": "properties", + "type": "Properties" + }, + { + "name": "validation", + "type": "Validation" + }, + { + "name": "values", + "type": "Value", + "isMany": true + } + ] + }, + { + "name": "Validation", + "superClass": ["Element"], + "properties": [ + { + "name": "constraints", + "type": "Constraint", + "isMany": true + } + ] + }, + { + "name": "Constraint", + "superClass": ["Element"], + "properties": [ + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "config", + "type": "String", + "isAttr": true + } + ] + }, + { + "name": "ConditionalEventDefinition", + "isAbstract": true, + "extends": ["bpmn:ConditionalEventDefinition"], + "properties": [ + { + "name": "variableName", + "isAttr": true, + "type": "String" + }, + { + "name": "variableEvents", + "isAttr": true, + "type": "String" + } + ] + } + ], + "emumerations": [] +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/flowableDescriptor.json b/src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/flowableDescriptor.json new file mode 100644 index 0000000..4ea632a --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/descriptor/flowableDescriptor.json @@ -0,0 +1,1217 @@ +{ + "name": "Flowable", + "uri": "http://flowable.org/bpmn", + "prefix": "flowable", + "xml": { + "tagAlias": "lowerCase" + }, + "associations": [], + "types": [ + { + "name": "InOutBinding", + "superClass": ["Element"], + "isAbstract": true, + "properties": [ + { + "name": "source", + "isAttr": true, + "type": "String" + }, + { + "name": "sourceExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "target", + "isAttr": true, + "type": "String" + }, + { + "name": "businessKey", + "isAttr": true, + "type": "String" + }, + { + "name": "local", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "variables", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "In", + "superClass": ["InOutBinding"], + "meta": { + "allowedIn": ["bpmn:CallActivity"] + } + }, + { + "name": "Out", + "superClass": ["InOutBinding"], + "meta": { + "allowedIn": ["bpmn:CallActivity"] + } + }, + { + "name": "AsyncCapable", + "isAbstract": true, + "extends": ["bpmn:Activity", "bpmn:Gateway", "bpmn:Event"], + "properties": [ + { + "name": "async", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "asyncBefore", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "asyncAfter", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "exclusive", + "isAttr": true, + "type": "Boolean", + "default": true + } + ] + }, + { + "name": "JobPriorized", + "isAbstract": true, + "extends": ["bpmn:Process", "flowable:AsyncCapable"], + "properties": [ + { + "name": "jobPriority", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "SignalEventDefinition", + "isAbstract": true, + "extends": ["bpmn:SignalEventDefinition"], + "properties": [ + { + "name": "async", + "isAttr": true, + "type": "Boolean", + "default": false + } + ] + }, + { + "name": "ErrorEventDefinition", + "isAbstract": true, + "extends": ["bpmn:ErrorEventDefinition"], + "properties": [ + { + "name": "errorCodeVariable", + "isAttr": true, + "type": "String" + }, + { + "name": "errorMessageVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Error", + "isAbstract": true, + "extends": ["bpmn:Error"], + "properties": [ + { + "name": "flowable:errorMessage", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "PotentialStarter", + "superClass": ["Element"], + "properties": [ + { + "name": "resourceAssignmentExpression", + "type": "bpmn:ResourceAssignmentExpression" + } + ] + }, + { + "name": "FormSupported", + "isAbstract": true, + "extends": ["bpmn:StartEvent", "bpmn:UserTask"], + "properties": [ + { + "name": "formHandlerClass", + "isAttr": true, + "type": "String" + }, + { + "name": "formKey", + "isAttr": true, + "type": "String" + }, + { + "name": "formType", + "isAttr": true, + "type": "String" + }, + { + "name": "formReadOnly", + "isAttr": true, + "type": "Boolean", + "default": false + }, + { + "name": "formInit", + "isAttr": true, + "type": "Boolean", + "default": true + } + ] + }, + { + "name": "TemplateSupported", + "isAbstract": true, + "extends": ["bpmn:Process", "bpmn:FlowElement"], + "properties": [ + { + "name": "modelerTemplate", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Initiator", + "isAbstract": true, + "extends": ["bpmn:StartEvent"], + "properties": [ + { + "name": "initiator", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "ScriptTask", + "isAbstract": true, + "extends": ["bpmn:ScriptTask"], + "properties": [ + { + "name": "resultVariable", + "isAttr": true, + "type": "String" + }, + { + "name": "resource", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Process", + "isAbstract": true, + "extends": ["bpmn:Process"], + "properties": [ + { + "name": "candidateStarterGroups", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateStarterUsers", + "isAttr": true, + "type": "String" + }, + { + "name": "versionTag", + "isAttr": true, + "type": "String" + }, + { + "name": "historyTimeToLive", + "isAttr": true, + "type": "String" + }, + { + "name": "isStartableInTasklist", + "isAttr": true, + "type": "Boolean", + "default": true + } + ] + }, + { + "name": "EscalationEventDefinition", + "isAbstract": true, + "extends": ["bpmn:EscalationEventDefinition"], + "properties": [ + { + "name": "escalationCodeVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "FormalExpression", + "isAbstract": true, + "extends": ["bpmn:FormalExpression"], + "properties": [ + { + "name": "resource", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Assignable", + "extends": ["bpmn:UserTask"], + "properties": [ + { + "name": "assignee", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateUsers", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateGroups", + "isAttr": true, + "type": "String" + }, + { + "name": "dueDate", + "isAttr": true, + "type": "String" + }, + { + "name": "followUpDate", + "isAttr": true, + "type": "String" + }, + { + "name": "priority", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateStrategy", + "isAttr": true, + "type": "String" + }, + { + "name": "candidateParam", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Assignee", + "supperClass": "Element", + "meta": { + "allowedIn": ["*"] + }, + "properties": [ + { + "name": "label", + "type": "String", + "isAttr": true + }, + { + "name": "viewId", + "type": "Number", + "isAttr": true + } + ] + }, + { + "name": "CallActivity", + "extends": ["bpmn:CallActivity"], + "properties": [ + { + "name": "calledElementBinding", + "isAttr": true, + "type": "String", + "default": "latest" + }, + { + "name": "calledElementVersion", + "isAttr": true, + "type": "String" + }, + { + "name": "calledElementVersionTag", + "isAttr": true, + "type": "String" + }, + { + "name": "calledElementTenantId", + "isAttr": true, + "type": "String" + }, + { + "name": "caseRef", + "isAttr": true, + "type": "String" + }, + { + "name": "caseBinding", + "isAttr": true, + "type": "String", + "default": "latest" + }, + { + "name": "caseVersion", + "isAttr": true, + "type": "String" + }, + { + "name": "caseTenantId", + "isAttr": true, + "type": "String" + }, + { + "name": "variableMappingClass", + "isAttr": true, + "type": "String" + }, + { + "name": "variableMappingDelegateExpression", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "ServiceTaskLike", + "extends": [ + "bpmn:ServiceTask", + "bpmn:BusinessRuleTask", + "bpmn:SendTask", + "bpmn:MessageEventDefinition" + ], + "properties": [ + { + "name": "expression", + "isAttr": true, + "type": "String" + }, + { + "name": "class", + "isAttr": true, + "type": "String" + }, + { + "name": "delegateExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "resultVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "DmnCapable", + "extends": ["bpmn:BusinessRuleTask"], + "properties": [ + { + "name": "decisionRef", + "isAttr": true, + "type": "String" + }, + { + "name": "decisionRefBinding", + "isAttr": true, + "type": "String", + "default": "latest" + }, + { + "name": "decisionRefVersion", + "isAttr": true, + "type": "String" + }, + { + "name": "mapDecisionResult", + "isAttr": true, + "type": "String", + "default": "resultList" + }, + { + "name": "decisionRefTenantId", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "ExternalCapable", + "extends": ["flowable:ServiceTaskLike"], + "properties": [ + { + "name": "type", + "isAttr": true, + "type": "String" + }, + { + "name": "topic", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "TaskPriorized", + "extends": ["bpmn:Process", "flowable:ExternalCapable"], + "properties": [ + { + "name": "taskPriority", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Properties", + "superClass": ["Element"], + "meta": { + "allowedIn": ["*"] + }, + "properties": [ + { + "name": "values", + "type": "Property", + "isMany": true + } + ] + }, + { + "name": "Property", + "superClass": ["Element"], + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "value", + "type": "String", + "isAttr": true + } + ] + }, + { + "name": "Button", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:UserTask"] + }, + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "code", + "type": "String", + "isAttr": true + }, + { + "name": "isHide", + "type": "String", + "isAttr": true + }, + { + "name": "next", + "type": "String", + "isAttr": true + }, + { + "name": "sort", + "type": "Integer", + "isAttr": true + } + ] + }, + { + "name": "Assignee", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:UserTask"] + }, + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "type", + "type": "String", + "isAttr": true + }, + { + "name": "value", + "type": "String", + "isAttr": true + }, + { + "name": "condition", + "type": "String", + "isAttr": true + }, + { + "name": "operationType", + "type": "String", + "isAttr": true + }, + { + "name": "sort", + "type": "Integer", + "isAttr": true + } + ] + }, + { + "name": "Connector", + "superClass": ["Element"], + "meta": { + "allowedIn": ["flowable:ServiceTaskLike"] + }, + "properties": [ + { + "name": "inputOutput", + "type": "InputOutput" + }, + { + "name": "connectorId", + "type": "String" + } + ] + }, + { + "name": "InputOutput", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:FlowNode", "flowable:Connector"] + }, + "properties": [ + { + "name": "inputOutput", + "type": "InputOutput" + }, + { + "name": "connectorId", + "type": "String" + }, + { + "name": "inputParameters", + "isMany": true, + "type": "InputParameter" + }, + { + "name": "outputParameters", + "isMany": true, + "type": "OutputParameter" + } + ] + }, + { + "name": "InputOutputParameter", + "properties": [ + { + "name": "name", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + }, + { + "name": "definition", + "type": "InputOutputParameterDefinition" + } + ] + }, + { + "name": "InputOutputParameterDefinition", + "isAbstract": true + }, + { + "name": "List", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "items", + "isMany": true, + "type": "InputOutputParameterDefinition" + } + ] + }, + { + "name": "Map", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "entries", + "isMany": true, + "type": "Entry" + } + ] + }, + { + "name": "Entry", + "properties": [ + { + "name": "key", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + }, + { + "name": "definition", + "type": "InputOutputParameterDefinition" + } + ] + }, + { + "name": "Value", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "id", + "isAttr": true, + "type": "String" + }, + { + "name": "name", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + } + ] + }, + { + "name": "Script", + "superClass": ["InputOutputParameterDefinition"], + "properties": [ + { + "name": "scriptFormat", + "isAttr": true, + "type": "String" + }, + { + "name": "resource", + "isAttr": true, + "type": "String" + }, + { + "name": "value", + "isBody": true, + "type": "String" + } + ] + }, + { + "name": "Field", + "superClass": ["Element"], + "meta": { + "allowedIn": [ + "flowable:ServiceTaskLike", + "flowable:ExecutionListener", + "flowable:TaskListener" + ] + }, + "properties": [ + { + "name": "name", + "isAttr": true, + "type": "String" + }, + { + "name": "expression", + "type": "String" + }, + { + "name": "stringValue", + "isAttr": true, + "type": "String" + }, + { + "name": "string", + "type": "String" + } + ] + }, + { + "name": "ChildField", + "superClass": ["Element"], + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "type", + "type": "String", + "isAttr": true + }, + { + "name": "required", + "type": "String", + "isAttr": true + }, + { + "name": "readable", + "type": "String", + "isAttr": true + }, + { + "name": "writable", + "type": "String", + "isAttr": true + }, + { + "name": "variable", + "type": "String", + "isAttr": true + }, + { + "name": "expression", + "type": "String", + "isAttr": true + }, + { + "name": "datePattern", + "type": "String", + "isAttr": true + }, + { + "name": "default", + "type": "String", + "isAttr": true + }, + { + "name": "values", + "type": "Value", + "isMany": true + } + ] + }, + { + "name": "InputParameter", + "superClass": ["InputOutputParameter"] + }, + { + "name": "OutputParameter", + "superClass": ["InputOutputParameter"] + }, + { + "name": "Collectable", + "isAbstract": true, + "extends": ["bpmn:MultiInstanceLoopCharacteristics"], + "superClass": ["flowable:AsyncCapable"], + "properties": [ + { + "name": "collection", + "isAttr": true, + "type": "String" + }, + { + "name": "elementVariable", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "FailedJobRetryTimeCycle", + "superClass": ["Element"], + "meta": { + "allowedIn": ["flowable:AsyncCapable", "bpmn:MultiInstanceLoopCharacteristics"] + }, + "properties": [ + { + "name": "body", + "isBody": true, + "type": "String" + } + ] + }, + { + "name": "ExecutionListener", + "superClass": ["Element"], + "meta": { + "allowedIn": [ + "bpmn:Task", + "bpmn:ServiceTask", + "bpmn:UserTask", + "bpmn:BusinessRuleTask", + "bpmn:ScriptTask", + "bpmn:ReceiveTask", + "bpmn:ManualTask", + "bpmn:ExclusiveGateway", + "bpmn:SequenceFlow", + "bpmn:ParallelGateway", + "bpmn:InclusiveGateway", + "bpmn:EventBasedGateway", + "bpmn:StartEvent", + "bpmn:IntermediateCatchEvent", + "bpmn:IntermediateThrowEvent", + "bpmn:EndEvent", + "bpmn:BoundaryEvent", + "bpmn:CallActivity", + "bpmn:SubProcess", + "bpmn:Process" + ] + }, + "properties": [ + { + "name": "expression", + "isAttr": true, + "type": "String" + }, + { + "name": "class", + "isAttr": true, + "type": "String" + }, + { + "name": "delegateExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "event", + "isAttr": true, + "type": "String" + }, + { + "name": "script", + "type": "Script" + }, + { + "name": "fields", + "type": "Field", + "isMany": true + } + ] + }, + { + "name": "TaskListener", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:UserTask"] + }, + "properties": [ + { + "name": "expression", + "isAttr": true, + "type": "String" + }, + { + "name": "class", + "isAttr": true, + "type": "String" + }, + { + "name": "delegateExpression", + "isAttr": true, + "type": "String" + }, + { + "name": "event", + "isAttr": true, + "type": "String" + }, + { + "name": "script", + "type": "Script" + }, + { + "name": "fields", + "type": "Field", + "isMany": true + } + ] + }, + { + "name": "FormProperty", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:StartEvent", "bpmn:UserTask"] + }, + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "type", + "type": "String", + "isAttr": true + }, + { + "name": "required", + "type": "String", + "isAttr": true + }, + { + "name": "readable", + "type": "String", + "isAttr": true + }, + { + "name": "writable", + "type": "String", + "isAttr": true + }, + { + "name": "variable", + "type": "String", + "isAttr": true + }, + { + "name": "expression", + "type": "String", + "isAttr": true + }, + { + "name": "datePattern", + "type": "String", + "isAttr": true + }, + { + "name": "default", + "type": "String", + "isAttr": true + }, + { + "name": "values", + "type": "Value", + "isMany": true + }, + { + "name": "children", + "type": "ChildField", + "isMany": true + }, + { + "name": "extensionElements", + "type": "bpmn:ExtensionElements", + "isMany": true + } + ] + }, + { + "name": "FormData", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:StartEvent", "bpmn:UserTask"] + }, + "properties": [ + { + "name": "fields", + "type": "FormField", + "isMany": true + }, + { + "name": "businessKey", + "type": "String", + "isAttr": true + } + ] + }, + { + "name": "FormField", + "superClass": ["Element"], + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "label", + "type": "String", + "isAttr": true + }, + { + "name": "type", + "type": "String", + "isAttr": true + }, + { + "name": "datePattern", + "type": "String", + "isAttr": true + }, + { + "name": "defaultValue", + "type": "String", + "isAttr": true + }, + { + "name": "properties", + "type": "Properties" + }, + { + "name": "validation", + "type": "Validation" + }, + { + "name": "values", + "type": "Value", + "isMany": true + } + ] + }, + { + "name": "Validation", + "superClass": ["Element"], + "properties": [ + { + "name": "constraints", + "type": "Constraint", + "isMany": true + } + ] + }, + { + "name": "Constraint", + "superClass": ["Element"], + "properties": [ + { + "name": "name", + "type": "String", + "isAttr": true + }, + { + "name": "config", + "type": "String", + "isAttr": true + } + ] + }, + { + "name": "ConditionalEventDefinition", + "isAbstract": true, + "extends": ["bpmn:ConditionalEventDefinition"], + "properties": [ + { + "name": "variableName", + "isAttr": true, + "type": "String" + }, + { + "name": "variableEvent", + "isAttr": true, + "type": "String" + } + ] + }, + { + "name": "Condition", + "superClass": ["Element"], + "meta": { + "allowedIn": ["bpmn:SequenceFlow"] + }, + "properties": [ + { + "name": "id", + "type": "String", + "isAttr": true + }, + { + "name": "field", + "type": "String", + "isAttr": true + }, + { + "name": "compare", + "type": "String", + "isAttr": true + }, + { + "name": "value", + "type": "String", + "isAttr": true + }, + { + "name": "logic", + "type": "String", + "isAttr": true + }, + { + "name": "sort", + "type": "Integer", + "isAttr": true + } + ] + } + ], + "emumerations": [] +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/activiti/activitiExtension.js b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/activiti/activitiExtension.js new file mode 100644 index 0000000..56ef38a --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/activiti/activitiExtension.js @@ -0,0 +1,83 @@ +'use strict' + +import { some } from 'min-dash' + +// const some = require('min-dash').some +// const some = some + +const ALLOWED_TYPES = { + FailedJobRetryTimeCycle: [ + 'bpmn:StartEvent', + 'bpmn:BoundaryEvent', + 'bpmn:IntermediateCatchEvent', + 'bpmn:Activity' + ], + Connector: ['bpmn:EndEvent', 'bpmn:IntermediateThrowEvent'], + Field: ['bpmn:EndEvent', 'bpmn:IntermediateThrowEvent'] +} + +function is(element, type) { + return element && typeof element.$instanceOf === 'function' && element.$instanceOf(type) +} + +function exists(element) { + return element && element.length +} + +function includesType(collection, type) { + return ( + exists(collection) && + some(collection, function (element) { + return is(element, type) + }) + ) +} + +function anyType(element, types) { + return some(types, function (type) { + return is(element, type) + }) +} + +function isAllowed(propName, propDescriptor, newElement) { + const name = propDescriptor.name, + types = ALLOWED_TYPES[name.replace(/activiti:/, '')] + + return name === propName && anyType(newElement, types) +} + +function ActivitiModdleExtension(eventBus) { + eventBus.on( + 'property.clone', + function (context) { + const newElement = context.newElement, + propDescriptor = context.propertyDescriptor + + this.canCloneProperty(newElement, propDescriptor) + }, + this + ) +} + +ActivitiModdleExtension.$inject = ['eventBus'] + +ActivitiModdleExtension.prototype.canCloneProperty = function (newElement, propDescriptor) { + if (isAllowed('activiti:FailedJobRetryTimeCycle', propDescriptor, newElement)) { + return ( + includesType(newElement.eventDefinitions, 'bpmn:TimerEventDefinition') || + includesType(newElement.eventDefinitions, 'bpmn:SignalEventDefinition') || + is(newElement.loopCharacteristics, 'bpmn:MultiInstanceLoopCharacteristics') + ) + } + + if (isAllowed('activiti:Connector', propDescriptor, newElement)) { + return includesType(newElement.eventDefinitions, 'bpmn:MessageEventDefinition') + } + + if (isAllowed('activiti:Field', propDescriptor, newElement)) { + return includesType(newElement.eventDefinitions, 'bpmn:MessageEventDefinition') + } +} + +// module.exports = ActivitiModdleExtension; +export default ActivitiModdleExtension diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/activiti/index.js b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/activiti/index.js new file mode 100644 index 0000000..c22ca34 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/activiti/index.js @@ -0,0 +1,11 @@ +/* + * @author igdianov + * address https://github.com/igdianov/activiti-bpmn-moddle + * */ + +import activitiExtension from './activitiExtension' + +export default { + __init__: ['ActivitiModdleExtension'], + ActivitiModdleExtension: ['type', activitiExtension] +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/camunda/extension.js b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/camunda/extension.js new file mode 100644 index 0000000..b8c37a5 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/camunda/extension.js @@ -0,0 +1,151 @@ +'use strict' + +import { isFunction, isObject, some } from 'min-dash' + +// const isFunction = isFunction, +// isObject = isObject, +// some = some +// const isFunction = require('min-dash').isFunction, +// isObject = require('min-dash').isObject, +// some = require('min-dash').some + +const WILDCARD = '*' + +function CamundaModdleExtension(eventBus) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this + + eventBus.on('moddleCopy.canCopyProperty', function (context) { + const property = context.property, + parent = context.parent + + return self.canCopyProperty(property, parent) + }) +} + +CamundaModdleExtension.$inject = ['eventBus'] + +/** + * Check wether to disallow copying property. + */ +CamundaModdleExtension.prototype.canCopyProperty = function (property, parent) { + // (1) check wether property is allowed in parent + if (isObject(property) && !isAllowedInParent(property, parent)) { + return false + } + + // (2) check more complex scenarios + + if (is(property, 'camunda:InputOutput') && !this.canHostInputOutput(parent)) { + return false + } + + if (isAny(property, ['camunda:Connector', 'camunda:Field']) && !this.canHostConnector(parent)) { + return false + } + + if (is(property, 'camunda:In') && !this.canHostIn(parent)) { + return false + } +} + +CamundaModdleExtension.prototype.canHostInputOutput = function (parent) { + // allowed in camunda:Connector + const connector = getParent(parent, 'camunda:Connector') + + if (connector) { + return true + } + + // special rules inside bpmn:FlowNode + const flowNode = getParent(parent, 'bpmn:FlowNode') + + if (!flowNode) { + return false + } + + if (isAny(flowNode, ['bpmn:StartEvent', 'bpmn:Gateway', 'bpmn:BoundaryEvent'])) { + return false + } + + return !(is(flowNode, 'bpmn:SubProcess') && flowNode.get('triggeredByEvent')) +} + +CamundaModdleExtension.prototype.canHostConnector = function (parent) { + const serviceTaskLike = getParent(parent, 'camunda:ServiceTaskLike') + + if (is(serviceTaskLike, 'bpmn:MessageEventDefinition')) { + // only allow on throw and end events + return getParent(parent, 'bpmn:IntermediateThrowEvent') || getParent(parent, 'bpmn:EndEvent') + } + + return true +} + +CamundaModdleExtension.prototype.canHostIn = function (parent) { + const callActivity = getParent(parent, 'bpmn:CallActivity') + + if (callActivity) { + return true + } + + const signalEventDefinition = getParent(parent, 'bpmn:SignalEventDefinition') + + if (signalEventDefinition) { + // only allow on throw and end events + return getParent(parent, 'bpmn:IntermediateThrowEvent') || getParent(parent, 'bpmn:EndEvent') + } + + return true +} + +// module.exports = CamundaModdleExtension; +export default CamundaModdleExtension + +// helpers ////////// + +function is(element, type) { + return element && isFunction(element.$instanceOf) && element.$instanceOf(type) +} + +function isAny(element, types) { + return some(types, function (t) { + return is(element, t) + }) +} + +function getParent(element, type) { + if (!type) { + return element.$parent + } + + if (is(element, type)) { + return element + } + + if (!element.$parent) { + return + } + + return getParent(element.$parent, type) +} + +function isAllowedInParent(property, parent) { + // (1) find property descriptor + const descriptor = property.$type && property.$model.getTypeDescriptor(property.$type) + + const allowedIn = descriptor && descriptor.meta && descriptor.meta.allowedIn + + if (!allowedIn || isWildcard(allowedIn)) { + return true + } + + // (2) check wether property has parent of allowed type + return some(allowedIn, function (type) { + return getParent(parent, type) + }) +} + +function isWildcard(allowedIn) { + return allowedIn.indexOf(WILDCARD) !== -1 +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/camunda/index.js b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/camunda/index.js new file mode 100644 index 0000000..1da1bc7 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/camunda/index.js @@ -0,0 +1,8 @@ +'use strict' + +import extension from './extension' + +export default { + __init__: ['camundaModdleExtension'], + camundaModdleExtension: ['type', extension] +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/flowable/flowableExtension.js b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/flowable/flowableExtension.js new file mode 100644 index 0000000..3dcea67 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/flowable/flowableExtension.js @@ -0,0 +1,83 @@ +'use strict' + +import { some } from 'min-dash' + +// const some = some +// const some = require('min-dash').some + +const ALLOWED_TYPES = { + FailedJobRetryTimeCycle: [ + 'bpmn:StartEvent', + 'bpmn:BoundaryEvent', + 'bpmn:IntermediateCatchEvent', + 'bpmn:Activity' + ], + Connector: ['bpmn:EndEvent', 'bpmn:IntermediateThrowEvent'], + Field: ['bpmn:EndEvent', 'bpmn:IntermediateThrowEvent'] +} + +function is(element, type) { + return element && typeof element.$instanceOf === 'function' && element.$instanceOf(type) +} + +function exists(element) { + return element && element.length +} + +function includesType(collection, type) { + return ( + exists(collection) && + some(collection, function (element) { + return is(element, type) + }) + ) +} + +function anyType(element, types) { + return some(types, function (type) { + return is(element, type) + }) +} + +function isAllowed(propName, propDescriptor, newElement) { + const name = propDescriptor.name, + types = ALLOWED_TYPES[name.replace(/flowable:/, '')] + + return name === propName && anyType(newElement, types) +} + +function FlowableModdleExtension(eventBus) { + eventBus.on( + 'property.clone', + function (context) { + const newElement = context.newElement, + propDescriptor = context.propertyDescriptor + + this.canCloneProperty(newElement, propDescriptor) + }, + this + ) +} + +FlowableModdleExtension.$inject = ['eventBus'] + +FlowableModdleExtension.prototype.canCloneProperty = function (newElement, propDescriptor) { + if (isAllowed('flowable:FailedJobRetryTimeCycle', propDescriptor, newElement)) { + return ( + includesType(newElement.eventDefinitions, 'bpmn:TimerEventDefinition') || + includesType(newElement.eventDefinitions, 'bpmn:SignalEventDefinition') || + is(newElement.loopCharacteristics, 'bpmn:MultiInstanceLoopCharacteristics') + ) + } + + if (isAllowed('flowable:Connector', propDescriptor, newElement)) { + return includesType(newElement.eventDefinitions, 'bpmn:MessageEventDefinition') + } + + if (isAllowed('flowable:Field', propDescriptor, newElement)) { + return includesType(newElement.eventDefinitions, 'bpmn:MessageEventDefinition') + } +} + +// module.exports = FlowableModdleExtension; +export default FlowableModdleExtension diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/flowable/index.js b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/flowable/index.js new file mode 100644 index 0000000..6d59b67 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/extension-moddle/flowable/index.js @@ -0,0 +1,10 @@ +/* + * @author igdianov + * address https://github.com/igdianov/activiti-bpmn-moddle + * */ +import flowableExtension from './flowableExtension' + +export default { + __init__: ['FlowableModdleExtension'], + FlowableModdleExtension: ['type', flowableExtension] +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/palette/CustomPalette.js b/src/components/bpmnProcessDesigner/package/designer/plugins/palette/CustomPalette.js new file mode 100644 index 0000000..5e2803b --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/palette/CustomPalette.js @@ -0,0 +1,221 @@ +import PaletteProvider from 'bpmn-js/lib/features/palette/PaletteProvider' +import { assign } from 'min-dash' + +export default function CustomPalette( + palette, + create, + elementFactory, + spaceTool, + lassoTool, + handTool, + globalConnect, + translate +) { + PaletteProvider.call( + this, + palette, + create, + elementFactory, + spaceTool, + lassoTool, + handTool, + globalConnect, + translate, + 2000 + ) +} + +const F = function () {} // 核心,利用空对象作为中介; +F.prototype = PaletteProvider.prototype // 核心,将父类的原型赋值给空对象F; + +// 利用中介函数重写原型链方法 +F.prototype.getPaletteEntries = function () { + const actions = {}, + create = this._create, + elementFactory = this._elementFactory, + spaceTool = this._spaceTool, + lassoTool = this._lassoTool, + handTool = this._handTool, + globalConnect = this._globalConnect, + translate = this._translate + + function createAction(type, group, className, title, options) { + function createListener(event) { + const shape = elementFactory.createShape(assign({ type: type }, options)) + + if (options) { + shape.businessObject.di.isExpanded = options.isExpanded + } + + create.start(event, shape) + } + + const shortType = type.replace(/^bpmn:/, '') + + return { + group: group, + className: className, + title: title || translate('Create {type}', { type: shortType }), + action: { + dragstart: createListener, + click: createListener + } + } + } + + function createSubprocess(event) { + const subProcess = elementFactory.createShape({ + type: 'bpmn:SubProcess', + x: 0, + y: 0, + isExpanded: true + }) + + const startEvent = elementFactory.createShape({ + type: 'bpmn:StartEvent', + x: 40, + y: 82, + parent: subProcess + }) + + create.start(event, [subProcess, startEvent], { + hints: { + autoSelect: [startEvent] + } + }) + } + + function createParticipant(event) { + create.start(event, elementFactory.createParticipantShape()) + } + + assign(actions, { + 'hand-tool': { + group: 'tools', + className: 'bpmn-icon-hand-tool', + title: '激活抓手工具', + // title: translate("Activate the hand tool"), + action: { + click: function (event) { + handTool.activateHand(event) + } + } + }, + 'lasso-tool': { + group: 'tools', + className: 'bpmn-icon-lasso-tool', + title: translate('Activate the lasso tool'), + action: { + click: function (event) { + lassoTool.activateSelection(event) + } + } + }, + 'space-tool': { + group: 'tools', + className: 'bpmn-icon-space-tool', + title: translate('Activate the create/remove space tool'), + action: { + click: function (event) { + spaceTool.activateSelection(event) + } + } + }, + 'global-connect-tool': { + group: 'tools', + className: 'bpmn-icon-connection-multi', + title: translate('Activate the global connect tool'), + action: { + click: function (event) { + globalConnect.toggle(event) + } + } + }, + 'tool-separator': { + group: 'tools', + separator: true + }, + 'create.start-event': createAction( + 'bpmn:StartEvent', + 'event', + 'bpmn-icon-start-event-none', + translate('Create StartEvent') + ), + 'create.intermediate-event': createAction( + 'bpmn:IntermediateThrowEvent', + 'event', + 'bpmn-icon-intermediate-event-none', + translate('Create Intermediate/Boundary Event') + ), + 'create.end-event': createAction( + 'bpmn:EndEvent', + 'event', + 'bpmn-icon-end-event-none', + translate('Create EndEvent') + ), + 'create.exclusive-gateway': createAction( + 'bpmn:ExclusiveGateway', + 'gateway', + 'bpmn-icon-gateway-none', + translate('Create Gateway') + ), + 'create.user-task': createAction( + 'bpmn:UserTask', + 'activity', + 'bpmn-icon-user-task', + translate('Create User Task') + ), + 'create.data-object': createAction( + 'bpmn:DataObjectReference', + 'data-object', + 'bpmn-icon-data-object', + translate('Create DataObjectReference') + ), + 'create.data-store': createAction( + 'bpmn:DataStoreReference', + 'data-store', + 'bpmn-icon-data-store', + translate('Create DataStoreReference') + ), + 'create.subprocess-expanded': { + group: 'activity', + className: 'bpmn-icon-subprocess-expanded', + title: translate('Create expanded SubProcess'), + action: { + dragstart: createSubprocess, + click: createSubprocess + } + }, + 'create.participant-expanded': { + group: 'collaboration', + className: 'bpmn-icon-participant', + title: translate('Create Pool/Participant'), + action: { + dragstart: createParticipant, + click: createParticipant + } + }, + 'create.group': createAction( + 'bpmn:Group', + 'artifact', + 'bpmn-icon-group', + translate('Create Group') + ) + }) + + return actions +} + +CustomPalette.$inject = [ + 'palette', + 'create', + 'elementFactory', + 'spaceTool', + 'lassoTool', + 'handTool', + 'globalConnect', + 'translate' +] + +CustomPalette.prototype = new F() // 核心,将 F的实例赋值给子类; +CustomPalette.prototype.constructor = CustomPalette // 修复子类CustomPalette的构造器指向,防止原型链的混乱; diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/palette/index.js b/src/components/bpmnProcessDesigner/package/designer/plugins/palette/index.js new file mode 100644 index 0000000..8e4f3ac --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/palette/index.js @@ -0,0 +1,22 @@ +// import PaletteModule from "diagram-js/lib/features/palette"; +// import CreateModule from "diagram-js/lib/features/create"; +// import SpaceToolModule from "diagram-js/lib/features/space-tool"; +// import LassoToolModule from "diagram-js/lib/features/lasso-tool"; +// import HandToolModule from "diagram-js/lib/features/hand-tool"; +// import GlobalConnectModule from "diagram-js/lib/features/global-connect"; +// import translate from "diagram-js/lib/i18n/translate"; +// +// import PaletteProvider from "./paletteProvider"; +// +// export default { +// __depends__: [PaletteModule, CreateModule, SpaceToolModule, LassoToolModule, HandToolModule, GlobalConnectModule, translate], +// __init__: ["paletteProvider"], +// paletteProvider: ["type", PaletteProvider] +// }; + +import CustomPalette from './CustomPalette' + +export default { + __init__: ['paletteProvider'], + paletteProvider: ['type', CustomPalette] +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/palette/paletteProvider.js b/src/components/bpmnProcessDesigner/package/designer/plugins/palette/paletteProvider.js new file mode 100644 index 0000000..7098981 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/palette/paletteProvider.js @@ -0,0 +1,213 @@ +import { assign } from 'min-dash' + +/** + * A palette provider for BPMN 2.0 elements. + */ +export default function PaletteProvider( + palette, + create, + elementFactory, + spaceTool, + lassoTool, + handTool, + globalConnect, + translate +) { + this._palette = palette + this._create = create + this._elementFactory = elementFactory + this._spaceTool = spaceTool + this._lassoTool = lassoTool + this._handTool = handTool + this._globalConnect = globalConnect + this._translate = translate + + palette.registerProvider(this) +} + +PaletteProvider.$inject = [ + 'palette', + 'create', + 'elementFactory', + 'spaceTool', + 'lassoTool', + 'handTool', + 'globalConnect', + 'translate' +] + +PaletteProvider.prototype.getPaletteEntries = function () { + const actions = {}, + create = this._create, + elementFactory = this._elementFactory, + spaceTool = this._spaceTool, + lassoTool = this._lassoTool, + handTool = this._handTool, + globalConnect = this._globalConnect, + translate = this._translate + + function createAction(type, group, className, title, options) { + function createListener(event) { + const shape = elementFactory.createShape(assign({ type: type }, options)) + + if (options) { + shape.businessObject.di.isExpanded = options.isExpanded + } + + create.start(event, shape) + } + + const shortType = type.replace(/^bpmn:/, '') + + return { + group: group, + className: className, + title: title || translate('Create {type}', { type: shortType }), + action: { + dragstart: createListener, + click: createListener + } + } + } + + function createSubprocess(event) { + const subProcess = elementFactory.createShape({ + type: 'bpmn:SubProcess', + x: 0, + y: 0, + isExpanded: true + }) + + const startEvent = elementFactory.createShape({ + type: 'bpmn:StartEvent', + x: 40, + y: 82, + parent: subProcess + }) + + create.start(event, [subProcess, startEvent], { + hints: { + autoSelect: [startEvent] + } + }) + } + + function createParticipant(event) { + create.start(event, elementFactory.createParticipantShape()) + } + + assign(actions, { + 'hand-tool': { + group: 'tools', + className: 'bpmn-icon-hand-tool', + title: translate('Activate the hand tool'), + action: { + click: function (event) { + handTool.activateHand(event) + } + } + }, + 'lasso-tool': { + group: 'tools', + className: 'bpmn-icon-lasso-tool', + title: translate('Activate the lasso tool'), + action: { + click: function (event) { + lassoTool.activateSelection(event) + } + } + }, + 'space-tool': { + group: 'tools', + className: 'bpmn-icon-space-tool', + title: translate('Activate the create/remove space tool'), + action: { + click: function (event) { + spaceTool.activateSelection(event) + } + } + }, + 'global-connect-tool': { + group: 'tools', + className: 'bpmn-icon-connection-multi', + title: translate('Activate the global connect tool'), + action: { + click: function (event) { + globalConnect.toggle(event) + } + } + }, + 'tool-separator': { + group: 'tools', + separator: true + }, + 'create.start-event': createAction( + 'bpmn:StartEvent', + 'event', + 'bpmn-icon-start-event-none', + translate('Create StartEvent') + ), + 'create.intermediate-event': createAction( + 'bpmn:IntermediateThrowEvent', + 'event', + 'bpmn-icon-intermediate-event-none', + translate('Create Intermediate/Boundary Event') + ), + 'create.end-event': createAction( + 'bpmn:EndEvent', + 'event', + 'bpmn-icon-end-event-none', + translate('Create EndEvent') + ), + 'create.exclusive-gateway': createAction( + 'bpmn:ExclusiveGateway', + 'gateway', + 'bpmn-icon-gateway-none', + translate('Create Gateway') + ), + 'create.user-task': createAction( + 'bpmn:UserTask', + 'activity', + 'bpmn-icon-user-task', + translate('Create User Task') + ), + 'create.data-object': createAction( + 'bpmn:DataObjectReference', + 'data-object', + 'bpmn-icon-data-object', + translate('Create DataObjectReference') + ), + 'create.data-store': createAction( + 'bpmn:DataStoreReference', + 'data-store', + 'bpmn-icon-data-store', + translate('Create DataStoreReference') + ), + 'create.subprocess-expanded': { + group: 'activity', + className: 'bpmn-icon-subprocess-expanded', + title: translate('Create expanded SubProcess'), + action: { + dragstart: createSubprocess, + click: createSubprocess + } + }, + 'create.participant-expanded': { + group: 'collaboration', + className: 'bpmn-icon-participant', + title: translate('Create Pool/Participant'), + action: { + dragstart: createParticipant, + click: createParticipant + } + }, + 'create.group': createAction( + 'bpmn:Group', + 'artifact', + 'bpmn-icon-group', + translate('Create Group') + ) + }) + + return actions +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/translate/customTranslate.js b/src/components/bpmnProcessDesigner/package/designer/plugins/translate/customTranslate.js new file mode 100644 index 0000000..c1b99e1 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/translate/customTranslate.js @@ -0,0 +1,44 @@ +// import translations from "./zh"; +// +// export default function customTranslate(template, replacements) { +// replacements = replacements || {}; +// +// // Translate +// template = translations[template] || template; +// +// // Replace +// return template.replace(/{([^}]+)}/g, function(_, key) { +// let str = replacements[key]; +// if ( +// translations[replacements[key]] !== null && +// translations[replacements[key]] !== "undefined" +// ) { +// // eslint-disable-next-line no-mixed-spaces-and-tabs +// str = translations[replacements[key]]; +// // eslint-disable-next-line no-mixed-spaces-and-tabs +// } +// return str || "{" + key + "}"; +// }); +// } + +export default function customTranslate(translations) { + return function (template, replacements) { + replacements = replacements || {} + // Translate + template = translations[template] || template + + // Replace + return template.replace(/{([^}]+)}/g, function (_, key) { + let str = replacements[key] + if ( + translations[replacements[key]] !== null && + translations[replacements[key]] !== undefined + ) { + // eslint-disable-next-line no-mixed-spaces-and-tabs + str = translations[replacements[key]] + // eslint-disable-next-line no-mixed-spaces-and-tabs + } + return str || '{' + key + '}' + }) + } +} diff --git a/src/components/bpmnProcessDesigner/package/designer/plugins/translate/zh.js b/src/components/bpmnProcessDesigner/package/designer/plugins/translate/zh.js new file mode 100644 index 0000000..777db3e --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/designer/plugins/translate/zh.js @@ -0,0 +1,240 @@ +/** + * This is a sample file that should be replaced with the actual translation. + * + * Checkout https://github.com/bpmn-io/bpmn-js-i18n for a list of available + * translations and labels to translate. + */ +export default { + // 添加部分 + 'Append EndEvent': '追加结束事件', + 'Append Gateway': '追加网关', + 'Append Task': '追加任务', + 'Append Intermediate/Boundary Event': '追加中间抛出事件/边界事件', + + 'Activate the global connect tool': '激活全局连接工具', + 'Append {type}': '添加 {type}', + 'Add Lane above': '在上面添加道', + 'Divide into two Lanes': '分割成两个道', + 'Divide into three Lanes': '分割成三个道', + 'Add Lane below': '在下面添加道', + 'Append compensation activity': '追加补偿活动', + 'Change type': '修改类型', + 'Connect using Association': '使用关联连接', + 'Connect using Sequence/MessageFlow or Association': '使用顺序/消息流或者关联连接', + 'Connect using DataInputAssociation': '使用数据输入关联连接', + Remove: '移除', + 'Activate the hand tool': '激活抓手工具', + 'Activate the lasso tool': '激活套索工具', + 'Activate the create/remove space tool': '激活创建/删除空间工具', + 'Create expanded SubProcess': '创建扩展子过程', + 'Create IntermediateThrowEvent/BoundaryEvent': '创建中间抛出事件/边界事件', + 'Create Pool/Participant': '创建池/参与者', + 'Parallel Multi Instance': '并行多重事件', + 'Sequential Multi Instance': '时序多重事件', + DataObjectReference: '数据对象参考', + DataStoreReference: '数据存储参考', + Loop: '循环', + 'Ad-hoc': '即席', + 'Create {type}': '创建 {type}', + Task: '任务', + 'Send Task': '发送任务', + 'Receive Task': '接收任务', + 'User Task': '用户任务', + 'Manual Task': '手工任务', + 'Business Rule Task': '业务规则任务', + 'Service Task': '服务任务', + 'Script Task': '脚本任务', + 'Call Activity': '调用活动', + 'Sub Process (collapsed)': '子流程(折叠的)', + 'Sub Process (expanded)': '子流程(展开的)', + 'Start Event': '开始事件', + StartEvent: '开始事件', + 'Intermediate Throw Event': '中间事件', + 'End Event': '结束事件', + EndEvent: '结束事件', + 'Create StartEvent': '创建开始事件', + 'Create EndEvent': '创建结束事件', + 'Create Task': '创建任务', + 'Create User Task': '创建用户任务', + 'Create Gateway': '创建网关', + 'Create DataObjectReference': '创建数据对象', + 'Create DataStoreReference': '创建数据存储', + 'Create Group': '创建分组', + 'Create Intermediate/Boundary Event': '创建中间/边界事件', + 'Message Start Event': '消息开始事件', + 'Timer Start Event': '定时开始事件', + 'Conditional Start Event': '条件开始事件', + 'Signal Start Event': '信号开始事件', + 'Error Start Event': '错误开始事件', + 'Escalation Start Event': '升级开始事件', + 'Compensation Start Event': '补偿开始事件', + 'Message Start Event (non-interrupting)': '消息开始事件(非中断)', + 'Timer Start Event (non-interrupting)': '定时开始事件(非中断)', + 'Conditional Start Event (non-interrupting)': '条件开始事件(非中断)', + 'Signal Start Event (non-interrupting)': '信号开始事件(非中断)', + 'Escalation Start Event (non-interrupting)': '升级开始事件(非中断)', + 'Message Intermediate Catch Event': '消息中间捕获事件', + 'Message Intermediate Throw Event': '消息中间抛出事件', + 'Timer Intermediate Catch Event': '定时中间捕获事件', + 'Escalation Intermediate Throw Event': '升级中间抛出事件', + 'Conditional Intermediate Catch Event': '条件中间捕获事件', + 'Link Intermediate Catch Event': '链接中间捕获事件', + 'Link Intermediate Throw Event': '链接中间抛出事件', + 'Compensation Intermediate Throw Event': '补偿中间抛出事件', + 'Signal Intermediate Catch Event': '信号中间捕获事件', + 'Signal Intermediate Throw Event': '信号中间抛出事件', + 'Message End Event': '消息结束事件', + 'Escalation End Event': '定时结束事件', + 'Error End Event': '错误结束事件', + 'Cancel End Event': '取消结束事件', + 'Compensation End Event': '补偿结束事件', + 'Signal End Event': '信号结束事件', + 'Terminate End Event': '终止结束事件', + 'Message Boundary Event': '消息边界事件', + 'Message Boundary Event (non-interrupting)': '消息边界事件(非中断)', + 'Timer Boundary Event': '定时边界事件', + 'Timer Boundary Event (non-interrupting)': '定时边界事件(非中断)', + 'Escalation Boundary Event': '升级边界事件', + 'Escalation Boundary Event (non-interrupting)': '升级边界事件(非中断)', + 'Conditional Boundary Event': '条件边界事件', + 'Conditional Boundary Event (non-interrupting)': '条件边界事件(非中断)', + 'Error Boundary Event': '错误边界事件', + 'Cancel Boundary Event': '取消边界事件', + 'Signal Boundary Event': '信号边界事件', + 'Signal Boundary Event (non-interrupting)': '信号边界事件(非中断)', + 'Compensation Boundary Event': '补偿边界事件', + 'Exclusive Gateway': '互斥网关', + 'Parallel Gateway': '并行网关', + 'Inclusive Gateway': '相容网关', + 'Complex Gateway': '复杂网关', + 'Event based Gateway': '事件网关', + Transaction: '转运', + 'Sub Process': '子流程', + 'Event Sub Process': '事件子流程', + 'Collapsed Pool': '折叠池', + 'Expanded Pool': '展开池', + + // Errors + 'no parent for {element} in {parent}': '在{parent}里,{element}没有父类', + 'no shape type specified': '没有指定的形状类型', + 'flow elements must be children of pools/participants': '流元素必须是池/参与者的子类', + 'out of bounds release': 'out of bounds release', + 'more than {count} child lanes': '子道大于{count} ', + 'element required': '元素不能为空', + 'diagram not part of bpmn:Definitions': '流程图不符合bpmn规范', + 'no diagram to display': '没有可展示的流程图', + 'no process or collaboration to display': '没有可展示的流程/协作', + 'element {element} referenced by {referenced}#{property} not yet drawn': + '由{referenced}#{property}引用的{element}元素仍未绘制', + 'already rendered {element}': '{element} 已被渲染', + 'failed to import {element}': '导入{element}失败', + //属性面板的参数 + Id: '编号', + Name: '名称', + General: '常规', + Details: '详情', + 'Message Name': '消息名称', + Message: '消息', + Initiator: '创建者', + 'Asynchronous Continuations': '持续异步', + 'Asynchronous Before': '异步前', + 'Asynchronous After': '异步后', + 'Job Configuration': '工作配置', + Exclusive: '排除', + 'Job Priority': '工作优先级', + 'Retry Time Cycle': '重试时间周期', + Documentation: '文档', + 'Element Documentation': '元素文档', + 'History Configuration': '历史配置', + 'History Time To Live': '历史的生存时间', + Forms: '表单', + 'Form Key': '表单key', + 'Form Fields': '表单字段', + 'Business Key': '业务key', + 'Form Field': '表单字段', + ID: '编号', + Type: '类型', + Label: '名称', + 'Default Value': '默认值', + 'Default Flow': '默认流转路径', + 'Conditional Flow': '条件流转路径', + 'Sequence Flow': '普通流转路径', + Validation: '校验', + 'Add Constraint': '添加约束', + Config: '配置', + Properties: '属性', + 'Add Property': '添加属性', + Value: '值', + Listeners: '监听器', + 'Execution Listener': '执行监听', + 'Event Type': '事件类型', + 'Listener Type': '监听器类型', + 'Java Class': 'Java类', + Expression: '表达式', + 'Must provide a value': '必须提供一个值', + 'Delegate Expression': '代理表达式', + Script: '脚本', + 'Script Format': '脚本格式', + 'Script Type': '脚本类型', + 'Inline Script': '内联脚本', + 'External Script': '外部脚本', + Resource: '资源', + 'Field Injection': '字段注入', + Extensions: '扩展', + 'Input/Output': '输入/输出', + 'Input Parameters': '输入参数', + 'Output Parameters': '输出参数', + Parameters: '参数', + 'Output Parameter': '输出参数', + 'Timer Definition Type': '定时器定义类型', + 'Timer Definition': '定时器定义', + Date: '日期', + Duration: '持续', + Cycle: '循环', + Signal: '信号', + 'Signal Name': '信号名称', + Escalation: '升级', + Error: '错误', + 'Link Name': '链接名称', + Condition: '条件名称', + 'Variable Name': '变量名称', + 'Variable Event': '变量事件', + 'Specify more than one variable change event as a comma separated list.': + '多个变量事件以逗号隔开', + 'Wait for Completion': '等待完成', + 'Activity Ref': '活动参考', + 'Version Tag': '版本标签', + Executable: '可执行文件', + 'External Task Configuration': '扩展任务配置', + 'Task Priority': '任务优先级', + External: '外部', + Connector: '连接器', + 'Must configure Connector': '必须配置连接器', + 'Connector Id': '连接器编号', + Implementation: '实现方式', + 'Field Injections': '字段注入', + Fields: '字段', + 'Result Variable': '结果变量', + Topic: '主题', + 'Configure Connector': '配置连接器', + 'Input Parameter': '输入参数', + Assignee: '代理人', + 'Candidate Users': '候选用户', + 'Candidate Groups': '候选组', + 'Due Date': '到期时间', + 'Follow Up Date': '跟踪日期', + Priority: '优先级', + 'The follow up date as an EL expression (e.g. ${someDate} or an ISO date (e.g. 2015-06-26T09:54:00)': + '跟踪日期必须符合EL表达式,如: ${someDate} ,或者一个ISO标准日期,如:2015-06-26T09:54:00', + 'The due date as an EL expression (e.g. ${someDate} or an ISO date (e.g. 2015-06-26T09:54:00)': + '跟踪日期必须符合EL表达式,如: ${someDate} ,或者一个ISO标准日期,如:2015-06-26T09:54:00', + Variables: '变量', + 'Candidate Starter Configuration': '候选人起动器配置', + 'Candidate Starter Groups': '候选人起动器组', + 'This maps to the process definition key.': '这映射到流程定义键。', + 'Candidate Starter Users': '候选人起动器的用户', + 'Specify more than one user as a comma separated list.': '指定多个用户作为逗号分隔的列表。', + 'Tasklist Configuration': 'Tasklist配置', + Startable: '启动', + 'Specify more than one group as a comma separated list.': '指定多个组作为逗号分隔的列表。' +} diff --git a/src/components/bpmnProcessDesigner/package/index.ts b/src/components/bpmnProcessDesigner/package/index.ts new file mode 100644 index 0000000..ce44a3c --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/index.ts @@ -0,0 +1,11 @@ +import MyProcessDesigner from './designer' +import MyProcessPenal from './penal' +import MyProcessViewer from './designer/index2' + +import './theme/index.scss' +import 'bpmn-js/dist/assets/diagram-js.css' +import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css' +import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-codes.css' +import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css' + +export { MyProcessDesigner, MyProcessPenal, MyProcessViewer } diff --git a/src/components/bpmnProcessDesigner/package/palette/ProcessPalette.vue b/src/components/bpmnProcessDesigner/package/palette/ProcessPalette.vue new file mode 100644 index 0000000..ba97d96 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/palette/ProcessPalette.vue @@ -0,0 +1,45 @@ +<template> + <div class="my-process-palette"> + <div class="test-button" @click="addTask" @mousedown="addTask">测试任务</div> + <div class="test-container" id="palette-container">1</div> + </div> +</template> + +<script lang="ts" setup> +import { assign } from 'min-dash' + +defineOptions({ name: 'MyProcessPalette' }) + +const bpmnInstances = () => (window as any).bpmnInstances +const addTask = (event, options: any = {}) => { + const ElementFactory = bpmnInstances().elementFactory + const create = bpmnInstances().modeler.get('create') + + console.log(ElementFactory, create) + + const shape = ElementFactory.createShape(assign({ type: 'bpmn:UserTask' }, options)) + + if (options) { + shape.businessObject.di.isExpanded = options.isExpanded + } + + console.log(event, 'event') + console.log(shape, 'shape') + create.start(event, shape) +} +</script> + +<style scoped lang="scss"> +.my-process-palette { + padding: 80px 20px 20px; + box-sizing: border-box; + + .test-button { + padding: 8px 16px; + cursor: pointer; + border: 1px solid rgb(24 144 255 / 80%); + border-radius: 4px; + box-sizing: border-box; + } +} +</style> diff --git a/src/components/bpmnProcessDesigner/package/penal/PropertiesPanel.vue b/src/components/bpmnProcessDesigner/package/penal/PropertiesPanel.vue new file mode 100644 index 0000000..86a1cf7 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/PropertiesPanel.vue @@ -0,0 +1,206 @@ +<template> + <div class="process-panel__container" :style="{ width: `${width}px` }"> + <el-collapse v-model="activeTab"> + <el-collapse-item name="base"> + <!-- class="panel-tab__title" --> + <template #title> + <Icon icon="ep:info-filled" /> + 常规</template + > + <ElementBaseInfo + :id-edit-disabled="idEditDisabled" + :business-object="elementBusinessObject" + :type="elementType" + :model="model" + /> + </el-collapse-item> + <el-collapse-item name="condition" v-if="elementType === 'Process'" key="message"> + <template #title><Icon icon="ep:comment" />消息与信号</template> + <signal-and-massage /> + </el-collapse-item> + <el-collapse-item name="condition" v-if="conditionFormVisible" key="condition"> + <template #title><Icon icon="ep:promotion" />流转条件</template> + <flow-condition :business-object="elementBusinessObject" :type="elementType" /> + </el-collapse-item> + <el-collapse-item name="condition" v-if="formVisible" key="form"> + <template #title><Icon icon="ep:list" />表单</template> + <element-form :id="elementId" :type="elementType" /> + </el-collapse-item> + <el-collapse-item name="task" v-if="elementType.indexOf('Task') !== -1" key="task"> + <template #title><Icon icon="ep:checked" />任务(审批人)</template> + <element-task :id="elementId" :type="elementType" /> + </el-collapse-item> + <el-collapse-item + name="multiInstance" + v-if="elementType.indexOf('Task') !== -1" + key="multiInstance" + > + <template #title><Icon icon="ep:help-filled" />多实例(会签配置)</template> + <element-multi-instance :business-object="elementBusinessObject" :type="elementType" /> + </el-collapse-item> + <el-collapse-item name="listeners" key="listeners"> + <template #title><Icon icon="ep:bell-filled" />执行监听器</template> + <element-listeners :id="elementId" :type="elementType" /> + </el-collapse-item> + <el-collapse-item name="taskListeners" v-if="elementType === 'UserTask'" key="taskListeners"> + <template #title><Icon icon="ep:bell-filled" />任务监听器</template> + <user-task-listeners :id="elementId" :type="elementType" /> + </el-collapse-item> + <el-collapse-item name="extensions" key="extensions"> + <template #title><Icon icon="ep:circle-plus-filled" />扩展属性</template> + <element-properties :id="elementId" :type="elementType" /> + </el-collapse-item> + <el-collapse-item name="other" key="other"> + <template #title><Icon icon="ep:promotion" />其他</template> + <element-other-config :id="elementId" /> + </el-collapse-item> + </el-collapse> + </div> +</template> +<script lang="ts" setup> +import ElementBaseInfo from './base/ElementBaseInfo.vue' +import ElementOtherConfig from './other/ElementOtherConfig.vue' +import ElementTask from './task/ElementTask.vue' +import ElementMultiInstance from './multi-instance/ElementMultiInstance.vue' +import FlowCondition from './flow-condition/FlowCondition.vue' +import SignalAndMassage from './signal-message/SignalAndMessage.vue' +import ElementListeners from './listeners/ElementListeners.vue' +import ElementProperties from './properties/ElementProperties.vue' +// import ElementForm from './form/ElementForm.vue' +import UserTaskListeners from './listeners/UserTaskListeners.vue' + +defineOptions({ name: 'MyPropertiesPanel' }) + +/** + * 侧边栏 + * @Author MiyueFE + * @Home https://github.com/miyuesc + * @Date 2021年3月31日18:57:51 + */ +const props = defineProps({ + bpmnModeler: { + type: Object, + default: () => {} + }, + prefix: { + type: String, + default: 'camunda' + }, + width: { + type: Number, + default: 480 + }, + idEditDisabled: { + type: Boolean, + default: false + }, + model: Object // 流程模型的数据 +}) + +const activeTab = ref('base') +const elementId = ref('') +const elementType = ref('') +const elementBusinessObject = ref<any>({}) // 元素 businessObject 镜像,提供给需要做判断的组件使用 +const conditionFormVisible = ref(false) // 流转条件设置 +const formVisible = ref(false) // 表单配置 +const bpmnElement = ref() + +provide('prefix', props.prefix) +provide('width', props.width) +const bpmnInstances = () => (window as any)?.bpmnInstances + +// 监听 props.bpmnModeler 然后 initModels +const unwatchBpmn = watch( + () => props.bpmnModeler, + () => { + // 避免加载时 流程图 并未加载完成 + if (!props.bpmnModeler) { + console.log('缺少props.bpmnModeler') + return + } + + console.log('props.bpmnModeler 有值了!!!') + const w = window as any + w.bpmnInstances = { + modeler: props.bpmnModeler, + modeling: props.bpmnModeler.get('modeling'), + moddle: props.bpmnModeler.get('moddle'), + eventBus: props.bpmnModeler.get('eventBus'), + bpmnFactory: props.bpmnModeler.get('bpmnFactory'), + elementFactory: props.bpmnModeler.get('elementFactory'), + elementRegistry: props.bpmnModeler.get('elementRegistry'), + replace: props.bpmnModeler.get('replace'), + selection: props.bpmnModeler.get('selection') + } + + console.log(bpmnInstances(), 'window.bpmnInstances') + getActiveElement() + unwatchBpmn() + }, + { + immediate: true + } +) + +const getActiveElement = () => { + // 初始第一个选中元素 bpmn:Process + initFormOnChanged(null) + props.bpmnModeler.on('import.done', (e) => { + console.log(e, 'eeeee') + initFormOnChanged(null) + }) + // 监听选择事件,修改当前激活的元素以及表单 + props.bpmnModeler.on('selection.changed', ({ newSelection }) => { + initFormOnChanged(newSelection[0] || null) + }) + props.bpmnModeler.on('element.changed', ({ element }) => { + // 保证 修改 "默认流转路径" 类似需要修改多个元素的事件发生的时候,更新表单的元素与原选中元素不一致。 + if (element && element.id === elementId.value) { + initFormOnChanged(element) + } + }) +} +// 初始化数据 +const initFormOnChanged = (element) => { + let activatedElement = element + if (!activatedElement) { + activatedElement = + bpmnInstances().elementRegistry.find((el) => el.type === 'bpmn:Process') ?? + bpmnInstances().elementRegistry.find((el) => el.type === 'bpmn:Collaboration') + } + if (!activatedElement) return + console.log(` + ---------- + select element changed: + id: ${activatedElement.id} + type: ${activatedElement.businessObject.$type} + ---------- + `) + console.log('businessObject: ', activatedElement.businessObject) + bpmnInstances().bpmnElement = activatedElement + bpmnElement.value = activatedElement + elementId.value = activatedElement.id + elementType.value = activatedElement.type.split(':')[1] || '' + elementBusinessObject.value = JSON.parse(JSON.stringify(activatedElement.businessObject)) + conditionFormVisible.value = !!( + elementType.value === 'SequenceFlow' && + activatedElement.source && + activatedElement.source.type.indexOf('StartEvent') === -1 + ) + formVisible.value = elementType.value === 'UserTask' || elementType.value === 'StartEvent' +} + +onBeforeUnmount(() => { + const w = window as any + w.bpmnInstances = null + console.log(props, 'props1') + console.log(props.bpmnModeler, 'props.bpmnModeler1') +}) + +watch( + () => elementId.value, + () => { + activeTab.value = 'base' + } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/base/ElementBaseInfo.vue b/src/components/bpmnProcessDesigner/package/penal/base/ElementBaseInfo.vue new file mode 100644 index 0000000..70ad4f8 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/base/ElementBaseInfo.vue @@ -0,0 +1,180 @@ +<template> + <div class="panel-tab__content"> + <el-form label-width="90px" :model="needProps" :rules="rules"> + <div v-if="needProps.type == 'bpmn:Process'"> + <!-- 如果是 Process 信息的时候,使用自定义表单 --> + <el-form-item label="流程标识" prop="id"> + <el-input + v-model="needProps.id" + placeholder="请输入流标标识" + :disabled="needProps.id !== undefined && needProps.id.length > 0" + @change="handleKeyUpdate" + /> + </el-form-item> + <el-form-item label="流程名称" prop="name"> + <el-input + v-model="needProps.name" + placeholder="请输入流程名称" + clearable + @change="handleNameUpdate" + /> + </el-form-item> + </div> + <div v-else> + <el-form-item label="ID"> + <el-input v-model="elementBaseInfo.id" clearable @change="updateBaseInfo('id')" /> + </el-form-item> + <el-form-item label="名称"> + <el-input v-model="elementBaseInfo.name" clearable @change="updateBaseInfo('name')" /> + </el-form-item> + </div> + </el-form> + </div> +</template> +<script lang="ts" setup> +defineOptions({ name: 'ElementBaseInfo' }) + +const props = defineProps({ + businessObject: { + type: Object, + default: () => {} + }, + model: { + type: Object, + default: () => {} + } +}) +const needProps = ref<any>({}) +const bpmnElement = ref() +const elementBaseInfo = ref<any>({}) +// 流程表单的下拉框的数据 +// const forms = ref([]) +// 流程模型的校验 +const rules = reactive({ + id: [{ required: true, message: '流程标识不能为空', trigger: 'blur' }], + name: [{ required: true, message: '流程名称不能为空', trigger: 'blur' }] +}) + +const bpmnInstances = () => (window as any)?.bpmnInstances +const resetBaseInfo = () => { + console.log(window, 'window') + console.log(bpmnElement.value, 'bpmnElement') + + bpmnElement.value = bpmnInstances()?.bpmnElement + // console.log(bpmnElement.value, 'resetBaseInfo11111111111') + elementBaseInfo.value = bpmnElement.value.businessObject + needProps.value['type'] = bpmnElement.value.businessObject.$type + // elementBaseInfo.value['typess'] = bpmnElement.value.businessObject.$type + + // elementBaseInfo.value = JSON.parse(JSON.stringify(bpmnElement.value.businessObject)) + // console.log(elementBaseInfo.value, 'elementBaseInfo22222222222') +} +const handleKeyUpdate = (value) => { + // 校验 value 的值,只有 XML NCName 通过的情况下,才进行赋值。否则,会导致流程图报错,无法绘制的问题 + if (!value) { + return + } + if (!value.match(/[a-zA-Z_][\-_.0-9a-zA-Z$]*/)) { + console.log('key 不满足 XML NCName 规则,所以不进行赋值') + return + } + console.log('key 满足 XML NCName 规则,所以进行赋值') + + // 在 BPMN 的 XML 中,流程标识 key,其实对应的是 id 节点 + elementBaseInfo.value['id'] = value + + setTimeout(() => { + updateBaseInfo('id') + }, 100) +} +const handleNameUpdate = (value) => { + console.log(elementBaseInfo, 'elementBaseInfo') + if (!value) { + return + } + elementBaseInfo.value['name'] = value + + setTimeout(() => { + updateBaseInfo('name') + }, 100) +} +// const handleDescriptionUpdate=(value)=> { +// TODO 芋艿:documentation 暂时无法修改,后续在看看 +// this.elementBaseInfo['documentation'] = value; +// this.updateBaseInfo('documentation'); +// } +const updateBaseInfo = (key) => { + console.log(key, 'key') + // 触发 elementBaseInfo 对应的字段 + const attrObj = Object.create(null) + // console.log(attrObj, 'attrObj') + attrObj[key] = elementBaseInfo.value[key] + // console.log(attrObj, 'attrObj111') + // const attrObj = { + // id: elementBaseInfo.value[key] + // // di: { id: `${elementBaseInfo.value[key]}_di` } + // } + // console.log(elementBaseInfo, 'elementBaseInfo11111111111') + needProps.value = { ...elementBaseInfo.value, ...needProps.value } + + if (key === 'id') { + // console.log('jinru') + console.log(window, 'window') + console.log(bpmnElement.value, 'bpmnElement') + console.log(toRaw(bpmnElement.value), 'bpmnElement') + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + id: elementBaseInfo.value[key], + di: { id: `${elementBaseInfo.value[key]}_di` } + }) + } else { + console.log(attrObj, 'attrObj') + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), attrObj) + } +} + +watch( + () => props.businessObject, + (val) => { + // console.log(val, 'val11111111111111111111') + if (val) { + // nextTick(() => { + resetBaseInfo() + // }) + } + } +) + +watch( + () => props.model?.key, + (val) => { + // 针对上传的 bpmn 流程图时,保证 key 和 name 的更新 + if (val) { + handleKeyUpdate(props.model.key) + handleNameUpdate(props.model.name) + } + } +) + +// watch( +// () => ({ ...props }), +// (oldVal, newVal) => { +// console.log(oldVal, 'oldVal') +// console.log(newVal, 'newVal') +// if (newVal) { +// needProps.value = newVal +// } +// }, +// { +// immediate: true +// } +// ) +// 'model.key': { +// immediate: false, +// handler: function (val) { +// this.handleKeyUpdate(val) +// } +// } +onBeforeUnmount(() => { + bpmnElement.value = null +}) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue b/src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue new file mode 100644 index 0000000..304630d --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue @@ -0,0 +1,191 @@ +<template> + <div class="panel-tab__content"> + <el-form :model="flowConditionForm" label-width="90px" size="small"> + <el-form-item label="流转类型"> + <el-select v-model="flowConditionForm.type" @change="updateFlowType"> + <el-option label="普通流转路径" value="normal" /> + <el-option label="默认流转路径" value="default" /> + <el-option label="条件流转路径" value="condition" /> + </el-select> + </el-form-item> + <el-form-item label="条件格式" v-if="flowConditionForm.type === 'condition'" key="condition"> + <el-select v-model="flowConditionForm.conditionType"> + <el-option label="表达式" value="expression" /> + <el-option label="脚本" value="script" /> + </el-select> + </el-form-item> + <el-form-item + label="表达式" + v-if="flowConditionForm.conditionType && flowConditionForm.conditionType === 'expression'" + key="express" + > + <el-input + v-model="flowConditionForm.body" + style="width: 192px" + clearable + @change="updateFlowCondition" + /> + </el-form-item> + <template + v-if="flowConditionForm.conditionType && flowConditionForm.conditionType === 'script'" + > + <el-form-item label="脚本语言" key="language"> + <el-input v-model="flowConditionForm.language" clearable @change="updateFlowCondition" /> + </el-form-item> + <el-form-item label="脚本类型" key="scriptType"> + <el-select v-model="flowConditionForm.scriptType"> + <el-option label="内联脚本" value="inlineScript" /> + <el-option label="外部脚本" value="externalScript" /> + </el-select> + </el-form-item> + <el-form-item + label="脚本" + v-if="flowConditionForm.scriptType === 'inlineScript'" + key="body" + > + <el-input + v-model="flowConditionForm.body" + type="textarea" + clearable + @change="updateFlowCondition" + /> + </el-form-item> + <el-form-item + label="资源地址" + v-if="flowConditionForm.scriptType === 'externalScript'" + key="resource" + > + <el-input v-model="flowConditionForm.resource" clearable @change="updateFlowCondition" /> + </el-form-item> + </template> + </el-form> + </div> +</template> + +<script lang="ts" setup> +defineOptions({ name: 'FlowCondition' }) + +const props = defineProps({ + businessObject: Object, + type: String +}) +const flowConditionForm = ref<any>({}) +const bpmnElement = ref() +const bpmnElementSource = ref() +const bpmnElementSourceRef = ref() +const flowConditionRef = ref() +const bpmnInstances = () => (window as any)?.bpmnInstances +const resetFlowCondition = () => { + bpmnElement.value = bpmnInstances().bpmnElement + bpmnElementSource.value = bpmnElement.value.source + bpmnElementSourceRef.value = bpmnElement.value.businessObject.sourceRef + // 初始化默认type为default + flowConditionForm.value = { type: 'default' } + if ( + bpmnElementSourceRef.value && + bpmnElementSourceRef.value.default && + bpmnElementSourceRef.value.default.id === bpmnElement.value.id + ) { + flowConditionForm.value = { type: 'default' } + } else if (!bpmnElement.value.businessObject.conditionExpression) { + // 普通 + flowConditionForm.value = { type: 'normal' } + } else { + // 带条件 + const conditionExpression = bpmnElement.value.businessObject.conditionExpression + flowConditionForm.value = { ...conditionExpression, type: 'condition' } + // resource 可直接标识 是否是外部资源脚本 + if (flowConditionForm.value.resource) { + // this.$set(this.flowConditionForm, "conditionType", "script"); + // this.$set(this.flowConditionForm, "scriptType", "externalScript"); + flowConditionForm.value['conditionType'] = 'script' + flowConditionForm.value['scriptType'] = 'externalScript' + return + } + if (conditionExpression.language) { + // this.$set(this.flowConditionForm, "conditionType", "script"); + // this.$set(this.flowConditionForm, "scriptType", "inlineScript"); + flowConditionForm.value['conditionType'] = 'script' + flowConditionForm.value['scriptType'] = 'inlineScript' + + return + } + // this.$set(this.flowConditionForm, "conditionType", "expression"); + flowConditionForm.value['conditionType'] = 'expression' + } +} +const updateFlowType = (flowType) => { + // 正常条件类 + if (flowType === 'condition') { + flowConditionRef.value = bpmnInstances().moddle.create('bpmn:FormalExpression') + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + conditionExpression: flowConditionRef.value + }) + return + } + // 默认路径 + if (flowType === 'default') { + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + conditionExpression: null + }) + bpmnInstances().modeling.updateProperties(toRaw(bpmnElementSource.value), { + default: toRaw(bpmnElement.value) + }) + return + } + // 正常路径,如果来源节点的默认路径是当前连线时,清除父元素的默认路径配置 + if ( + bpmnElementSourceRef.value.default && + bpmnElementSourceRef.value.default.id === bpmnElement.value.id + ) { + bpmnInstances().modeling.updateProperties(toRaw(bpmnElementSource.value), { + default: null + }) + } + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + conditionExpression: null + }) +} +const updateFlowCondition = () => { + let { conditionType, scriptType, body, resource, language } = flowConditionForm.value + let condition + if (conditionType === 'expression') { + condition = bpmnInstances().moddle.create('bpmn:FormalExpression', { body }) + } else { + if (scriptType === 'inlineScript') { + condition = bpmnInstances().moddle.create('bpmn:FormalExpression', { body, language }) + // this.$set(this.flowConditionForm, "resource", ""); + flowConditionForm.value['resource'] = '' + } else { + // this.$set(this.flowConditionForm, "body", ""); + flowConditionForm.value['body'] = '' + condition = bpmnInstances().moddle.create('bpmn:FormalExpression', { + resource, + language + }) + } + } + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + conditionExpression: condition + }) +} + +onBeforeUnmount(() => { + bpmnElement.value = null + bpmnElementSource.value = null + bpmnElementSourceRef.value = null +}) + +watch( + () => props.businessObject, + (val) => { + console.log(val, 'val') + nextTick(() => { + resetFlowCondition() + }) + }, + { + immediate: true + } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/form/ElementForm.vue b/src/components/bpmnProcessDesigner/package/penal/form/ElementForm.vue new file mode 100644 index 0000000..33f0bc0 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/form/ElementForm.vue @@ -0,0 +1,478 @@ +<template> + <div class="panel-tab__content"> + <el-form label-width="80px"> + <el-form-item label="流程表单"> + <!-- <el-input v-model="formKey" clearable @change="updateElementFormKey" />--> + <el-select v-model="formKey" clearable @change="updateElementFormKey"> + <el-option v-for="form in formList" :key="form.id" :label="form.name" :value="form.id" /> + </el-select> + </el-form-item> + <!-- <el-form-item label="业务标识">--> + <!-- <el-select v-model="businessKey" @change="updateElementBusinessKey">--> + <!-- <el-option v-for="i in fieldList" :key="i.id" :value="i.id" :label="i.label" />--> + <!-- <el-option label="无" value="" />--> + <!-- </el-select>--> + <!-- </el-form-item>--> + </el-form> + + <!--字段列表--> + <!-- <div class="element-property list-property">--> + <!-- <el-divider><Icon icon="ep:coin" /> 表单字段</el-divider>--> + <!-- <el-table :data="fieldList" max-height="240" fit border>--> + <!-- <el-table-column label="序号" type="index" width="50px" />--> + <!-- <el-table-column label="字段名称" prop="label" min-width="80px" show-overflow-tooltip />--> + <!-- <el-table-column--> + <!-- label="字段类型"--> + <!-- prop="type"--> + <!-- min-width="80px"--> + <!-- :formatter="(row) => fieldType[row.type] || row.type"--> + <!-- show-overflow-tooltip--> + <!-- />--> + <!-- <el-table-column--> + <!-- label="默认值"--> + <!-- prop="defaultValue"--> + <!-- min-width="80px"--> + <!-- show-overflow-tooltip--> + <!-- />--> + <!-- <el-table-column label="操作" width="90px">--> + <!-- <template #default="scope">--> + <!-- <el-button type="primary" link @click="openFieldForm(scope, scope.$index)"--> + <!-- >编辑</el-button--> + <!-- >--> + <!-- <el-divider direction="vertical" />--> + <!-- <el-button--> + <!-- type="primary"--> + <!-- link--> + <!-- style="color: #ff4d4f"--> + <!-- @click="removeField(scope, scope.$index)"--> + <!-- >移除</el-button--> + <!-- >--> + <!-- </template>--> + <!-- </el-table-column>--> + <!-- </el-table>--> + <!-- </div>--> + <!-- <div class="element-drawer__button">--> + <!-- <XButton type="primary" proIcon="ep:plus" title="添加字段" @click="openFieldForm(null, -1)" />--> + <!-- </div>--> + + <!--字段配置侧边栏--> + <!-- <el-drawer--> + <!-- v-model="fieldModelVisible"--> + <!-- title="字段配置"--> + <!-- :size="`${width}px`"--> + <!-- append-to-body--> + <!-- destroy-on-close--> + <!-- >--> + <!-- <el-form :model="formFieldForm" label-width="90px">--> + <!-- <el-form-item label="字段ID">--> + <!-- <el-input v-model="formFieldForm.id" clearable />--> + <!-- </el-form-item>--> + <!-- <el-form-item label="类型">--> + <!-- <el-select--> + <!-- v-model="formFieldForm.typeType"--> + <!-- placeholder="请选择字段类型"--> + <!-- clearable--> + <!-- @change="changeFieldTypeType"--> + <!-- >--> + <!-- <el-option v-for="(value, key) of fieldType" :label="value" :value="key" :key="key" />--> + <!-- </el-select>--> + <!-- </el-form-item>--> + <!-- <el-form-item label="类型名称" v-if="formFieldForm.typeType === 'custom'">--> + <!-- <el-input v-model="formFieldForm.type" clearable />--> + <!-- </el-form-item>--> + <!-- <el-form-item label="名称">--> + <!-- <el-input v-model="formFieldForm.label" clearable />--> + <!-- </el-form-item>--> + <!-- <el-form-item label="时间格式" v-if="formFieldForm.typeType === 'date'">--> + <!-- <el-input v-model="formFieldForm.datePattern" clearable />--> + <!-- </el-form-item>--> + <!-- <el-form-item label="默认值">--> + <!-- <el-input v-model="formFieldForm.defaultValue" clearable />--> + <!-- </el-form-item>--> + <!-- </el-form>--> + + <!-- <!– 枚举值设置 –>--> + <!-- <template v-if="formFieldForm.type === 'enum'">--> + <!-- <el-divider key="enum-divider" />--> + <!-- <p class="listener-filed__title" key="enum-title">--> + <!-- <span><Icon icon="ep:menu" />枚举值列表:</span>--> + <!-- <el-button type="primary" @click="openFieldOptionForm(null, -1, 'enum')"--> + <!-- >添加枚举值</el-button--> + <!-- >--> + <!-- </p>--> + <!-- <el-table :data="fieldEnumList" key="enum-table" max-height="240" fit border>--> + <!-- <el-table-column label="序号" width="50px" type="index" />--> + <!-- <el-table-column label="枚举值编号" prop="id" min-width="100px" show-overflow-tooltip />--> + <!-- <el-table-column label="枚举值名称" prop="name" min-width="100px" show-overflow-tooltip />--> + <!-- <el-table-column label="操作" width="90px">--> + <!-- <template #default="scope">--> + <!-- <el-button--> + <!-- type="primary"--> + <!-- link--> + <!-- @click="openFieldOptionForm(scope, scope.$index, 'enum')"--> + <!-- >编辑</el-button--> + <!-- >--> + <!-- <el-divider direction="vertical" />--> + <!-- <el-button--> + <!-- type="primary"--> + <!-- link--> + <!-- style="color: #ff4d4f"--> + <!-- @click="removeFieldOptionItem(scope, scope.$index, 'enum')"--> + <!-- >移除</el-button--> + <!-- >--> + <!-- </template>--> + <!-- </el-table-column>--> + <!-- </el-table>--> + <!-- </template>--> + + <!-- <!– 校验规则 –>--> + <!-- <el-divider key="validation-divider" />--> + <!-- <p class="listener-filed__title" key="validation-title">--> + <!-- <span><Icon icon="ep:menu" />约束条件列表:</span>--> + <!-- <el-button type="primary" @click="openFieldOptionForm(null, -1, 'constraint')"--> + <!-- >添加约束</el-button--> + <!-- >--> + <!-- </p>--> + <!-- <el-table :data="fieldConstraintsList" key="validation-table" max-height="240" fit border>--> + <!-- <el-table-column label="序号" width="50px" type="index" />--> + <!-- <el-table-column label="约束名称" prop="name" min-width="100px" show-overflow-tooltip />--> + <!-- <el-table-column label="约束配置" prop="config" min-width="100px" show-overflow-tooltip />--> + <!-- <el-table-column label="操作" width="90px">--> + <!-- <template #default="scope">--> + <!-- <el-button--> + <!-- type="primary"--> + <!-- link--> + <!-- @click="openFieldOptionForm(scope, scope.$index, 'constraint')"--> + <!-- >编辑</el-button--> + <!-- >--> + <!-- <el-divider direction="vertical" />--> + <!-- <el-button--> + <!-- type="primary"--> + <!-- link--> + <!-- style="color: #ff4d4f"--> + <!-- @click="removeFieldOptionItem(scope, scope.$index, 'constraint')"--> + <!-- >移除</el-button--> + <!-- >--> + <!-- </template>--> + <!-- </el-table-column>--> + <!-- </el-table>--> + + <!-- <!– 表单属性 –>--> + <!-- <el-divider key="property-divider" />--> + <!-- <p class="listener-filed__title" key="property-title">--> + <!-- <span><Icon icon="ep:menu" />字段属性列表:</span>--> + <!-- <el-button type="primary" @click="openFieldOptionForm(null, -1, 'property')"--> + <!-- >添加属性</el-button--> + <!-- >--> + <!-- </p>--> + <!-- <el-table :data="fieldPropertiesList" key="property-table" max-height="240" fit border>--> + <!-- <el-table-column label="序号" width="50px" type="index" />--> + <!-- <el-table-column label="属性编号" prop="id" min-width="100px" show-overflow-tooltip />--> + <!-- <el-table-column label="属性值" prop="value" min-width="100px" show-overflow-tooltip />--> + <!-- <el-table-column label="操作" width="90px">--> + <!-- <template #default="scope">--> + <!-- <el-button--> + <!-- type="primary"--> + <!-- link--> + <!-- @click="openFieldOptionForm(scope, scope.$index, 'property')"--> + <!-- >编辑</el-button--> + <!-- >--> + <!-- <el-divider direction="vertical" />--> + <!-- <el-button--> + <!-- type="primary"--> + <!-- link--> + <!-- style="color: #ff4d4f"--> + <!-- @click="removeFieldOptionItem(scope, scope.$index, 'property')"--> + <!-- >移除</el-button--> + <!-- >--> + <!-- </template>--> + <!-- </el-table-column>--> + <!-- </el-table>--> + + <!-- <!– 底部按钮 –>--> + <!-- <div class="element-drawer__button">--> + <!-- <el-button>取 消</el-button>--> + <!-- <el-button type="primary" @click="saveField">保 存</el-button>--> + <!-- </div>--> + <!-- </el-drawer>--> + + <!-- <el-dialog--> + <!-- v-model="fieldOptionModelVisible"--> + <!-- :title="optionModelTitle"--> + <!-- width="600px"--> + <!-- append-to-body--> + <!-- destroy-on-close--> + <!-- >--> + <!-- <el-form :model="fieldOptionForm" label-width="96px">--> + <!-- <el-form-item label="编号/ID" v-if="fieldOptionType !== 'constraint'" key="option-id">--> + <!-- <el-input v-model="fieldOptionForm.id" clearable />--> + <!-- </el-form-item>--> + <!-- <el-form-item label="名称" v-if="fieldOptionType !== 'property'" key="option-name">--> + <!-- <el-input v-model="fieldOptionForm.name" clearable />--> + <!-- </el-form-item>--> + <!-- <el-form-item label="配置" v-if="fieldOptionType === 'constraint'" key="option-config">--> + <!-- <el-input v-model="fieldOptionForm.config" clearable />--> + <!-- </el-form-item>--> + <!-- <el-form-item label="值" v-if="fieldOptionType === 'property'" key="option-value">--> + <!-- <el-input v-model="fieldOptionForm.value" clearable />--> + <!-- </el-form-item>--> + <!-- </el-form>--> + <!-- <template #footer>--> + <!-- <el-button @click="fieldOptionModelVisible = false">取 消</el-button>--> + <!-- <el-button type="primary" @click="saveFieldOption">确 定</el-button>--> + <!-- </template>--> + <!-- </el-dialog>--> + </div> +</template> + +<script lang="ts" setup> +import * as FormApi from '@/api/bpm/form' + +defineOptions({ name: 'ElementForm' }) + +const props = defineProps({ + id: String, + type: String +}) +const prefix = inject('prefix') +const width = inject('width') + +const formKey = ref('') +const businessKey = ref('') +const optionModelTitle = ref('') +const fieldList = ref<any[]>([]) +const formFieldForm = ref<any>({}) +const fieldType = ref({ + long: '长整型', + string: '字符串', + boolean: '布尔类', + date: '日期类', + enum: '枚举类', + custom: '自定义类型' +}) +const formFieldIndex = ref(-1) // 编辑中的字段, -1 为新增 +const formFieldOptionIndex = ref(-1) // 编辑中的字段配置项, -1 为新增 +const fieldModelVisible = ref(false) +const fieldOptionModelVisible = ref(false) +const fieldOptionForm = ref<any>({}) // 当前激活的字段配置项数据 +const fieldOptionType = ref('') // 当前激活的字段配置项弹窗 类型 +const fieldEnumList = ref<any[]>([]) // 枚举值列表 +const fieldConstraintsList = ref<any[]>([]) // 约束条件列表 +const fieldPropertiesList = ref<any[]>([]) // 绑定属性列表 +const bpmnELement = ref() +const elExtensionElements = ref() +const formData = ref() +const otherExtensions = ref() + +const bpmnInstances = () => (window as any)?.bpmnInstances +const resetFormList = () => { + bpmnELement.value = bpmnInstances().bpmnElement + formKey.value = bpmnELement.value.businessObject.formKey + if (formKey.value?.length > 0) { + formKey.value = parseInt(formKey.value) + } + // 获取元素扩展属性 或者 创建扩展属性 + elExtensionElements.value = + bpmnELement.value.businessObject.get('extensionElements') || + bpmnInstances().moddle.create('bpmn:ExtensionElements', { values: [] }) + // 获取元素表单配置 或者 创建新的表单配置 + formData.value = + elExtensionElements.value.values.filter((ex) => ex.$type === `${prefix}:FormData`)?.[0] || + bpmnInstances().moddle.create(`${prefix}:FormData`, { fields: [] }) + + // 业务标识 businessKey, 绑定在 formData 中 + businessKey.value = formData.value.businessKey + + // 保留剩余扩展元素,便于后面更新该元素对应属性 + otherExtensions.value = elExtensionElements.value.values.filter( + (ex) => ex.$type !== `${prefix}:FormData` + ) + + // 复制原始值,填充表格 + fieldList.value = JSON.parse(JSON.stringify(formData.value.fields || [])) + + // 更新元素扩展属性,避免后续报错 + updateElementExtensions() +} +const updateElementFormKey = () => { + bpmnInstances().modeling.updateProperties(toRaw(bpmnELement.value), { + formKey: formKey.value + }) +} +const updateElementBusinessKey = () => { + bpmnInstances().modeling.updateModdleProperties(toRaw(bpmnELement.value), formData.value, { + businessKey: businessKey.value + }) +} +// 根据类型调整字段type +const changeFieldTypeType = (type) => { + // this.$set(this.formFieldForm, "type", type === "custom" ? "" : type); + formFieldForm.value['type'] = type === 'custom' ? '' : type +} + +// 打开字段详情侧边栏 +const openFieldForm = (field, index) => { + formFieldIndex.value = index + if (index !== -1) { + const FieldObject = formData.value.fields[index] + formFieldForm.value = JSON.parse(JSON.stringify(field)) + // 设置自定义类型 + // this.$set(this.formFieldForm, "typeType", !this.fieldType[field.type] ? "custom" : field.type); + formFieldForm.value['typeType'] = !fieldType.value[field.type] ? 'custom' : field.type + // 初始化枚举值列表 + field.type === 'enum' && + (fieldEnumList.value = JSON.parse(JSON.stringify(FieldObject?.values || []))) + // 初始化约束条件列表 + fieldConstraintsList.value = JSON.parse( + JSON.stringify(FieldObject?.validation?.constraints || []) + ) + // 初始化自定义属性列表 + fieldPropertiesList.value = JSON.parse(JSON.stringify(FieldObject?.properties?.values || [])) + } else { + formFieldForm.value = {} + // 初始化枚举值列表 + fieldEnumList.value = [] + // 初始化约束条件列表 + fieldConstraintsList.value = [] + // 初始化自定义属性列表 + fieldPropertiesList.value = [] + } + fieldModelVisible.value = true +} +// 打开字段 某个 配置项 弹窗 +const openFieldOptionForm = (option, index, type) => { + fieldOptionModelVisible.value = true + fieldOptionType.value = type + formFieldOptionIndex.value = index + if (type === 'property') { + fieldOptionForm.value = option ? JSON.parse(JSON.stringify(option)) : {} + return (optionModelTitle.value = '属性配置') + } + if (type === 'enum') { + fieldOptionForm.value = option ? JSON.parse(JSON.stringify(option)) : {} + return (optionModelTitle.value = '枚举值配置') + } + fieldOptionForm.value = option ? JSON.parse(JSON.stringify(option)) : {} + return (optionModelTitle.value = '约束条件配置') +} + +// 保存字段 某个 配置项 +const saveFieldOption = () => { + if (formFieldOptionIndex.value === -1) { + if (fieldOptionType.value === 'property') { + fieldPropertiesList.value.push(fieldOptionForm.value) + } + if (fieldOptionType.value === 'constraint') { + fieldConstraintsList.value.push(fieldOptionForm.value) + } + if (fieldOptionType.value === 'enum') { + fieldEnumList.value.push(fieldOptionForm.value) + } + } else { + fieldOptionType.value === 'property' && + fieldPropertiesList.value.splice(formFieldOptionIndex.value, 1, fieldOptionForm.value) + fieldOptionType.value === 'constraint' && + fieldConstraintsList.value.splice(formFieldOptionIndex.value, 1, fieldOptionForm.value) + fieldOptionType.value === 'enum' && + fieldEnumList.value.splice(formFieldOptionIndex.value, 1, fieldOptionForm.value) + } + fieldOptionModelVisible.value = false + fieldOptionForm.value = {} +} +// 保存字段配置 +const saveField = () => { + const { id, type, label, defaultValue, datePattern } = formFieldForm.value + const Field = bpmnInstances().moddle.create(`${prefix}:FormField`, { id, type, label }) + defaultValue && (Field.defaultValue = defaultValue) + datePattern && (Field.datePattern = datePattern) + // 构建属性 + if (fieldPropertiesList.value && fieldPropertiesList.value.length) { + const fieldPropertyList = fieldPropertiesList.value.map((fp) => { + return bpmnInstances().moddle.create(`${prefix}:Property`, { + id: fp.id, + value: fp.value + }) + }) + Field.properties = bpmnInstances().moddle.create(`${prefix}:Properties`, { + values: fieldPropertyList + }) + } + // 构建校验规则 + if (fieldConstraintsList.value && fieldConstraintsList.value.length) { + const fieldConstraintList = fieldConstraintsList.value.map((fc) => { + return bpmnInstances().moddle.create(`${prefix}:Constraint`, { + name: fc.name, + config: fc.config + }) + }) + Field.validation = bpmnInstances().moddle.create(`${prefix}:Validation`, { + constraints: fieldConstraintList + }) + } + // 构建枚举值 + if (fieldEnumList.value && fieldEnumList.value.length) { + Field.values = fieldEnumList.value.map((fe) => { + return bpmnInstances().moddle.create(`${prefix}:Value`, { name: fe.name, id: fe.id }) + }) + } + // 更新数组 与 表单配置实例 + if (formFieldIndex.value === -1) { + fieldList.value.push(formFieldForm.value) + formData.value.fields.push(Field) + } else { + fieldList.value.splice(formFieldIndex.value, 1, formFieldForm.value) + formData.value.fields.splice(formFieldIndex.value, 1, Field) + } + updateElementExtensions() + fieldModelVisible.value = false +} + +// 移除某个 字段的 配置项 +const removeFieldOptionItem = (option, index, type) => { + // console.log(option, 'option') + if (type === 'property') { + fieldPropertiesList.value.splice(index, 1) + return + } + if (type === 'enum') { + fieldEnumList.value.splice(index, 1) + return + } + fieldConstraintsList.value.splice(index, 1) +} +// 移除 字段 +const removeField = (field, index) => { + console.log(field, 'field') + fieldList.value.splice(index, 1) + formData.value.fields.splice(index, 1) + updateElementExtensions() +} + +const updateElementExtensions = () => { + // 更新回扩展元素 + const newElExtensionElements = bpmnInstances().moddle.create(`bpmn:ExtensionElements`, { + values: otherExtensions.value.concat(formData.value) + }) + // 更新到元素上 + bpmnInstances().modeling.updateProperties(toRaw(bpmnELement.value), { + extensionElements: newElExtensionElements + }) +} + +const formList = ref([]) // 流程表单的下拉框的数据 +onMounted(async () => { + formList.value = await FormApi.getFormSimpleList() +}) + +watch( + () => props.id, + (val) => { + val && + val.length && + nextTick(() => { + resetFormList() + }) + }, + { immediate: true } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/index.js b/src/components/bpmnProcessDesigner/package/penal/index.js new file mode 100644 index 0000000..7fa5617 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/index.js @@ -0,0 +1,7 @@ +import MyPropertiesPanel from './PropertiesPanel.vue' + +MyPropertiesPanel.install = function (Vue) { + Vue.component(MyPropertiesPanel.name, MyPropertiesPanel) +} + +export default MyPropertiesPanel diff --git a/src/components/bpmnProcessDesigner/package/penal/listeners/ElementListeners.vue b/src/components/bpmnProcessDesigner/package/penal/listeners/ElementListeners.vue new file mode 100644 index 0000000..de5445c --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/listeners/ElementListeners.vue @@ -0,0 +1,448 @@ +<template> + <div class="panel-tab__content"> + <el-table :data="elementListenersList" size="small" border> + <el-table-column label="序号" width="50px" type="index" /> + <el-table-column label="事件类型" min-width="100px" prop="event" /> + <el-table-column + label="监听器类型" + min-width="100px" + show-overflow-tooltip + :formatter="(row) => listenerTypeObject[row.listenerType]" + /> + <el-table-column label="操作" width="100px"> + <template #default="scope"> + <el-button size="small" link @click="openListenerForm(scope.row, scope.$index)" + >编辑</el-button + > + <el-divider direction="vertical" /> + <el-button size="small" link style="color: #ff4d4f" @click="removeListener(scope.$index)" + >移除</el-button + > + </template> + </el-table-column> + </el-table> + <div class="element-drawer__button"> + <XButton + type="primary" + preIcon="ep:plus" + title="添加监听器" + size="small" + @click="openListenerForm(null)" + /> + <XButton + type="success" + preIcon="ep:select" + title="选择监听器" + size="small" + @click="openProcessListenerDialog" + /> + </div> + + <!-- 监听器 编辑/创建 部分 --> + <el-drawer + v-model="listenerFormModelVisible" + title="执行监听器" + :size="`${width}px`" + append-to-body + destroy-on-close + > + <el-form :model="listenerForm" label-width="96px" ref="listenerFormRef"> + <el-form-item + label="事件类型" + prop="event" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-select v-model="listenerForm.event"> + <el-option label="start" value="start" /> + <el-option label="end" value="end" /> + </el-select> + </el-form-item> + <el-form-item + label="监听器类型" + prop="listenerType" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-select v-model="listenerForm.listenerType"> + <el-option + v-for="i in Object.keys(listenerTypeObject)" + :key="i" + :label="listenerTypeObject[i]" + :value="i" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="listenerForm.listenerType === 'classListener'" + label="Java类" + prop="class" + key="listener-class" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerForm.class" clearable /> + </el-form-item> + <el-form-item + v-if="listenerForm.listenerType === 'expressionListener'" + label="表达式" + prop="expression" + key="listener-expression" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerForm.expression" clearable /> + </el-form-item> + <el-form-item + v-if="listenerForm.listenerType === 'delegateExpressionListener'" + label="代理表达式" + prop="delegateExpression" + key="listener-delegate" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerForm.delegateExpression" clearable /> + </el-form-item> + <template v-if="listenerForm.listenerType === 'scriptListener'"> + <el-form-item + label="脚本格式" + prop="scriptFormat" + key="listener-script-format" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写脚本格式' }" + > + <el-input v-model="listenerForm.scriptFormat" clearable /> + </el-form-item> + <el-form-item + label="脚本类型" + prop="scriptType" + key="listener-script-type" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请选择脚本类型' }" + > + <el-select v-model="listenerForm.scriptType"> + <el-option label="内联脚本" value="inlineScript" /> + <el-option label="外部脚本" value="externalScript" /> + </el-select> + </el-form-item> + <el-form-item + v-if="listenerForm.scriptType === 'inlineScript'" + label="脚本内容" + prop="value" + key="listener-script" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写脚本内容' }" + > + <el-input v-model="listenerForm.value" clearable /> + </el-form-item> + <el-form-item + v-if="listenerForm.scriptType === 'externalScript'" + label="资源地址" + prop="resource" + key="listener-resource" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写资源地址' }" + > + <el-input v-model="listenerForm.resource" clearable /> + </el-form-item> + </template> + </el-form> + <el-divider /> + <p class="listener-filed__title"> + <span><Icon icon="ep:menu" />注入字段:</span> + <XButton type="primary" @click="openListenerFieldForm(null)" title="添加字段" /> + </p> + <el-table + :data="fieldsListOfListener" + size="small" + max-height="240" + fit + border + style="flex: none" + > + <el-table-column label="序号" width="50px" type="index" /> + <el-table-column label="字段名称" min-width="100px" prop="name" /> + <el-table-column + label="字段类型" + min-width="80px" + show-overflow-tooltip + :formatter="(row) => fieldTypeObject[row.fieldType]" + /> + <el-table-column + label="字段值/表达式" + min-width="100px" + show-overflow-tooltip + :formatter="(row) => row.string || row.expression" + /> + <el-table-column label="操作" width="130px"> + <template #default="scope"> + <el-button size="small" link @click="openListenerFieldForm(scope.row, scope.$index)" + >编辑</el-button + > + <el-divider direction="vertical" /> + <el-button + size="small" + link + style="color: #ff4d4f" + @click="removeListenerField(scope.$index)" + >移除</el-button + > + </template> + </el-table-column> + </el-table> + + <div class="element-drawer__button"> + <el-button @click="listenerFormModelVisible = false">取 消</el-button> + <el-button type="primary" @click="saveListenerConfig">保 存</el-button> + </div> + </el-drawer> + + <!-- 注入西段 编辑/创建 部分 --> + <el-dialog + title="字段配置" + v-model="listenerFieldFormModelVisible" + width="600px" + append-to-body + destroy-on-close + > + <el-form + :model="listenerFieldForm" + label-width="96spx" + ref="listenerFieldFormRef" + style="height: 136px" + > + <el-form-item + label="字段名称:" + prop="name" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerFieldForm.name" clearable /> + </el-form-item> + <el-form-item + label="字段类型:" + prop="fieldType" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-select v-model="listenerFieldForm.fieldType"> + <el-option + v-for="i in Object.keys(fieldTypeObject)" + :key="i" + :label="fieldTypeObject[i]" + :value="i" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="listenerFieldForm.fieldType === 'string'" + label="字段值:" + prop="string" + key="field-string" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerFieldForm.string" clearable /> + </el-form-item> + <el-form-item + v-if="listenerFieldForm.fieldType === 'expression'" + label="表达式:" + prop="expression" + key="field-expression" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerFieldForm.expression" clearable /> + </el-form-item> + </el-form> + <template #footer> + <el-button size="small" @click="listenerFieldFormModelVisible = false">取 消</el-button> + <el-button size="small" type="primary" @click="saveListenerFiled">确 定</el-button> + </template> + </el-dialog> + </div> + + <!-- 选择弹窗 --> + <ProcessListenerDialog ref="processListenerDialogRef" @select="selectProcessListener" /> +</template> +<script lang="ts" setup> +import { ElMessageBox } from 'element-plus' +import { createListenerObject, updateElementExtensions } from '../../utils' +import { + initListenerType, + initListenerForm, + listenerType, + fieldType, + initListenerForm2 +} from './utilSelf' +import ProcessListenerDialog from './ProcessListenerDialog.vue' + +defineOptions({ name: 'ElementListeners' }) + +const props = defineProps({ + id: String, + type: String +}) +const prefix = inject('prefix') +const width = inject('width') +const elementListenersList = ref<any[]>([]) // 监听器列表 +const listenerForm = ref<any>({}) // 监听器详情表单 +const listenerFormModelVisible = ref(false) // 监听器 编辑 侧边栏显示状态 +const fieldsListOfListener = ref<any[]>([]) +const listenerFieldForm = ref<any>({}) // 监听器 注入字段 详情表单 +const listenerFieldFormModelVisible = ref(false) // 监听器 注入字段表单弹窗 显示状态 +const editingListenerIndex = ref(-1) // 监听器所在下标,-1 为新增 +const editingListenerFieldIndex = ref(-1) // 字段所在下标,-1 为新增 +const listenerTypeObject = ref(listenerType) +const fieldTypeObject = ref(fieldType) +const bpmnElement = ref() +const otherExtensionList = ref() +const bpmnElementListeners = ref() +const listenerFormRef = ref() +const listenerFieldFormRef = ref() +const bpmnInstances = () => (window as any)?.bpmnInstances + +const resetListenersList = () => { + bpmnElement.value = bpmnInstances().bpmnElement + otherExtensionList.value = [] + bpmnElementListeners.value = + bpmnElement.value.businessObject?.extensionElements?.values?.filter( + (ex) => ex.$type === `${prefix}:ExecutionListener` + ) ?? [] + elementListenersList.value = bpmnElementListeners.value.map((listener) => + initListenerType(listener) + ) +} +// 打开 监听器详情 侧边栏 +const openListenerForm = (listener, index?) => { + // debugger + if (listener) { + listenerForm.value = initListenerForm(listener) + editingListenerIndex.value = index + } else { + listenerForm.value = {} + editingListenerIndex.value = -1 // 标记为新增 + } + if (listener && listener.fields) { + fieldsListOfListener.value = listener.fields.map((field) => ({ + ...field, + fieldType: field.string ? 'string' : 'expression' + })) + } else { + fieldsListOfListener.value = [] + listenerForm.value['fields'] = [] + } + // 打开侧边栏并清楚验证状态 + listenerFormModelVisible.value = true + nextTick(() => { + if (listenerFormRef.value) { + listenerFormRef.value.clearValidate() + } + }) +} +// 打开监听器字段编辑弹窗 +const openListenerFieldForm = (field, index?) => { + listenerFieldForm.value = field ? JSON.parse(JSON.stringify(field)) : {} + editingListenerFieldIndex.value = field ? index : -1 + listenerFieldFormModelVisible.value = true + nextTick(() => { + if (listenerFieldFormRef.value) { + listenerFieldFormRef.value.clearValidate() + } + }) +} +// 保存监听器注入字段 +const saveListenerFiled = async () => { + // debugger + let validateStatus = await listenerFieldFormRef.value.validate() + if (!validateStatus) return // 验证不通过直接返回 + if (editingListenerFieldIndex.value === -1) { + fieldsListOfListener.value.push(listenerFieldForm.value) + listenerForm.value.fields.push(listenerFieldForm.value) + } else { + fieldsListOfListener.value.splice(editingListenerFieldIndex.value, 1, listenerFieldForm.value) + listenerForm.value.fields.splice(editingListenerFieldIndex.value, 1, listenerFieldForm.value) + } + listenerFieldFormModelVisible.value = false + nextTick(() => { + listenerFieldForm.value = {} + }) +} +// 移除监听器字段 +const removeListenerField = (index) => { + // debugger + ElMessageBox.confirm('确认移除该字段吗?', '提示', { + confirmButtonText: '确 认', + cancelButtonText: '取 消' + }) + .then(() => { + fieldsListOfListener.value.splice(index, 1) + listenerForm.value.fields.splice(index, 1) + }) + .catch(() => console.info('操作取消')) +} +// 移除监听器 +const removeListener = (index) => { + debugger + ElMessageBox.confirm('确认移除该监听器吗?', '提示', { + confirmButtonText: '确 认', + cancelButtonText: '取 消' + }) + .then(() => { + bpmnElementListeners.value.splice(index, 1) + elementListenersList.value.splice(index, 1) + updateElementExtensions( + bpmnElement.value, + otherExtensionList.value.concat(bpmnElementListeners.value) + ) + }) + .catch(() => console.info('操作取消')) +} +// 保存监听器配置 +const saveListenerConfig = async () => { + // debugger + let validateStatus = await listenerFormRef.value.validate() + if (!validateStatus) return // 验证不通过直接返回 + const listenerObject = createListenerObject(listenerForm.value, false, prefix) + if (editingListenerIndex.value === -1) { + bpmnElementListeners.value.push(listenerObject) + elementListenersList.value.push(listenerForm.value) + } else { + bpmnElementListeners.value.splice(editingListenerIndex.value, 1, listenerObject) + elementListenersList.value.splice(editingListenerIndex.value, 1, listenerForm.value) + } + // 保存其他配置 + otherExtensionList.value = + bpmnElement.value.businessObject?.extensionElements?.values?.filter( + (ex) => ex.$type !== `${prefix}:ExecutionListener` + ) ?? [] + updateElementExtensions( + bpmnElement.value, + otherExtensionList.value.concat(bpmnElementListeners.value) + ) + // 4. 隐藏侧边栏 + listenerFormModelVisible.value = false + listenerForm.value = {} +} + +// 打开监听器弹窗 +const processListenerDialogRef = ref() +const openProcessListenerDialog = async () => { + processListenerDialogRef.value.open('execution') +} +const selectProcessListener = (listener) => { + const listenerForm = initListenerForm2(listener) + const listenerObject = createListenerObject(listenerForm, false, prefix) + bpmnElementListeners.value.push(listenerObject) + elementListenersList.value.push(listenerForm) + + // 保存其他配置 + otherExtensionList.value = + bpmnElement.value.businessObject?.extensionElements?.values?.filter( + (ex) => ex.$type !== `${prefix}:ExecutionListener` + ) ?? [] + updateElementExtensions( + bpmnElement.value, + otherExtensionList.value.concat(bpmnElementListeners.value) + ) +} + +watch( + () => props.id, + (val) => { + val && + val.length && + nextTick(() => { + resetListenersList() + }) + }, + { immediate: true } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/listeners/ProcessListenerDialog.vue b/src/components/bpmnProcessDesigner/package/penal/listeners/ProcessListenerDialog.vue new file mode 100644 index 0000000..21088ab --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/listeners/ProcessListenerDialog.vue @@ -0,0 +1,85 @@ +<!-- 执行器选择 --> +<template> + <Dialog title="请选择监听器" v-model="dialogVisible" width="1024px"> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column label="类型" align="center" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BPM_PROCESS_LISTENER_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column label="事件" align="center" prop="event" /> + <el-table-column label="值类型" align="center" prop="valueType"> + <template #default="scope"> + <dict-tag + :type="DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE" + :value="scope.row.valueType" + /> + </template> + </el-table-column> + <el-table-column label="值" align="center" prop="value" /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button link type="primary" @click="select(scope.row)"> 选择 </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + </Dialog> +</template> +<script setup lang="ts"> +import { ProcessListenerApi, ProcessListenerVO } from '@/api/bpm/processListener' +import { DICT_TYPE } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' + +/** BPM 流程 表单 */ +defineOptions({ name: 'ProcessListenerDialog' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const loading = ref(true) // 列表的加载中 +const list = ref<ProcessListenerVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + type: '', + status: CommonStatusEnum.ENABLE +}) + +/** 打开弹窗 */ +const open = async (type: string) => { + queryParams.pageNo = 1 + queryParams.type = type + getList() + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProcessListenerApi.getProcessListenerPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const select = async (row) => { + dialogVisible.value = false + // 发送操作成功的事件 + emit('select', row) +} +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/listeners/UserTaskListeners.vue b/src/components/bpmnProcessDesigner/package/penal/listeners/UserTaskListeners.vue new file mode 100644 index 0000000..76e0c80 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/listeners/UserTaskListeners.vue @@ -0,0 +1,491 @@ +<template> + <div class="panel-tab__content"> + <el-table :data="elementListenersList" size="small" border> + <el-table-column label="序号" width="50px" type="index" /> + <el-table-column + label="事件类型" + min-width="80px" + show-overflow-tooltip + :formatter="(row) => listenerEventTypeObject[row.event]" + /> + <el-table-column label="事件id" min-width="80px" prop="id" show-overflow-tooltip /> + <el-table-column + label="监听器类型" + min-width="80px" + show-overflow-tooltip + :formatter="(row) => listenerTypeObject[row.listenerType]" + /> + <el-table-column label="操作" width="90px"> + <template #default="scope"> + <el-button size="small" link @click="openListenerForm(scope.row, scope.$index)" + >编辑</el-button + > + <el-divider direction="vertical" /> + <el-button + size="small" + link + style="color: #ff4d4f" + @click="removeListener(scope.row, scope.$index)" + >移除</el-button + > + </template> + </el-table-column> + </el-table> + <div class="element-drawer__button"> + <XButton + size="small" + type="primary" + preIcon="ep:plus" + title="添加监听器" + @click="openListenerForm(null)" + /> + <XButton + type="success" + preIcon="ep:select" + title="选择监听器" + size="small" + @click="openProcessListenerDialog" + /> + </div> + + <!-- 监听器 编辑/创建 部分 --> + <el-drawer + v-model="listenerFormModelVisible" + title="任务监听器" + :size="`${width}px`" + append-to-body + destroy-on-close + > + <el-form size="small" :model="listenerForm" label-width="96px" ref="listenerFormRef"> + <el-form-item + label="事件类型" + prop="event" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-select v-model="listenerForm.event"> + <el-option + v-for="i in Object.keys(listenerEventTypeObject)" + :key="i" + :label="listenerEventTypeObject[i]" + :value="i" + /> + </el-select> + </el-form-item> + <el-form-item + label="监听器ID" + prop="id" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerForm.id" clearable /> + </el-form-item> + <el-form-item + label="监听器类型" + prop="listenerType" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-select v-model="listenerForm.listenerType"> + <el-option + v-for="i in Object.keys(listenerTypeObject)" + :key="i" + :label="listenerTypeObject[i]" + :value="i" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="listenerForm.listenerType === 'classListener'" + label="Java类" + prop="class" + key="listener-class" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerForm.class" clearable /> + </el-form-item> + <el-form-item + v-if="listenerForm.listenerType === 'expressionListener'" + label="表达式" + prop="expression" + key="listener-expression" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerForm.expression" clearable /> + </el-form-item> + <el-form-item + v-if="listenerForm.listenerType === 'delegateExpressionListener'" + label="代理表达式" + prop="delegateExpression" + key="listener-delegate" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerForm.delegateExpression" clearable /> + </el-form-item> + <template v-if="listenerForm.listenerType === 'scriptListener'"> + <el-form-item + label="脚本格式" + prop="scriptFormat" + key="listener-script-format" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写脚本格式' }" + > + <el-input v-model="listenerForm.scriptFormat" clearable /> + </el-form-item> + <el-form-item + label="脚本类型" + prop="scriptType" + key="listener-script-type" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请选择脚本类型' }" + > + <el-select v-model="listenerForm.scriptType"> + <el-option label="内联脚本" value="inlineScript" /> + <el-option label="外部脚本" value="externalScript" /> + </el-select> + </el-form-item> + <el-form-item + v-if="listenerForm.scriptType === 'inlineScript'" + label="脚本内容" + prop="value" + key="listener-script" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写脚本内容' }" + > + <el-input v-model="listenerForm.value" clearable /> + </el-form-item> + <el-form-item + v-if="listenerForm.scriptType === 'externalScript'" + label="资源地址" + prop="resource" + key="listener-resource" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写资源地址' }" + > + <el-input v-model="listenerForm.resource" clearable /> + </el-form-item> + </template> + + <template v-if="listenerForm.event === 'timeout'"> + <el-form-item label="定时器类型" prop="eventDefinitionType" key="eventDefinitionType"> + <el-select v-model="listenerForm.eventDefinitionType"> + <el-option label="日期" value="date" /> + <el-option label="持续时长" value="duration" /> + <el-option label="循环" value="cycle" /> + <el-option label="无" value="null" /> + </el-select> + </el-form-item> + <el-form-item + v-if="!!listenerForm.eventDefinitionType && listenerForm.eventDefinitionType !== 'null'" + label="定时器" + prop="eventTimeDefinitions" + key="eventTimeDefinitions" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写定时器配置' }" + > + <el-input v-model="listenerForm.eventTimeDefinitions" clearable /> + </el-form-item> + </template> + </el-form> + + <el-divider /> + <p class="listener-filed__title"> + <span><Icon icon="ep:menu" />注入字段:</span> + <el-button size="small" type="primary" @click="openListenerFieldForm(null)" + >添加字段</el-button + > + </p> + <el-table + :data="fieldsListOfListener" + size="small" + max-height="240" + fit + border + style="flex: none" + > + <el-table-column label="序号" width="50px" type="index" /> + <el-table-column label="字段名称" min-width="100px" prop="name" /> + <el-table-column + label="字段类型" + min-width="80px" + show-overflow-tooltip + :formatter="(row) => fieldTypeObject[row.fieldType]" + /> + <el-table-column + label="字段值/表达式" + min-width="100px" + show-overflow-tooltip + :formatter="(row) => row.string || row.expression" + /> + <el-table-column label="操作" width="100px"> + <template #default="scope"> + <el-button size="small" link @click="openListenerFieldForm(scope.row, scope.$index)" + >编辑</el-button + > + <el-divider direction="vertical" /> + <el-button + size="small" + link + style="color: #ff4d4f" + @click="removeListenerField(scope.row, scope.$index)" + >移除</el-button + > + </template> + </el-table-column> + </el-table> + + <div class="element-drawer__button"> + <el-button size="small" @click="listenerFormModelVisible = false">取 消</el-button> + <el-button size="small" type="primary" @click="saveListenerConfig">保 存</el-button> + </div> + </el-drawer> + + <!-- 注入西段 编辑/创建 部分 --> + <el-dialog + title="字段配置" + v-model="listenerFieldFormModelVisible" + width="600px" + append-to-body + destroy-on-close + > + <el-form + :model="listenerFieldForm" + size="small" + label-width="96px" + ref="listenerFieldFormRef" + style="height: 136px" + > + <el-form-item + label="字段名称:" + prop="name" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerFieldForm.name" clearable /> + </el-form-item> + <el-form-item + label="字段类型:" + prop="fieldType" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-select v-model="listenerFieldForm.fieldType"> + <el-option + v-for="i in Object.keys(fieldTypeObject)" + :key="i" + :label="fieldTypeObject[i]" + :value="i" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="listenerFieldForm.fieldType === 'string'" + label="字段值:" + prop="string" + key="field-string" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerFieldForm.string" clearable /> + </el-form-item> + <el-form-item + v-if="listenerFieldForm.fieldType === 'expression'" + label="表达式:" + prop="expression" + key="field-expression" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerFieldForm.expression" clearable /> + </el-form-item> + </el-form> + <template #footer> + <el-button size="small" @click="listenerFieldFormModelVisible = false">取 消</el-button> + <el-button size="small" type="primary" @click="saveListenerFiled">确 定</el-button> + </template> + </el-dialog> + </div> + + <!-- 选择弹窗 --> + <ProcessListenerDialog ref="processListenerDialogRef" @select="selectProcessListener" /> +</template> +<script lang="ts" setup> +import { ElMessageBox } from 'element-plus' +import { createListenerObject, updateElementExtensions } from '../../utils' +import { + initListenerForm, + initListenerType, + eventType, + listenerType, + fieldType, + initListenerForm2 +} from './utilSelf' +import ProcessListenerDialog from '@/components/bpmnProcessDesigner/package/penal/listeners/ProcessListenerDialog.vue' + +defineOptions({ name: 'UserTaskListeners' }) + +const props = defineProps({ + id: String, + type: String +}) +const prefix = inject('prefix') +const width = inject('width') +const elementListenersList = ref<any[]>([]) +const listenerEventTypeObject = ref(eventType) +const listenerTypeObject = ref(listenerType) +const listenerFormModelVisible = ref(false) +const listenerForm = ref<any>({}) +const fieldTypeObject = ref(fieldType) +const fieldsListOfListener = ref<any[]>([]) +const listenerFieldFormModelVisible = ref(false) // 监听器 注入字段表单弹窗 显示状态 +const editingListenerIndex = ref(-1) // 监听器所在下标,-1 为新增 +const editingListenerFieldIndex = ref(-1) // 字段所在下标,-1 为新增 +const listenerFieldForm = ref<any>({}) // 监听器 注入字段 详情表单 +const bpmnElement = ref() +const bpmnElementListeners = ref() +const otherExtensionList = ref() +const listenerFormRef = ref() +const listenerFieldFormRef = ref() +const bpmnInstances = () => (window as any)?.bpmnInstances + +const resetListenersList = () => { + console.log( + bpmnInstances().bpmnElement, + 'window.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElement' + ) + bpmnElement.value = bpmnInstances().bpmnElement + otherExtensionList.value = [] + bpmnElementListeners.value = + bpmnElement.value.businessObject?.extensionElements?.values.filter( + (ex) => ex.$type === `${prefix}:TaskListener` + ) ?? [] + elementListenersList.value = bpmnElementListeners.value.map((listener) => + initListenerType(listener) + ) +} +const openListenerForm = (listener, index?) => { + if (listener) { + listenerForm.value = initListenerForm(listener) + editingListenerIndex.value = index + } else { + listenerForm.value = {} + editingListenerIndex.value = -1 // 标记为新增 + } + if (listener && listener.fields) { + fieldsListOfListener.value = listener.fields.map((field) => ({ + ...field, + fieldType: field.string ? 'string' : 'expression' + })) + } else { + fieldsListOfListener.value = [] + listenerForm.value['fields'] = [] + } + // 打开侧边栏并清楚验证状态 + listenerFormModelVisible.value = true + nextTick(() => { + if (listenerFormRef.value) listenerFormRef.value.clearValidate() + }) +} +// 移除监听器 +const removeListener = (listener, index?) => { + console.log(listener, 'listener') + ElMessageBox.confirm('确认移除该监听器吗?', '提示', { + confirmButtonText: '确 认', + cancelButtonText: '取 消' + }) + .then(() => { + bpmnElementListeners.value.splice(index, 1) + elementListenersList.value.splice(index, 1) + updateElementExtensions( + bpmnElement.value, + otherExtensionList.value.concat(bpmnElementListeners.value) + ) + }) + .catch(() => console.info('操作取消')) +} +// 保存监听器 +const saveListenerConfig = async () => { + let validateStatus = await listenerFormRef.value.validate() + if (!validateStatus) return // 验证不通过直接返回 + const listenerObject = createListenerObject(listenerForm.value, true, prefix) + if (editingListenerIndex.value === -1) { + bpmnElementListeners.value.push(listenerObject) + elementListenersList.value.push(listenerForm.value) + } else { + bpmnElementListeners.value.splice(editingListenerIndex.value, 1, listenerObject) + elementListenersList.value.splice(editingListenerIndex.value, 1, listenerForm.value) + } + // 保存其他配置 + otherExtensionList.value = + bpmnElement.value.businessObject?.extensionElements?.values?.filter( + (ex) => ex.$type !== `${prefix}:TaskListener` + ) ?? [] + updateElementExtensions( + bpmnElement.value, + otherExtensionList.value.concat(bpmnElementListeners.value) + ) + // 4. 隐藏侧边栏 + listenerFormModelVisible.value = false + listenerForm.value = {} +} +// 打开监听器字段编辑弹窗 +const openListenerFieldForm = (field, index?) => { + listenerFieldForm.value = field ? JSON.parse(JSON.stringify(field)) : {} + editingListenerFieldIndex.value = field ? index : -1 + listenerFieldFormModelVisible.value = true + nextTick(() => { + if (listenerFieldFormRef.value) listenerFieldFormRef.value.clearValidate() + }) +} +// 保存监听器注入字段 +const saveListenerFiled = async () => { + let validateStatus = await listenerFieldFormRef.value.validate() + if (!validateStatus) return // 验证不通过直接返回 + if (editingListenerFieldIndex.value === -1) { + fieldsListOfListener.value.push(listenerFieldForm.value) + listenerForm.value.fields.push(listenerFieldForm.value) + } else { + fieldsListOfListener.value.splice(editingListenerFieldIndex.value, 1, listenerFieldForm.value) + listenerForm.value.fields.splice(editingListenerFieldIndex.value, 1, listenerFieldForm.value) + } + listenerFieldFormModelVisible.value = false + nextTick(() => { + listenerFieldForm.value = {} + }) +} +// 移除监听器字段 +const removeListenerField = (field, index) => { + console.log(field, 'field') + ElMessageBox.confirm('确认移除该字段吗?', '提示', { + confirmButtonText: '确 认', + cancelButtonText: '取 消' + }) + .then(() => { + fieldsListOfListener.value.splice(index, 1) + listenerForm.value.fields.splice(index, 1) + }) + .catch(() => console.info('操作取消')) +} + +// 打开监听器弹窗 +const processListenerDialogRef = ref() +const openProcessListenerDialog = async () => { + processListenerDialogRef.value.open('task') +} +const selectProcessListener = (listener) => { + const listenerForm = initListenerForm2(listener) + const listenerObject = createListenerObject(listenerForm, true, prefix) + bpmnElementListeners.value.push(listenerObject) + elementListenersList.value.push(listenerForm) + + // 保存其他配置 + otherExtensionList.value = + bpmnElement.value.businessObject?.extensionElements?.values?.filter( + (ex) => ex.$type !== `${prefix}:TaskListener` + ) ?? [] + updateElementExtensions( + bpmnElement.value, + otherExtensionList.value.concat(bpmnElementListeners.value) + ) +} + +watch( + () => props.id, + (val) => { + val && + val.length && + nextTick(() => { + resetListenersList() + }) + }, + { immediate: true } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/listeners/template.js b/src/components/bpmnProcessDesigner/package/penal/listeners/template.js new file mode 100644 index 0000000..430dc64 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/listeners/template.js @@ -0,0 +1,178 @@ +export const template = (isTaskListener) => { + return ` + <div class="panel-tab__content"> + <el-table :data="elementListenersList" size="small" border> + <el-table-column label="序号" width="50px" type="index" /> + <el-table-column label="事件类型" min-width="100px" prop="event" /> + <el-table-column label="监听器类型" min-width="100px" show-overflow-tooltip :formatter="row => listenerTypeObject[row.listenerType]" /> + <el-table-column label="操作" width="90px"> + <template #default="scope"> + <el-button size="small" type="primary" link @click="openListenerForm(scope, scope.$index)">编辑</el-button> + <el-divider direction="vertical" /> + <el-button size="small" type="primary" link style="color: #ff4d4f" @click="removeListener(scope, scope.$index)">移除</el-button> + </template> + </el-table-column> + </el-table> + <div class="element-drawer__button"> + <el-button size="small" type="primary" icon="el-icon-plus" @click="openListenerForm(null)">添加监听器</el-button> + </div> + + <!-- 监听器 编辑/创建 部分 --> + <el-drawer :visible.sync="listenerFormModelVisible" title="执行监听器" :size="width + 'px'" append-to-body destroy-on-close> + <el-form size="small" :model="listenerForm" label-width="96px" ref="listenerFormRef" @submit.native.prevent> + <el-form-item label="事件类型" prop="event" :rules="{ required: true, trigger: ['blur', 'change'] }"> + <el-select v-model="listenerForm.event"> + <el-option label="start" value="start" /> + <el-option label="end" value="end" /> + </el-select> + </el-form-item> + <el-form-item label="监听器类型" prop="listenerType" :rules="{ required: true, trigger: ['blur', 'change'] }"> + <el-select v-model="listenerForm.listenerType"> + <el-option v-for="i in Object.keys(listenerTypeObject)" :key="i" :label="listenerTypeObject[i]" :value="i" /> + </el-select> + </el-form-item> + <el-form-item + v-if="listenerForm.listenerType === 'classListener'" + label="Java类" + prop="class" + key="listener-class" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerForm.class" clearable /> + </el-form-item> + <el-form-item + v-if="listenerForm.listenerType === 'expressionListener'" + label="表达式" + prop="expression" + key="listener-expression" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerForm.expression" clearable /> + </el-form-item> + <el-form-item + v-if="listenerForm.listenerType === 'delegateExpressionListener'" + label="代理表达式" + prop="delegateExpression" + key="listener-delegate" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerForm.delegateExpression" clearable /> + </el-form-item> + <template v-if="listenerForm.listenerType === 'scriptListener'"> + <el-form-item + label="脚本格式" + prop="scriptFormat" + key="listener-script-format" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写脚本格式' }" + > + <el-input v-model="listenerForm.scriptFormat" clearable /> + </el-form-item> + <el-form-item + label="脚本类型" + prop="scriptType" + key="listener-script-type" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请选择脚本类型' }" + > + <el-select v-model="listenerForm.scriptType"> + <el-option label="内联脚本" value="inlineScript" /> + <el-option label="外部脚本" value="externalScript" /> + </el-select> + </el-form-item> + <el-form-item + v-if="listenerForm.scriptType === 'inlineScript'" + label="脚本内容" + prop="value" + key="listener-script" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写脚本内容' }" + > + <el-input v-model="listenerForm.value" clearable /> + </el-form-item> + <el-form-item + v-if="listenerForm.scriptType === 'externalScript'" + label="资源地址" + prop="resource" + key="listener-resource" + :rules="{ required: true, trigger: ['blur', 'change'], message: '请填写资源地址' }" + > + <el-input v-model="listenerForm.resource" clearable /> + </el-form-item> + </template> + ${ + isTaskListener + ? "<el-form-item label='定时器类型' prop='eventDefinitionType' key='eventDefinitionType'>" + + "<el-select v-model='listenerForm.eventDefinitionType'>" + + "<el-option label='日期' value='date' />" + + "<el-option label='持续时长' value='duration' />" + + "<el-option label='循环' value='cycle' />" + + "<el-option label='无' value='' />" + + '</el-select>' + + '</el-form-item>' + + "<el-form-item v-if='!!listenerForm.eventDefinitionType' label='定时器' prop='eventDefinitions' key='eventDefinitions'>" + + "<el-input v-model='listenerForm.eventDefinitions' clearable />" + + '</el-form-item>' + : '' + } + </el-form> + <el-divider /> + <p class="listener-filed__title"> + <span><i class="el-icon-menu"></i>注入字段:</span> + <el-button size="small" type="primary" @click="openListenerFieldForm(null)">添加字段</el-button> + </p> + <el-table :data="fieldsListOfListener" size="small" max-height="240" border fit style="flex: none"> + <el-table-column label="序号" width="50px" type="index" /> + <el-table-column label="字段名称" min-width="100px" prop="name" /> + <el-table-column label="字段类型" min-width="80px" show-overflow-tooltip :formatter="row => fieldTypeObject[row.fieldType]" /> + <el-table-column label="字段值/表达式" min-width="100px" show-overflow-tooltip :formatter="row => row.string || row.expression" /> + <el-table-column label="操作" width="100px"> + <template #default="scope"> + <el-button size="small" type="primary" link @click="openListenerFieldForm(scope, scope.$index)">编辑</el-button> + <el-divider direction="vertical" /> + <el-button size="small" type="primary" link style="color: #ff4d4f" @click="removeListenerField(scope, scope.$index)">移除</el-button> + </template> + </el-table-column> + </el-table> + + <div class="element-drawer__button"> + <el-button size="small" @click="listenerFormModelVisible = false">取 消</el-button> + <el-button size="small" type="primary" @click="saveListenerConfig">保 存</el-button> + </div> + </el-drawer> + + <!-- 注入西段 编辑/创建 部分 --> + <el-dialog title="字段配置" :visible.sync="listenerFieldFormModelVisible" width="600px" append-to-body destroy-on-close> + <el-form :model="listenerFieldForm" size="small" label-width="96px" ref="listenerFieldFormRef" style="height: 136px" @submit.native.prevent> + <el-form-item label="字段名称:" prop="name" :rules="{ required: true, trigger: ['blur', 'change'] }"> + <el-input v-model="listenerFieldForm.name" clearable /> + </el-form-item> + <el-form-item label="字段类型:" prop="fieldType" :rules="{ required: true, trigger: ['blur', 'change'] }"> + <el-select v-model="listenerFieldForm.fieldType"> + <el-option v-for="i in Object.keys(fieldTypeObject)" :key="i" :label="fieldTypeObject[i]" :value="i" /> + </el-select> + </el-form-item> + <el-form-item + v-if="listenerFieldForm.fieldType === 'string'" + label="字段值:" + prop="string" + key="field-string" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerFieldForm.string" clearable /> + </el-form-item> + <el-form-item + v-if="listenerFieldForm.fieldType === 'expression'" + label="表达式:" + prop="expression" + key="field-expression" + :rules="{ required: true, trigger: ['blur', 'change'] }" + > + <el-input v-model="listenerFieldForm.expression" clearable /> + </el-form-item> + </el-form> + <template #footer> + <el-button size="small" @click="listenerFieldFormModelVisible = false">取 消</el-button> + <el-button size="small" type="primary" @click="saveListenerFiled">确 定</el-button> + </template> + </el-dialog> + </div> + ` +} diff --git a/src/components/bpmnProcessDesigner/package/penal/listeners/utilSelf.ts b/src/components/bpmnProcessDesigner/package/penal/listeners/utilSelf.ts new file mode 100644 index 0000000..b4eb1d2 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/listeners/utilSelf.ts @@ -0,0 +1,89 @@ +// 初始化表单数据 +export function initListenerForm(listener) { + let self = { + ...listener + } + if (listener.script) { + self = { + ...listener, + ...listener.script, + scriptType: listener.script.resource ? 'externalScript' : 'inlineScript' + } + } + if (listener.event === 'timeout' && listener.eventDefinitions) { + if (listener.eventDefinitions.length) { + let k = '' + for (const key in listener.eventDefinitions[0]) { + console.log(listener.eventDefinitions, key) + if (key.indexOf('time') !== -1) { + k = key + self.eventDefinitionType = key.replace('time', '').toLowerCase() + } + } + console.log(k) + self.eventTimeDefinitions = listener.eventDefinitions[0][k].body + } + } + return self +} + +export function initListenerType(listener) { + let listenerType + if (listener.class) listenerType = 'classListener' + if (listener.expression) listenerType = 'expressionListener' + if (listener.delegateExpression) listenerType = 'delegateExpressionListener' + if (listener.script) listenerType = 'scriptListener' + return { + ...JSON.parse(JSON.stringify(listener)), + ...(listener.script ?? {}), + listenerType: listenerType + } +} + +/** 将 ProcessListenerDO 转换成 initListenerForm 想同的 Form 对象 */ +export function initListenerForm2(processListener) { + if (processListener.valueType === 'class') { + return { + listenerType: 'classListener', + class: processListener.value, + event: processListener.event, + fields: [] + } + } else if (processListener.valueType === 'expression') { + return { + listenerType: 'expressionListener', + expression: processListener.value, + event: processListener.event, + fields: [] + } + } else if (processListener.valueType === 'delegateExpression') { + return { + listenerType: 'delegateExpressionListener', + delegateExpression: processListener.value, + event: processListener.event, + fields: [] + } + } + throw new Error('未知的监听器类型') +} + +export const listenerType = { + classListener: 'Java 类', + expressionListener: '表达式', + delegateExpressionListener: '代理表达式', + scriptListener: '脚本' +} + +export const eventType = { + create: '创建', + assignment: '指派', + complete: '完成', + delete: '删除', + update: '更新', + timeout: '超时' +} + +export const fieldType = { + string: '字符串', + expression: '表达式' +} diff --git a/src/components/bpmnProcessDesigner/package/penal/multi-instance/ElementMultiInstance.vue b/src/components/bpmnProcessDesigner/package/penal/multi-instance/ElementMultiInstance.vue new file mode 100644 index 0000000..c0ec1ca --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/multi-instance/ElementMultiInstance.vue @@ -0,0 +1,280 @@ +<template> + <div class="panel-tab__content"> + <el-form label-width="90px"> + <el-form-item label="快捷配置"> + <el-button size="small" @click="changeConfig('依次审批')">依次审批</el-button> + <el-button size="small" @click="changeConfig('会签')">会签</el-button> + <el-button size="small" @click="changeConfig('或签')">或签</el-button> + </el-form-item> + <el-form-item label="会签类型"> + <el-select v-model="loopCharacteristics" @change="changeLoopCharacteristicsType"> + <el-option label="并行多重事件" value="ParallelMultiInstance" /> + <el-option label="时序多重事件" value="SequentialMultiInstance" /> + <el-option label="无" value="Null" /> + </el-select> + </el-form-item> + <template + v-if=" + loopCharacteristics === 'ParallelMultiInstance' || + loopCharacteristics === 'SequentialMultiInstance' + " + > + <el-form-item label="循环数量" key="loopCardinality"> + <el-input + v-model="loopInstanceForm.loopCardinality" + clearable + @change="updateLoopCardinality" + /> + </el-form-item> + <el-form-item label="集合" key="collection" v-show="false"> + <el-input v-model="loopInstanceForm.collection" clearable @change="updateLoopBase" /> + </el-form-item> + <!-- add by 芋艿:由于「元素变量」暂时用不到,所以这里 display 为 none --> + <el-form-item label="元素变量" key="elementVariable" style="display: none"> + <el-input v-model="loopInstanceForm.elementVariable" clearable @change="updateLoopBase" /> + </el-form-item> + <el-form-item label="完成条件" key="completionCondition"> + <el-input + v-model="loopInstanceForm.completionCondition" + clearable + @change="updateLoopCondition" + /> + </el-form-item> + <!-- add by 芋艿:由于「异步状态」暂时用不到,所以这里 display 为 none --> + <el-form-item label="异步状态" key="async" style="display: none"> + <el-checkbox + v-model="loopInstanceForm.asyncBefore" + label="异步前" + @change="updateLoopAsync('asyncBefore')" + /> + <el-checkbox + v-model="loopInstanceForm.asyncAfter" + label="异步后" + @change="updateLoopAsync('asyncAfter')" + /> + <el-checkbox + v-model="loopInstanceForm.exclusive" + v-if="loopInstanceForm.asyncAfter || loopInstanceForm.asyncBefore" + label="排除" + @change="updateLoopAsync('exclusive')" + /> + </el-form-item> + <el-form-item + label="重试周期" + prop="timeCycle" + v-if="loopInstanceForm.asyncAfter || loopInstanceForm.asyncBefore" + key="timeCycle" + > + <el-input v-model="loopInstanceForm.timeCycle" clearable @change="updateLoopTimeCycle" /> + </el-form-item> + </template> + </el-form> + </div> +</template> + +<script lang="ts" setup> +defineOptions({ name: 'ElementMultiInstance' }) + +const props = defineProps({ + businessObject: Object, + type: String +}) +const prefix = inject('prefix') +const loopCharacteristics = ref('') +//默认配置,用来覆盖原始不存在的选项,避免报错 +const defaultLoopInstanceForm = ref({ + completionCondition: '', + loopCardinality: '', + extensionElements: [], + asyncAfter: false, + asyncBefore: false, + exclusive: false +}) +const loopInstanceForm = ref<any>({}) +const bpmnElement = ref(null) +const multiLoopInstance = ref(null) +const bpmnInstances = () => (window as any)?.bpmnInstances + +const getElementLoop = (businessObject) => { + if (!businessObject.loopCharacteristics) { + loopCharacteristics.value = 'Null' + loopInstanceForm.value = {} + return + } + if (businessObject.loopCharacteristics.$type === 'bpmn:StandardLoopCharacteristics') { + loopCharacteristics.value = 'StandardLoop' + loopInstanceForm.value = {} + return + } + if (businessObject.loopCharacteristics.isSequential) { + loopCharacteristics.value = 'SequentialMultiInstance' + } else { + loopCharacteristics.value = 'ParallelMultiInstance' + } + // 合并配置 + loopInstanceForm.value = { + ...defaultLoopInstanceForm.value, + ...businessObject.loopCharacteristics, + completionCondition: businessObject.loopCharacteristics?.completionCondition?.body ?? '', + loopCardinality: businessObject.loopCharacteristics?.loopCardinality?.body ?? '' + } + // 保留当前元素 businessObject 上的 loopCharacteristics 实例 + multiLoopInstance.value = bpmnInstances().bpmnElement.businessObject.loopCharacteristics + // 更新表单 + if ( + businessObject.loopCharacteristics.extensionElements && + businessObject.loopCharacteristics.extensionElements.values && + businessObject.loopCharacteristics.extensionElements.values.length + ) { + loopInstanceForm.value['timeCycle'] = + businessObject.loopCharacteristics.extensionElements.values[0].body + } +} + +const changeLoopCharacteristicsType = (type) => { + // this.loopInstanceForm = { ...this.defaultLoopInstanceForm }; // 切换类型取消原表单配置 + // 取消多实例配置 + if (type === 'Null') { + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + loopCharacteristics: null + }) + return + } + // 配置循环 + if (type === 'StandardLoop') { + const loopCharacteristicsObject = bpmnInstances().moddle.create( + 'bpmn:StandardLoopCharacteristics' + ) + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + loopCharacteristics: loopCharacteristicsObject + }) + multiLoopInstance.value = null + return + } + // 时序 + if (type === 'SequentialMultiInstance') { + multiLoopInstance.value = bpmnInstances().moddle.create( + 'bpmn:MultiInstanceLoopCharacteristics', + { isSequential: true } + ) + } else { + multiLoopInstance.value = bpmnInstances().moddle.create( + 'bpmn:MultiInstanceLoopCharacteristics', + { collection: '${coll_userList}' } + ) + } + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + loopCharacteristics: toRaw(multiLoopInstance.value) + }) +} + +// 循环基数 +const updateLoopCardinality = (cardinality) => { + let loopCardinality = null + if (cardinality && cardinality.length) { + loopCardinality = bpmnInstances().moddle.create('bpmn:FormalExpression', { + body: cardinality + }) + } + bpmnInstances().modeling.updateModdleProperties( + toRaw(bpmnElement.value), + multiLoopInstance.value, + { + loopCardinality + } + ) +} + +// 完成条件 +const updateLoopCondition = (condition) => { + let completionCondition = null + if (condition && condition.length) { + completionCondition = bpmnInstances().moddle.create('bpmn:FormalExpression', { + body: condition + }) + } + bpmnInstances().modeling.updateModdleProperties( + toRaw(bpmnElement.value), + multiLoopInstance.value, + { + completionCondition + } + ) +} + +// 重试周期 +const updateLoopTimeCycle = (timeCycle) => { + const extensionElements = bpmnInstances().moddle.create('bpmn:ExtensionElements', { + values: [ + bpmnInstances().moddle.create(`${prefix}:FailedJobRetryTimeCycle`, { + body: timeCycle + }) + ] + }) + bpmnInstances().modeling.updateModdleProperties( + toRaw(bpmnElement.value), + multiLoopInstance.value, + { + extensionElements + } + ) +} + +// 直接更新的基础信息 +const updateLoopBase = () => { + bpmnInstances().modeling.updateModdleProperties( + toRaw(bpmnElement.value), + multiLoopInstance.value, + { + collection: loopInstanceForm.value.collection || null, + elementVariable: loopInstanceForm.value.elementVariable || null + } + ) +} + +// 各异步状态 +const updateLoopAsync = (key) => { + const { asyncBefore, asyncAfter } = loopInstanceForm.value + let asyncAttr = Object.create(null) + if (!asyncBefore && !asyncAfter) { + // this.$set(this.loopInstanceForm, "exclusive", false); + loopInstanceForm.value['exclusive'] = false + asyncAttr = { asyncBefore: false, asyncAfter: false, exclusive: false, extensionElements: null } + } else { + asyncAttr[key] = loopInstanceForm.value[key] + } + bpmnInstances().modeling.updateModdleProperties( + toRaw(bpmnElement.value), + multiLoopInstance.value, + asyncAttr + ) +} + +const changeConfig = (config) => { + if (config === '依次审批') { + changeLoopCharacteristicsType('SequentialMultiInstance') + updateLoopCardinality('1') + updateLoopCondition('${ nrOfCompletedInstances >= nrOfInstances }') + } else if (config === '会签') { + changeLoopCharacteristicsType('ParallelMultiInstance') + updateLoopCondition('${ nrOfCompletedInstances >= nrOfInstances }') + } else if (config === '或签') { + changeLoopCharacteristicsType('ParallelMultiInstance') + updateLoopCondition('${ nrOfCompletedInstances > 0 }') + } +} + +onBeforeUnmount(() => { + multiLoopInstance.value = null + bpmnElement.value = null +}) + +watch( + () => props.businessObject, + (val) => { + bpmnElement.value = bpmnInstances().bpmnElement + getElementLoop(val) + }, + { immediate: true } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/other/ElementOtherConfig.vue b/src/components/bpmnProcessDesigner/package/penal/other/ElementOtherConfig.vue new file mode 100644 index 0000000..05532c6 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/other/ElementOtherConfig.vue @@ -0,0 +1,55 @@ +<template> + <div class="panel-tab__content"> + <div class="element-property input-property"> + <div class="element-property__label">元素文档:</div> + <div class="element-property__value"> + <el-input + type="textarea" + v-model="documentation" + resize="vertical" + :autosize="{ minRows: 2, maxRows: 4 }" + @input="updateDocumentation" + @blur="updateDocumentation" + /> + </div> + </div> + </div> +</template> + +<script lang="ts" setup> +defineOptions({ name: 'ElementOtherConfig' }) +const props = defineProps({ + id: String +}) +const documentation = ref('') +const bpmnElement = ref() +const bpmnInstances = () => (window as any).bpmnInstances +const updateDocumentation = () => { + ;(bpmnElement.value && bpmnElement.value.id === props.id) || + (bpmnElement.value = bpmnInstances().elementRegistry.get(props.id)) + const documentations = bpmnInstances().bpmnFactory.create('bpmn:Documentation', { + text: documentation.value + }) + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + documentation: [documentations] + }) +} +onBeforeUnmount(() => { + bpmnElement.value = null +}) + +watch( + () => props.id, + (id) => { + if (id && id.length) { + nextTick(() => { + const documentations = bpmnInstances().bpmnElement.businessObject?.documentation + documentation.value = documentations && documentations.length ? documentations[0].text : '' + }) + } else { + documentation.value = '' + } + }, + { immediate: true } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/properties/ElementProperties.vue b/src/components/bpmnProcessDesigner/package/penal/properties/ElementProperties.vue new file mode 100644 index 0000000..494b3d9 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/properties/ElementProperties.vue @@ -0,0 +1,169 @@ +<template> + <div class="panel-tab__content"> + <el-table :data="elementPropertyList" max-height="240" fit border> + <el-table-column label="序号" width="50px" type="index" /> + <el-table-column label="属性名" prop="name" min-width="100px" show-overflow-tooltip /> + <el-table-column label="属性值" prop="value" min-width="100px" show-overflow-tooltip /> + <el-table-column label="操作" width="110px"> + <template #default="scope"> + <el-button link @click="openAttributesForm(scope.row, scope.$index)" size="small"> + 编辑 + </el-button> + <el-divider direction="vertical" /> + <el-button + link + size="small" + style="color: #ff4d4f" + @click="removeAttributes(scope.row, scope.$index)" + > + 移除 + </el-button> + </template> + </el-table-column> + </el-table> + <div class="element-drawer__button"> + <XButton + type="primary" + preIcon="ep:plus" + title="添加属性" + @click="openAttributesForm(null, -1)" + /> + </div> + + <el-dialog + v-model="propertyFormModelVisible" + title="属性配置" + width="600px" + append-to-body + destroy-on-close + > + <el-form :model="propertyForm" label-width="80px" ref="attributeFormRef"> + <el-form-item label="属性名:" prop="name"> + <el-input v-model="propertyForm.name" clearable /> + </el-form-item> + <el-form-item label="属性值:" prop="value"> + <el-input v-model="propertyForm.value" clearable /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="propertyFormModelVisible = false">取 消</el-button> + <el-button type="primary" @click="saveAttribute">确 定</el-button> + </template> + </el-dialog> + </div> +</template> + +<script lang="ts" setup> +import { ElMessageBox } from 'element-plus' +defineOptions({ name: 'ElementProperties' }) +const props = defineProps({ + id: String, + type: String +}) +const prefix = inject('prefix') +// const width = inject('width') + +const elementPropertyList = ref<any[]>([]) +const propertyForm = ref<any>({}) +const editingPropertyIndex = ref(-1) +const propertyFormModelVisible = ref(false) +const bpmnElement = ref() +const otherExtensionList = ref() +const bpmnElementProperties = ref() +const bpmnElementPropertyList = ref() +const attributeFormRef = ref() +const bpmnInstances = () => (window as any)?.bpmnInstances + +const resetAttributesList = () => { + console.log(window, 'windowwindowwindowwindowwindowwindowwindow') + bpmnElement.value = bpmnInstances().bpmnElement + otherExtensionList.value = [] // 其他扩展配置 + bpmnElementProperties.value = + // bpmnElement.value.businessObject?.extensionElements?.filter((ex) => { + bpmnElement.value.businessObject?.extensionElements?.values.filter((ex) => { + if (ex.$type !== `${prefix}:Properties`) { + otherExtensionList.value.push(ex) + } + return ex.$type === `${prefix}:Properties` + }) ?? [] + + // 保存所有的 扩展属性字段 + bpmnElementPropertyList.value = bpmnElementProperties.value.reduce( + (pre, current) => pre.concat(current.values), + [] + ) + // 复制 显示 + elementPropertyList.value = JSON.parse(JSON.stringify(bpmnElementPropertyList.value ?? [])) +} +const openAttributesForm = (attr, index) => { + editingPropertyIndex.value = index + propertyForm.value = index === -1 ? {} : JSON.parse(JSON.stringify(attr)) + propertyFormModelVisible.value = true + nextTick(() => { + if (attributeFormRef.value) attributeFormRef.value.clearValidate() + }) +} +const removeAttributes = (attr, index) => { + console.log(attr, 'attr') + ElMessageBox.confirm('确认移除该属性吗?', '提示', { + confirmButtonText: '确 认', + cancelButtonText: '取 消' + }) + .then(() => { + elementPropertyList.value.splice(index, 1) + bpmnElementPropertyList.value.splice(index, 1) + // 新建一个属性字段的保存列表 + const propertiesObject = bpmnInstances().moddle.create(`${prefix}:Properties`, { + values: bpmnElementPropertyList.value + }) + updateElementExtensions(propertiesObject) + resetAttributesList() + }) + .catch(() => console.info('操作取消')) +} +const saveAttribute = () => { + console.log(propertyForm.value, 'propertyForm.value') + const { name, value } = propertyForm.value + if (editingPropertyIndex.value !== -1) { + bpmnInstances().modeling.updateModdleProperties( + toRaw(bpmnElement.value), + toRaw(bpmnElementPropertyList.value)[toRaw(editingPropertyIndex.value)], + { + name, + value + } + ) + } else { + // 新建属性字段 + const newPropertyObject = bpmnInstances().moddle.create(`${prefix}:Property`, { + name, + value + }) + // 新建一个属性字段的保存列表 + const propertiesObject = bpmnInstances().moddle.create(`${prefix}:Properties`, { + values: bpmnElementPropertyList.value.concat([newPropertyObject]) + }) + updateElementExtensions(propertiesObject) + } + propertyFormModelVisible.value = false + resetAttributesList() +} +const updateElementExtensions = (properties) => { + const extensions = bpmnInstances().moddle.create('bpmn:ExtensionElements', { + values: otherExtensionList.value.concat([properties]) + }) + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + extensionElements: extensions + }) +} + +watch( + () => props.id, + (val) => { + if (val) { + val && val.length && resetAttributesList() + } + }, + { immediate: true } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/signal-message/SignalAndMessage.vue b/src/components/bpmnProcessDesigner/package/penal/signal-message/SignalAndMessage.vue new file mode 100644 index 0000000..f38f31c --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/signal-message/SignalAndMessage.vue @@ -0,0 +1,113 @@ +<template> + <div class="panel-tab__content"> + <div class="panel-tab__content--title"> + <span><Icon icon="ep:menu" style="margin-right: 8px; color: #555" />消息列表</span> + <XButton type="primary" title="创建新消息" preIcon="ep:plus" @click="openModel('message')" /> + </div> + <el-table :data="messageList" border> + <el-table-column type="index" label="序号" width="60px" /> + <el-table-column label="消息ID" prop="id" max-width="300px" show-overflow-tooltip /> + <el-table-column label="消息名称" prop="name" max-width="300px" show-overflow-tooltip /> + </el-table> + <div + class="panel-tab__content--title" + style="padding-top: 8px; margin-top: 8px; border-top: 1px solid #eee" + > + <span><Icon icon="ep:menu" style="margin-right: 8px; color: #555" />信号列表</span> + <XButton type="primary" title="创建新信号" preIcon="ep:plus" @click="openModel('signal')" /> + </div> + <el-table :data="signalList" border> + <el-table-column type="index" label="序号" width="60px" /> + <el-table-column label="信号ID" prop="id" max-width="300px" show-overflow-tooltip /> + <el-table-column label="信号名称" prop="name" max-width="300px" show-overflow-tooltip /> + </el-table> + + <el-dialog + v-model="dialogVisible" + :title="modelConfig.title" + :close-on-click-modal="false" + width="400px" + append-to-body + destroy-on-close + > + <el-form :model="modelObjectForm" label-width="90px"> + <el-form-item :label="modelConfig.idLabel"> + <el-input v-model="modelObjectForm.id" clearable /> + </el-form-item> + <el-form-item :label="modelConfig.nameLabel"> + <el-input v-model="modelObjectForm.name" clearable /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="dialogVisible = false">取 消</el-button> + <el-button type="primary" @click="addNewObject">保 存</el-button> + </template> + </el-dialog> + </div> +</template> +<script lang="ts" setup> +defineOptions({ name: 'SignalAndMassage' }) + +const message = useMessage() +const signalList = ref<any[]>([]) +const messageList = ref<any[]>([]) +const dialogVisible = ref(false) +const modelType = ref('') +const modelObjectForm = ref<any>({}) +const rootElements = ref() +const messageIdMap = ref() +const signalIdMap = ref() +const modelConfig = computed(() => { + if (modelType.value === 'message') { + return { title: '创建消息', idLabel: '消息ID', nameLabel: '消息名称' } + } else { + return { title: '创建信号', idLabel: '信号ID', nameLabel: '信号名称' } + } +}) +const bpmnInstances = () => (window as any)?.bpmnInstances + +const initDataList = () => { + console.log(window, 'window') + rootElements.value = bpmnInstances().modeler.getDefinitions().rootElements + messageIdMap.value = {} + signalIdMap.value = {} + messageList.value = [] + signalList.value = [] + rootElements.value.forEach((el) => { + if (el.$type === 'bpmn:Message') { + messageIdMap.value[el.id] = true + messageList.value.push({ ...el }) + } + if (el.$type === 'bpmn:Signal') { + signalIdMap.value[el.id] = true + signalList.value.push({ ...el }) + } + }) +} +const openModel = (type) => { + modelType.value = type + modelObjectForm.value = {} + dialogVisible.value = true +} +const addNewObject = () => { + if (modelType.value === 'message') { + if (messageIdMap.value[modelObjectForm.value.id]) { + message.error('该消息已存在,请修改id后重新保存') + } + const messageRef = bpmnInstances().moddle.create('bpmn:Message', modelObjectForm.value) + rootElements.value.push(messageRef) + } else { + if (signalIdMap.value[modelObjectForm.value.id]) { + message.error('该信号已存在,请修改id后重新保存') + } + const signalRef = bpmnInstances().moddle.create('bpmn:Signal', modelObjectForm.value) + rootElements.value.push(signalRef) + } + dialogVisible.value = false + initDataList() +} + +onMounted(() => { + initDataList() +}) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/task/ElementTask.vue b/src/components/bpmnProcessDesigner/package/penal/task/ElementTask.vue new file mode 100644 index 0000000..e808af3 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/task/ElementTask.vue @@ -0,0 +1,87 @@ +<template> + <div class="panel-tab__content"> + <el-form size="small" label-width="90px"> + <!-- add by 芋艿:由于「异步延续」暂时用不到,所以这里 display 为 none --> + <el-form-item label="异步延续" style="display: none"> + <el-checkbox + v-model="taskConfigForm.asyncBefore" + label="异步前" + @change="changeTaskAsync" + /> + <el-checkbox v-model="taskConfigForm.asyncAfter" label="异步后" @change="changeTaskAsync" /> + <el-checkbox + v-model="taskConfigForm.exclusive" + v-if="taskConfigForm.asyncAfter || taskConfigForm.asyncBefore" + label="排除" + @change="changeTaskAsync" + /> + </el-form-item> + <component :is="witchTaskComponent" v-bind="$props" /> + </el-form> + </div> +</template> + +<script lang="ts" setup> +import UserTask from './task-components/UserTask.vue' +import ScriptTask from './task-components/ScriptTask.vue' +import ReceiveTask from './task-components/ReceiveTask.vue' + +defineOptions({ name: 'ElementTaskConfig' }) + +const props = defineProps({ + id: String, + type: String +}) +const taskConfigForm = ref({ + asyncAfter: false, + asyncBefore: false, + exclusive: false +}) +const witchTaskComponent = ref() +const installedComponent = ref({ + // 手工任务与普通任务一致,不需要其他配置 + // 接收消息任务,需要在全局下插入新的消息实例,并在该节点下的 messageRef 属性绑定该实例 + // 发送任务、服务任务、业务规则任务共用一个相同配置 + UserTask: 'UserTask', // 用户任务配置 + ScriptTask: 'ScriptTask', // 脚本任务配置 + ReceiveTask: 'ReceiveTask' // 消息接收任务 +}) +const bpmnElement = ref() + +const bpmnInstances = () => (window as any).bpmnInstances +const changeTaskAsync = () => { + if (!taskConfigForm.value.asyncBefore && !taskConfigForm.value.asyncAfter) { + taskConfigForm.value.exclusive = false + } + bpmnInstances().modeling.updateProperties(bpmnInstances().bpmnElement, { + ...taskConfigForm.value + }) +} + +watch( + () => props.id, + () => { + bpmnElement.value = bpmnInstances().bpmnElement + taskConfigForm.value.asyncBefore = bpmnElement.value?.businessObject?.asyncBefore + taskConfigForm.value.asyncAfter = bpmnElement.value?.businessObject?.asyncAfter + taskConfigForm.value.exclusive = bpmnElement.value?.businessObject?.exclusive + }, + { immediate: true } +) +watch( + () => props.type, + () => { + // witchTaskComponent.value = installedComponent.value[props.type] + if (props.type == installedComponent.value.UserTask) { + witchTaskComponent.value = UserTask + } + if (props.type == installedComponent.value.ScriptTask) { + witchTaskComponent.value = ScriptTask + } + if (props.type == installedComponent.value.ReceiveTask) { + witchTaskComponent.value = ReceiveTask + } + }, + { immediate: true } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/task/task-components/ProcessExpressionDialog.vue b/src/components/bpmnProcessDesigner/package/penal/task/task-components/ProcessExpressionDialog.vue new file mode 100644 index 0000000..a038e69 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/task/task-components/ProcessExpressionDialog.vue @@ -0,0 +1,70 @@ +<!-- 表达式选择 --> +<template> + <Dialog title="请选择表达式" v-model="dialogVisible" width="1024px"> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column label="表达式" align="center" prop="expression" /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button link type="primary" @click="select(scope.row)"> 选择 </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + </Dialog> +</template> +<script setup lang="ts"> +import { CommonStatusEnum } from '@/utils/constants' +import { ProcessExpressionApi, ProcessExpressionVO } from '@/api/bpm/processExpression' + +/** BPM 流程 表单 */ +defineOptions({ name: 'ProcessExpressionDialog' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const loading = ref(true) // 列表的加载中 +const list = ref<ProcessExpressionVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + type: '', + status: CommonStatusEnum.ENABLE +}) + +/** 打开弹窗 */ +const open = (type: string) => { + queryParams.pageNo = 1 + queryParams.type = type + getList() + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProcessExpressionApi.getProcessExpressionPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const select = async (row) => { + dialogVisible.value = false + // 发送操作成功的事件 + emit('select', row) +} +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/task/task-components/ReceiveTask.vue b/src/components/bpmnProcessDesigner/package/penal/task/task-components/ReceiveTask.vue new file mode 100644 index 0000000..83ed24e --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/task/task-components/ReceiveTask.vue @@ -0,0 +1,125 @@ +<template> + <div style="margin-top: 16px"> + <el-form-item label="消息实例"> + <div + style=" + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + " + > + <el-select v-model="bindMessageId" @change="updateTaskMessage"> + <el-option + v-for="key in Object.keys(messageMap)" + :value="key" + :label="messageMap[key]" + :key="key" + /> + </el-select> + <XButton + type="primary" + preIcon="ep:plus" + style="margin-left: 8px" + @click="openMessageModel" + /> + </div> + </el-form-item> + <el-dialog + v-model="messageModelVisible" + :close-on-click-modal="false" + title="创建新消息" + width="400px" + append-to-body + destroy-on-close + > + <el-form :model="newMessageForm" size="small" label-width="90px"> + <el-form-item label="消息ID"> + <el-input v-model="newMessageForm.id" clearable /> + </el-form-item> + <el-form-item label="消息名称"> + <el-input v-model="newMessageForm.name" clearable /> + </el-form-item> + </el-form> + <template #footer> + <el-button size="small" type="primary" @click="createNewMessage">确 认</el-button> + </template> + </el-dialog> + </div> +</template> + +<script lang="ts" setup> +defineOptions({ name: 'ReceiveTask' }) +const props = defineProps({ + id: String, + type: String +}) + +const message = useMessage() + +const bindMessageId = ref('') +const newMessageForm = ref<any>({}) +const messageMap = ref<any>({}) +const messageModelVisible = ref(false) +const bpmnElement = ref<any>() +const bpmnMessageRefsMap = ref<any>() +const bpmnRootElements = ref<any>() + +const bpmnInstances = () => (window as any).bpmnInstances +const getBindMessage = () => { + bpmnElement.value = bpmnInstances().bpmnElement + bindMessageId.value = bpmnElement.value.businessObject?.messageRef?.id || '-1' +} +const openMessageModel = () => { + messageModelVisible.value = true + newMessageForm.value = {} +} +const createNewMessage = () => { + if (messageMap.value[newMessageForm.value.id]) { + message.error('该消息已存在,请修改id后重新保存') + return + } + const newMessage = bpmnInstances().moddle.create('bpmn:Message', newMessageForm.value) + bpmnRootElements.value.push(newMessage) + messageMap.value[newMessageForm.value.id] = newMessageForm.value.name + bpmnMessageRefsMap.value[newMessageForm.value.id] = newMessage + messageModelVisible.value = false +} +const updateTaskMessage = (messageId) => { + if (messageId === '-1') { + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + messageRef: null + }) + } else { + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + messageRef: bpmnMessageRefsMap.value[messageId] + }) + } +} + +onMounted(() => { + bpmnMessageRefsMap.value = Object.create(null) + bpmnRootElements.value = bpmnInstances().modeler.getDefinitions().rootElements + bpmnRootElements.value + .filter((el) => el.$type === 'bpmn:Message') + .forEach((m) => { + bpmnMessageRefsMap.value[m.id] = m + messageMap.value[m.id] = m.name + }) + messageMap.value['-1'] = '无' +}) + +onBeforeUnmount(() => { + bpmnElement.value = null +}) +watch( + () => props.id, + () => { + // bpmnElement.value = bpmnInstances().bpmnElement + nextTick(() => { + getBindMessage() + }) + }, + { immediate: true } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/task/task-components/ScriptTask.vue b/src/components/bpmnProcessDesigner/package/penal/task/task-components/ScriptTask.vue new file mode 100644 index 0000000..683fef3 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/task/task-components/ScriptTask.vue @@ -0,0 +1,99 @@ +<template> + <div style="margin-top: 16px"> + <el-form-item label="脚本格式"> + <el-input + v-model="scriptTaskForm.scriptFormat" + clearable + @input="updateElementTask()" + @change="updateElementTask()" + /> + </el-form-item> + <el-form-item label="脚本类型"> + <el-select v-model="scriptTaskForm.scriptType"> + <el-option label="内联脚本" value="inline" /> + <el-option label="外部资源" value="external" /> + </el-select> + </el-form-item> + <el-form-item label="脚本" v-show="scriptTaskForm.scriptType === 'inline'"> + <el-input + v-model="scriptTaskForm.script" + type="textarea" + resize="vertical" + :autosize="{ minRows: 2, maxRows: 4 }" + clearable + @input="updateElementTask()" + @change="updateElementTask()" + /> + </el-form-item> + <el-form-item label="资源地址" v-show="scriptTaskForm.scriptType === 'external'"> + <el-input + v-model="scriptTaskForm.resource" + clearable + @input="updateElementTask()" + @change="updateElementTask()" + /> + </el-form-item> + <el-form-item label="结果变量"> + <el-input + v-model="scriptTaskForm.resultVariable" + clearable + @input="updateElementTask()" + @change="updateElementTask()" + /> + </el-form-item> + </div> +</template> + +<script lang="ts" setup> +defineOptions({ name: 'ScriptTask' }) +const props = defineProps({ + id: String, + type: String +}) +const defaultTaskForm = ref({ + scriptFormat: '', + script: '', + resource: '', + resultVariable: '' +}) +const scriptTaskForm = ref<any>({}) +const bpmnElement = ref() + +const bpmnInstances = () => (window as any)?.bpmnInstances + +const resetTaskForm = () => { + for (let key in defaultTaskForm.value) { + let value = bpmnElement.value?.businessObject[key] || defaultTaskForm.value[key] + scriptTaskForm.value[key] = value + } + scriptTaskForm.value.scriptType = scriptTaskForm.value.script ? 'inline' : 'external' +} +const updateElementTask = () => { + let taskAttr = Object.create(null) + taskAttr.scriptFormat = scriptTaskForm.value.scriptFormat || null + taskAttr.resultVariable = scriptTaskForm.value.resultVariable || null + if (scriptTaskForm.value.scriptType === 'inline') { + taskAttr.script = scriptTaskForm.value.script || null + taskAttr.resource = null + } else { + taskAttr.resource = scriptTaskForm.value.resource || null + taskAttr.script = null + } + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), taskAttr) +} + +onBeforeUnmount(() => { + bpmnElement.value = null +}) + +watch( + () => props.id, + () => { + bpmnElement.value = bpmnInstances().bpmnElement + nextTick(() => { + resetTaskForm() + }) + }, + { immediate: true } +) +</script> diff --git a/src/components/bpmnProcessDesigner/package/penal/task/task-components/UserTask.vue b/src/components/bpmnProcessDesigner/package/penal/task/task-components/UserTask.vue new file mode 100644 index 0000000..f404ef7 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/penal/task/task-components/UserTask.vue @@ -0,0 +1,234 @@ +<template> + <el-form label-width="100px"> + <el-form-item label="规则类型" prop="candidateStrategy"> + <el-select + v-model="userTaskForm.candidateStrategy" + clearable + style="width: 100%" + @change="changeCandidateStrategy" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BPM_TASK_CANDIDATE_STRATEGY)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="userTaskForm.candidateStrategy == 10" + label="指定角色" + prop="candidateParam" + > + <el-select + v-model="userTaskForm.candidateParam" + clearable + multiple + style="width: 100%" + @change="updateElementTask" + > + <el-option v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" /> + </el-select> + </el-form-item> + <el-form-item + v-if="userTaskForm.candidateStrategy == 20 || userTaskForm.candidateStrategy == 21" + label="指定部门" + prop="candidateParam" + span="24" + > + <el-tree-select + ref="treeRef" + v-model="userTaskForm.candidateParam" + :data="deptTreeOptions" + :props="defaultProps" + empty-text="加载中,请稍后" + multiple + node-key="id" + show-checkbox + @change="updateElementTask" + /> + </el-form-item> + <el-form-item + v-if="userTaskForm.candidateStrategy == 22" + label="指定岗位" + prop="candidateParam" + span="24" + > + <el-select + v-model="userTaskForm.candidateParam" + clearable + multiple + style="width: 100%" + @change="updateElementTask" + > + <el-option v-for="item in postOptions" :key="item.id" :label="item.name" :value="item.id" /> + </el-select> + </el-form-item> + <el-form-item + v-if="userTaskForm.candidateStrategy == 30" + label="指定用户" + prop="candidateParam" + span="24" + > + <el-select + v-model="userTaskForm.candidateParam" + clearable + multiple + style="width: 100%" + @change="updateElementTask" + > + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="userTaskForm.candidateStrategy === 40" + label="指定用户组" + prop="candidateParam" + > + <el-select + v-model="userTaskForm.candidateParam" + clearable + multiple + style="width: 100%" + @change="updateElementTask" + > + <el-option + v-for="item in userGroupOptions" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="userTaskForm.candidateStrategy === 60" + label="流程表达式" + prop="candidateParam" + > + <el-input + type="textarea" + v-model="userTaskForm.candidateParam[0]" + clearable + style="width: 72%" + @change="updateElementTask" + /> + <el-button class="ml-5px" size="small" type="success" @click="openProcessExpressionDialog" + >选择表达式</el-button + > + <!-- 选择弹窗 --> + <ProcessExpressionDialog ref="processExpressionDialogRef" @select="selectProcessExpression" /> + </el-form-item> + </el-form> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { defaultProps, handleTree } from '@/utils/tree' +import * as RoleApi from '@/api/system/role' +import * as DeptApi from '@/api/system/dept' +import * as PostApi from '@/api/system/post' +import * as UserApi from '@/api/system/user' +import * as UserGroupApi from '@/api/bpm/userGroup' +import ProcessExpressionDialog from './ProcessExpressionDialog.vue' +import { ProcessExpressionVO } from '@/api/bpm/processExpression' + +defineOptions({ name: 'UserTask' }) +const props = defineProps({ + id: String, + type: String +}) +const userTaskForm = ref({ + candidateStrategy: undefined, // 分配规则 + candidateParam: [] // 分配选项 +}) +const bpmnElement = ref() +const bpmnInstances = () => (window as any)?.bpmnInstances + +const roleOptions = ref<RoleApi.RoleVO[]>([]) // 角色列表 +const deptTreeOptions = ref() // 部门树 +const postOptions = ref<PostApi.PostVO[]>([]) // 岗位列表 +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const userGroupOptions = ref<UserGroupApi.UserGroupVO[]>([]) // 用户组列表 + +const resetTaskForm = () => { + const businessObject = bpmnElement.value.businessObject + if (!businessObject) { + return + } + if (businessObject.candidateStrategy != undefined) { + userTaskForm.value.candidateStrategy = parseInt(businessObject.candidateStrategy) as any + } else { + userTaskForm.value.candidateStrategy = undefined + } + if (businessObject.candidateParam && businessObject.candidateParam.length > 0) { + if (userTaskForm.value.candidateStrategy === 60) { + // 特殊:流程表达式,只有一个 input 输入框 + userTaskForm.value.candidateParam = [businessObject.candidateParam] + } else { + userTaskForm.value.candidateParam = businessObject.candidateParam + .split(',') + .map((item) => +item) + } + } else { + userTaskForm.value.candidateParam = [] + } +} + +/** 更新 candidateStrategy 字段时,需要清空 candidateParam,并触发 bpmn 图更新 */ +const changeCandidateStrategy = () => { + userTaskForm.value.candidateParam = [] + updateElementTask() +} + +/** 选中某个 options 时候,更新 bpmn 图 */ +const updateElementTask = () => { + bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), { + candidateStrategy: userTaskForm.value.candidateStrategy, + candidateParam: userTaskForm.value.candidateParam.join(',') + }) +} + +// 打开监听器弹窗 +const processExpressionDialogRef = ref() +const openProcessExpressionDialog = async () => { + processExpressionDialogRef.value.open() +} +const selectProcessExpression = (expression: ProcessExpressionVO) => { + userTaskForm.value.candidateParam = [expression.expression] + updateElementTask() +} + +watch( + () => props.id, + () => { + bpmnElement.value = bpmnInstances().bpmnElement + nextTick(() => { + resetTaskForm() + }) + }, + { immediate: true } +) + +onMounted(async () => { + // 获得角色列表 + roleOptions.value = await RoleApi.getSimpleRoleList() + // 获得部门列表 + const deptOptions = await DeptApi.getSimpleDeptList() + deptTreeOptions.value = handleTree(deptOptions, 'id') + // 获得岗位列表 + postOptions.value = await PostApi.getSimplePostList() + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() + // 获得用户组列表 + userGroupOptions.value = await UserGroupApi.getUserGroupSimpleList() +}) + +onBeforeUnmount(() => { + bpmnElement.value = null +}) +</script> diff --git a/src/components/bpmnProcessDesigner/package/theme/element-variables.scss b/src/components/bpmnProcessDesigner/package/theme/element-variables.scss new file mode 100644 index 0000000..49bd326 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/theme/element-variables.scss @@ -0,0 +1,70 @@ +/* 改变主题色变量 */ +$--color-primary: #1890ff; +$--color-danger: #ff4d4f; + +/* 改变 icon 字体路径变量,必需 */ +$--font-path: '~element-ui/lib/theme-chalk/fonts'; + +@import '~element-ui/packages/theme-chalk/src/index'; + +.el-table td, +.el-table th { + color: #333; +} +.el-drawer__header { + padding: 16px 16px 8px 16px; + margin: 0; + line-height: 24px; + font-size: 18px; + color: #303133; + box-sizing: border-box; + border-bottom: 1px solid #e8e8e8; +} +div[class^='el-drawer']:focus, +span:focus { + outline: none; +} +.el-drawer__body { + box-sizing: border-box; + padding: 16px; + width: 100%; + overflow-y: auto; +} + +.el-dialog { + margin-top: 50vh !important; + transform: translateY(-50%); + overflow: hidden; +} +.el-dialog__wrapper { + overflow: hidden; + max-height: 100vh; +} +.el-dialog__header { + padding: 16px 16px 8px 16px; + box-sizing: border-box; + border-bottom: 1px solid #e8e8e8; +} +.el-dialog__body { + padding: 16px; + max-height: 80vh; + box-sizing: border-box; + overflow-y: auto; +} +.el-dialog__footer { + padding: 16px; + box-sizing: border-box; + border-top: 1px solid #e8e8e8; +} +.el-dialog__close { + font-weight: 600; +} +.el-select { + width: 100%; +} +.el-divider:not(.el-divider--horizontal) { + margin: 0 8px; +} +.el-divider.el-divider--horizontal { + margin: 16px 0; +} diff --git a/src/components/bpmnProcessDesigner/package/theme/index.scss b/src/components/bpmnProcessDesigner/package/theme/index.scss new file mode 100644 index 0000000..2e60fad --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/theme/index.scss @@ -0,0 +1,2 @@ +@import './process-designer.scss'; +@import './process-panel.scss'; diff --git a/src/components/bpmnProcessDesigner/package/theme/process-designer.scss b/src/components/bpmnProcessDesigner/package/theme/process-designer.scss new file mode 100644 index 0000000..6af945d --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/theme/process-designer.scss @@ -0,0 +1,161 @@ +@import 'bpmn-js-token-simulation/assets/css/bpmn-js-token-simulation.css'; +@import 'bpmn-js-token-simulation/assets/css/font-awesome.min.css'; +@import 'bpmn-js-token-simulation/assets/css/normalize.css'; + +// 边框被 token-simulation 样式覆盖了 +.djs-palette { + background: var(--palette-background-color); + border: solid 1px var(--palette-border-color) !important; + border-radius: 2px; +} + +.my-process-designer { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + box-sizing: border-box; + .my-process-designer__header { + width: 100%; + min-height: 36px; + .el-button { + text-align: center; + } + .el-button-group { + margin: 4px; + } + .el-tooltip__popper { + .el-button { + width: 100%; + text-align: left; + padding-left: 8px; + padding-right: 8px; + } + .el-button:hover { + background: rgba(64, 158, 255, 0.8); + color: #ffffff; + } + } + .align { + position: relative; + i { + &:after { + content: '|'; + position: absolute; + // transform: rotate(90deg) translate(200%, 60%); + transform: rotate(180deg) translate(271%, -10%); + } + } + } + .align.align-left i { + transform: rotate(90deg); + } + .align.align-right i { + transform: rotate(-90deg); + } + .align.align-top i { + transform: rotate(180deg); + } + .align.align-bottom i { + transform: rotate(0deg); + } + .align.align-center i { + transform: rotate(0deg); + &:after { + // transform: rotate(90deg) translate(0, 60%); + transform: rotate(0deg) translate(-0%, -5%); + } + } + .align.align-middle i { + transform: rotate(-90deg); + &:after { + // transform: rotate(90deg) translate(0, 60%); + transform: rotate(0deg) translate(0, -10%); + } + } + } + .my-process-designer__container { + display: inline-flex; + width: 100%; + flex: 1; + .my-process-designer__canvas { + flex: 1; + height: 100%; + position: relative; + background: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PHBhdHRlcm4gaWQ9ImEiIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgcGF0dGVyblVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+PHBhdGggZD0iTTAgMTBoNDBNMTAgMHY0ME0wIDIwaDQwTTIwIDB2NDBNMCAzMGg0ME0zMCAwdjQwIiBmaWxsPSJub25lIiBzdHJva2U9IiNlMGUwZTAiIG9wYWNpdHk9Ii4yIi8+PHBhdGggZD0iTTQwIDBIMHY0MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZTBlMGUwIi8+PC9wYXR0ZXJuPjwvZGVmcz48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSJ1cmwoI2EpIi8+PC9zdmc+') + repeat !important; + div.toggle-mode { + display: none; + } + } + .my-process-designer__property-panel { + height: 100%; + overflow: scroll; + overflow-y: auto; + z-index: 10; + * { + box-sizing: border-box; + } + } + svg { + width: 100%; + height: 100%; + min-height: 100%; + overflow: hidden; + } + } +} + +//侧边栏配置 +// .djs-palette .two-column .open { +.open { + // .djs-palette.open { + .djs-palette-entries { + div[class^='bpmn-icon-']:before, + div[class*='bpmn-icon-']:before { + line-height: unset; + } + div.entry { + position: relative; + } + div.entry:hover { + &::after { + width: max-content; + content: attr(title); + vertical-align: text-bottom; + position: absolute; + right: -10px; + top: 0; + bottom: 0; + overflow: hidden; + transform: translateX(100%); + font-size: 0.5em; + display: inline-block; + text-decoration: inherit; + font-variant: normal; + text-transform: none; + background: #fafafa; + box-shadow: 0 0 6px #eeeeee; + border: 1px solid #cccccc; + box-sizing: border-box; + padding: 0 16px; + border-radius: 4px; + z-index: 100; + } + } + } +} +pre { + margin: 0; + height: 100%; + overflow: hidden; + max-height: calc(80vh - 32px); + overflow-y: auto; +} +.hljs { + word-break: break-word; + white-space: pre-wrap; +} +.hljs * { + font-family: Consolas, Monaco, monospace; +} diff --git a/src/components/bpmnProcessDesigner/package/theme/process-panel.scss b/src/components/bpmnProcessDesigner/package/theme/process-panel.scss new file mode 100644 index 0000000..f840cdd --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/theme/process-panel.scss @@ -0,0 +1,107 @@ +.process-panel__container { + box-sizing: border-box; + padding: 0 8px; + border-left: 1px solid #eeeeee; + box-shadow: 0 0 8px #cccccc; + max-height: 100%; + overflow-y: scroll; +} +.panel-tab__title { + font-weight: 600; + padding: 0 8px; + font-size: 1.1em; + line-height: 1.2em; + i { + margin-right: 8px; + font-size: 1.2em; + } +} +.panel-tab__content { + width: 100%; + box-sizing: border-box; + border-top: 1px solid #eeeeee; + padding: 8px 16px; + .panel-tab__content--title { + display: flex; + justify-content: space-between; + padding-bottom: 8px; + span { + flex: 1; + text-align: left; + } + } +} +.element-property { + width: 100%; + display: flex; + align-items: flex-start; + margin: 8px 0; + .element-property__label { + display: block; + width: 90px; + text-align: right; + overflow: hidden; + padding-right: 12px; + line-height: 32px; + font-size: 14px; + box-sizing: border-box; + } + .element-property__value { + flex: 1; + line-height: 32px; + } + .el-form-item { + width: 100%; + margin-bottom: 0; + padding-bottom: 18px; + } +} +.list-property { + flex-direction: column; + .element-listener-item { + width: 100%; + display: inline-grid; + grid-template-columns: 16px auto 32px 32px; + grid-column-gap: 8px; + } + .element-listener-item + .element-listener-item { + margin-top: 8px; + } +} +.listener-filed__title { + display: inline-flex; + width: 100%; + justify-content: space-between; + align-items: center; + margin-top: 0; + span { + width: 200px; + text-align: left; + font-size: 14px; + } + i { + margin-right: 8px; + } +} +.element-drawer__button { + margin-top: 8px; + width: 100%; + display: inline-flex; + justify-content: space-around; +} +.element-drawer__button > .el-button { + width: 100%; +} + +.el-collapse-item__content { + padding-bottom: 0; +} +.el-input.is-disabled .el-input__inner { + color: #999999; +} +.el-form-item.el-form-item--mini { + margin-bottom: 0; + & + .el-form-item { + margin-top: 16px; + } +} diff --git a/src/components/bpmnProcessDesigner/package/utils.ts b/src/components/bpmnProcessDesigner/package/utils.ts new file mode 100644 index 0000000..8996788 --- /dev/null +++ b/src/components/bpmnProcessDesigner/package/utils.ts @@ -0,0 +1,78 @@ +import { toRaw } from 'vue' +const bpmnInstances = () => (window as any)?.bpmnInstances +// 创建监听器实例 +export function createListenerObject(options, isTask, prefix) { + debugger + const listenerObj = Object.create(null) + listenerObj.event = options.event + isTask && (listenerObj.id = options.id) // 任务监听器特有的 id 字段 + switch (options.listenerType) { + case 'scriptListener': + listenerObj.script = createScriptObject(options, prefix) + break + case 'expressionListener': + listenerObj.expression = options.expression + break + case 'delegateExpressionListener': + listenerObj.delegateExpression = options.delegateExpression + break + default: + listenerObj.class = options.class + } + // 注入字段 + if (options.fields) { + listenerObj.fields = options.fields.map((field) => { + return createFieldObject(field, prefix) + }) + } + // 任务监听器的 定时器 设置 + if (isTask && options.event === 'timeout' && !!options.eventDefinitionType) { + const timeDefinition = bpmnInstances().moddle.create('bpmn:FormalExpression', { + body: options.eventTimeDefinitions + }) + const TimerEventDefinition = bpmnInstances().moddle.create('bpmn:TimerEventDefinition', { + id: `TimerEventDefinition_${uuid(8)}`, + [`time${options.eventDefinitionType.replace(/^\S/, (s) => s.toUpperCase())}`]: timeDefinition + }) + listenerObj.eventDefinitions = [TimerEventDefinition] + } + return bpmnInstances().moddle.create( + `${prefix}:${isTask ? 'TaskListener' : 'ExecutionListener'}`, + listenerObj + ) +} + +// 创建 监听器的注入字段 实例 +export function createFieldObject(option, prefix) { + const { name, fieldType, string, expression } = option + const fieldConfig = fieldType === 'string' ? { name, string } : { name, expression } + return bpmnInstances().moddle.create(`${prefix}:Field`, fieldConfig) +} + +// 创建脚本实例 +export function createScriptObject(options, prefix) { + const { scriptType, scriptFormat, value, resource } = options + const scriptConfig = + scriptType === 'inlineScript' ? { scriptFormat, value } : { scriptFormat, resource } + return bpmnInstances().moddle.create(`${prefix}:Script`, scriptConfig) +} + +// 更新元素扩展属性 +export function updateElementExtensions(element, extensionList) { + const extensions = bpmnInstances().moddle.create('bpmn:ExtensionElements', { + values: extensionList + }) + bpmnInstances().modeling.updateProperties(toRaw(element), { + extensionElements: extensions + }) +} + +// 创建一个id +export function uuid(length = 8, chars?) { + let result = '' + const charsString = chars || '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + for (let i = length; i > 0; --i) { + result += charsString[Math.floor(Math.random() * charsString.length)] + } + return result +} diff --git a/src/components/bpmnProcessDesigner/src/highlight/index.js b/src/components/bpmnProcessDesigner/src/highlight/index.js new file mode 100644 index 0000000..5df38c9 --- /dev/null +++ b/src/components/bpmnProcessDesigner/src/highlight/index.js @@ -0,0 +1,5 @@ +const hljs = require('highlight.js/lib/core') +hljs.registerLanguage('xml', require('highlight.js/lib/languages/xml')) +hljs.registerLanguage('json', require('highlight.js/lib/languages/json')) + +module.exports = hljs diff --git a/src/components/bpmnProcessDesigner/src/modules/custom-renderer/CustomRenderer.js b/src/components/bpmnProcessDesigner/src/modules/custom-renderer/CustomRenderer.js new file mode 100644 index 0000000..e876031 --- /dev/null +++ b/src/components/bpmnProcessDesigner/src/modules/custom-renderer/CustomRenderer.js @@ -0,0 +1,14 @@ +import BpmnRenderer from 'bpmn-js/lib/draw/BpmnRenderer' + +export default function CustomRenderer(config, eventBus, styles, pathMap, canvas, textRenderer) { + BpmnRenderer.call(this, config, eventBus, styles, pathMap, canvas, textRenderer, 2000) + + this.handlers['label'] = function () { + return null + } +} + +const F = function () {} // 核心,利用空对象作为中介; +F.prototype = BpmnRenderer.prototype // 核心,将父类的原型赋值给空对象F; +CustomRenderer.prototype = new F() // 核心,将 F的实例赋值给子类; +CustomRenderer.prototype.constructor = CustomRenderer // 修复子类CustomRenderer的构造器指向,防止原型链的混乱; diff --git a/src/components/bpmnProcessDesigner/src/modules/custom-renderer/index.js b/src/components/bpmnProcessDesigner/src/modules/custom-renderer/index.js new file mode 100644 index 0000000..79d8bd0 --- /dev/null +++ b/src/components/bpmnProcessDesigner/src/modules/custom-renderer/index.js @@ -0,0 +1,6 @@ +import CustomRenderer from './CustomRenderer' + +export default { + __init__: ['customRenderer'], + customRenderer: ['type', CustomRenderer] +} diff --git a/src/components/bpmnProcessDesigner/src/modules/rules/CustomRules.js b/src/components/bpmnProcessDesigner/src/modules/rules/CustomRules.js new file mode 100644 index 0000000..9fa1d14 --- /dev/null +++ b/src/components/bpmnProcessDesigner/src/modules/rules/CustomRules.js @@ -0,0 +1,16 @@ +import BpmnRules from 'bpmn-js/lib/features/rules/BpmnRules' +import inherits from 'inherits' + +export default function CustomRules(eventBus) { + BpmnRules.call(this, eventBus) +} + +inherits(CustomRules, BpmnRules) + +CustomRules.prototype.canDrop = function () { + return false +} + +CustomRules.prototype.canMove = function () { + return false +} diff --git a/src/components/bpmnProcessDesigner/src/modules/rules/index.js b/src/components/bpmnProcessDesigner/src/modules/rules/index.js new file mode 100644 index 0000000..12cf05a --- /dev/null +++ b/src/components/bpmnProcessDesigner/src/modules/rules/index.js @@ -0,0 +1,6 @@ +import CustomRules from './CustomRules' + +export default { + __init__: ['customRules'], + customRules: ['type', CustomRules] +} diff --git a/src/components/bpmnProcessDesigner/src/translations.ts b/src/components/bpmnProcessDesigner/src/translations.ts new file mode 100644 index 0000000..5f9b9a5 --- /dev/null +++ b/src/components/bpmnProcessDesigner/src/translations.ts @@ -0,0 +1,25 @@ +/** + * This is a sample file that should be replaced with the actual translation. + * + * Checkout https://github.com/bpmn-io/bpmn-js-i18n for a list of available + * translations and labels to translate. + */ +export default { + 'Exclusive Gateway': 'Exklusives Gateway', + 'Parallel Gateway': 'Paralleles Gateway', + 'Inclusive Gateway': 'Inklusives Gateway', + 'Complex Gateway': 'Komplexes Gateway', + 'Event based Gateway': 'Ereignis-basiertes Gateway', + 'Message Start Event': '消息启动事件', + 'Timer Start Event': '定时启动事件', + 'Conditional Start Event': '条件启动事件', + 'Signal Start Event': '信号启动事件', + 'Error Start Event': '错误启动事件', + 'Escalation Start Event': '升级启动事件', + 'Compensation Start Event': '补偿启动事件', + 'Message Start Event (non-interrupting)': '消息启动事件 (非中断)', + 'Timer Start Event (non-interrupting)': '定时启动事件 (非中断)', + 'Conditional Start Event (non-interrupting)': '条件启动事件 (非中断)', + 'Signal Start Event (non-interrupting)': '信号启动事件 (非中断)', + 'Escalation Start Event (non-interrupting)': '升级启动事件 (非中断)' +} diff --git a/src/components/bpmnProcessDesigner/src/utils/directive/clickOutSide.js b/src/components/bpmnProcessDesigner/src/utils/directive/clickOutSide.js new file mode 100644 index 0000000..bb71d44 --- /dev/null +++ b/src/components/bpmnProcessDesigner/src/utils/directive/clickOutSide.js @@ -0,0 +1,39 @@ +//outside.js + +const ctx = '@@clickoutsideContext' + +export default { + bind(el, binding, vnode) { + const ele = el + const documentHandler = (e) => { + if (!vnode.context || ele.contains(e.target)) { + return false + } + // 调用指令回调 + if (binding.expression) { + vnode.context[el[ctx].methodName](e) + } else { + el[ctx].bindingFn(e) + } + } + // 将方法添加到ele + ele[ctx] = { + documentHandler, + methodName: binding.expression, + bindingFn: binding.value + } + + setTimeout(() => { + document.addEventListener('touchstart', documentHandler) // 为document绑定事件 + }) + }, + update(el, binding) { + const ele = el + ele[ctx].methodName = binding.expression + ele[ctx].bindingFn = binding.value + }, + unbind(el) { + document.removeEventListener('touchstart', el[ctx].documentHandler) // 解绑 + delete el[ctx] + } +} diff --git a/src/components/bpmnProcessDesigner/src/utils/index.js b/src/components/bpmnProcessDesigner/src/utils/index.js new file mode 100644 index 0000000..7d970ec --- /dev/null +++ b/src/components/bpmnProcessDesigner/src/utils/index.js @@ -0,0 +1,10 @@ +export function debounce(fn, delay = 500) { + let timer + return function (...args) { + if (timer) { + clearTimeout(timer) + timer = null + } + timer = setTimeout(fn.bind(this, ...args), delay) + } +} diff --git a/src/components/bpmnProcessDesigner/src/utils/xml2json.js b/src/components/bpmnProcessDesigner/src/utils/xml2json.js new file mode 100644 index 0000000..fe1a52f --- /dev/null +++ b/src/components/bpmnProcessDesigner/src/utils/xml2json.js @@ -0,0 +1,50 @@ +function xmlStr2XmlObj(xmlStr) { + let xmlObj = {} + if (document.all) { + const xmlDom = new window.ActiveXObject('Microsoft.XMLDOM') + xmlDom.loadXML(xmlStr) + xmlObj = xmlDom + } else { + xmlObj = new DOMParser().parseFromString(xmlStr, 'text/xml') + } + return xmlObj +} + +function xml2json(xml) { + try { + let obj = {} + if (xml.children.length > 0) { + for (let i = 0; i < xml.children.length; i++) { + const item = xml.children.item(i) + const nodeName = item.nodeName + if (typeof obj[nodeName] == 'undefined') { + obj[nodeName] = xml2json(item) + } else { + if (typeof obj[nodeName].push == 'undefined') { + const old = obj[nodeName] + obj[nodeName] = [] + obj[nodeName].push(old) + } + obj[nodeName].push(xml2json(item)) + } + } + } else { + obj = xml.textContent + } + return obj + } catch (e) { + console.log(e.message) + } +} + +function xmlObj2json(xml) { + const xmlObj = xmlStr2XmlObj(xml) + console.log(xmlObj) + let jsonObj = {} + if (xmlObj.childNodes.length > 0) { + jsonObj = xml2json(xmlObj) + } + return jsonObj +} + +export default xmlObj2json diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..4d030c3 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,6 @@ +import type { App } from 'vue' +import { Icon } from './Icon' + +export const setupGlobCom = (app: App<Element>): void => { + app.component('Icon', Icon) +} diff --git a/src/config/axios/config.ts b/src/config/axios/config.ts new file mode 100644 index 0000000..8116508 --- /dev/null +++ b/src/config/axios/config.ts @@ -0,0 +1,28 @@ +const config: { + base_url: string + result_code: number | string + default_headers: AxiosHeaders + request_timeout: number +} = { + /** + * api请求基础路径 + */ + base_url: import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL, + /** + * 接口成功返回状态码 + */ + result_code: 200, + + /** + * 接口请求超时时间 + */ + request_timeout: 30000, + + /** + * 默认接口请求类型 + * 可选值:application/x-www-form-urlencoded multipart/form-data + */ + default_headers: 'application/json' +} + +export { config } diff --git a/src/config/axios/errorCode.ts b/src/config/axios/errorCode.ts new file mode 100644 index 0000000..94d719f --- /dev/null +++ b/src/config/axios/errorCode.ts @@ -0,0 +1,6 @@ +export default { + '401': '认证失败,无法访问系统资源', + '403': '当前操作没有权限', + '404': '访问资源不存在', + default: '系统未知错误,请反馈给管理员' +} diff --git a/src/config/axios/index.ts b/src/config/axios/index.ts new file mode 100644 index 0000000..79e558d --- /dev/null +++ b/src/config/axios/index.ts @@ -0,0 +1,51 @@ +import { service } from './service' + +import { config } from './config' + +const { default_headers } = config + +const request = (option: any) => { + const { url, method, params, data, headersType, responseType, ...config } = option + return service({ + url: url, + method, + params, + data, + ...config, + responseType: responseType, + headers: { + 'Content-Type': headersType || default_headers + } + }) +} +export default { + get: async <T = any>(option: any) => { + const res = await request({ method: 'GET', ...option }) + return res.data as unknown as T + }, + post: async <T = any>(option: any) => { + const res = await request({ method: 'POST', ...option }) + return res.data as unknown as T + }, + postOriginal: async (option: any) => { + const res = await request({ method: 'POST', ...option }) + return res + }, + delete: async <T = any>(option: any) => { + const res = await request({ method: 'DELETE', ...option }) + return res.data as unknown as T + }, + put: async <T = any>(option: any) => { + const res = await request({ method: 'PUT', ...option }) + return res.data as unknown as T + }, + download: async <T = any>(option: any) => { + const res = await request({ method: 'GET', responseType: 'blob', ...option }) + return res as unknown as Promise<T> + }, + upload: async <T = any>(option: any) => { + option.headersType = 'multipart/form-data' + const res = await request({ method: 'POST', ...option }) + return res as unknown as Promise<T> + } +} diff --git a/src/config/axios/service.ts b/src/config/axios/service.ts new file mode 100644 index 0000000..aff542b --- /dev/null +++ b/src/config/axios/service.ts @@ -0,0 +1,231 @@ +import axios, { + AxiosError, + AxiosInstance, + AxiosRequestHeaders, + AxiosResponse, + InternalAxiosRequestConfig +} from 'axios' + +import { ElMessage, ElMessageBox, ElNotification } from 'element-plus' +import qs from 'qs' +import { config } from '@/config/axios/config' +import { getAccessToken, getRefreshToken, getTenantId, removeToken, setToken } from '@/utils/auth' +import errorCode from './errorCode' + +import { resetRouter } from '@/router' +import { deleteUserCache } from '@/hooks/web/useCache' + +const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLE +const { result_code, base_url, request_timeout } = config + +// 需要忽略的提示。忽略后,自动 Promise.reject('error') +const ignoreMsgs = [ + '无效的刷新令牌', // 刷新令牌被删除时,不用提示 + '刷新令牌已过期' // 使用刷新令牌,刷新获取新的访问令牌时,结果因为过期失败,此时需要忽略。否则,会导致继续 401,无法跳转到登出界面 +] +// 是否显示重新登录 +export const isRelogin = { show: false } +// Axios 无感知刷新令牌,参考 https://www.dashingdog.cn/article/11 与 https://segmentfault.com/a/1190000020210980 实现 +// 请求队列 +let requestList: any[] = [] +// 是否正在刷新中 +let isRefreshToken = false +// 请求白名单,无须token的接口 +const whiteList: string[] = ['/login', '/refresh-token'] + +// 创建axios实例 +const service: AxiosInstance = axios.create({ + baseURL: base_url, // api 的 base_url + timeout: request_timeout, // 请求超时时间 + withCredentials: false // 禁用 Cookie 等信息 +}) + +// request拦截器 +service.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + // 是否需要设置 token + let isToken = (config!.headers || {}).isToken === false + whiteList.some((v) => { + if (config.url) { + config.url.indexOf(v) > -1 + return (isToken = false) + } + }) + if (getAccessToken() && !isToken) { + ;(config as Recordable).headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token + } + // 设置租户 + if (tenantEnable && tenantEnable === 'true') { + const tenantId = getTenantId() + if (tenantId) (config as Recordable).headers['tenant-id'] = tenantId + } + const params = config.params || {} + const data = config.data || false + if ( + config.method?.toUpperCase() === 'POST' && + (config.headers as AxiosRequestHeaders)['Content-Type'] === + 'application/x-www-form-urlencoded' + ) { + config.data = qs.stringify(data) + } + // get参数编码 + if (config.method?.toUpperCase() === 'GET' && params) { + config.params = {} + const paramsStr = qs.stringify(params, { allowDots: true }) + if (paramsStr) { + config.url = config.url + '?' + paramsStr + } + } + return config + }, + (error: AxiosError) => { + // Do something with request error + console.log(error) // for debug + return Promise.reject(error) + } +) + +// response 拦截器 +service.interceptors.response.use( + async (response: AxiosResponse<any>) => { + let { data } = response + const config = response.config + if (!data) { + // 返回“[HTTP]请求没有返回值”; + throw new Error() + } + const { t } = useI18n() + // 未设置状态码则默认成功状态 + // 二进制数据则直接返回,例如说 Excel 导出 + if ( + response.request.responseType === 'blob' || + response.request.responseType === 'arraybuffer' + ) { + // 注意:如果导出的响应为 json,说明可能失败了,不直接返回进行下载 + if (response.data.type !== 'application/json') { + return response.data + } + data = await new Response(response.data).json() + } + const code = data.code || result_code + // 获取错误信息 + const msg = data.msg || errorCode[code] || errorCode['default'] + if (ignoreMsgs.indexOf(msg) !== -1) { + // 如果是忽略的错误码,直接返回 msg 异常 + return Promise.reject(msg) + } else if (code === 401) { + // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了 + if (!isRefreshToken) { + isRefreshToken = true + // 1. 如果获取不到刷新令牌,则只能执行登出操作 + if (!getRefreshToken()) { + return handleAuthorized() + } + // 2. 进行刷新访问令牌 + try { + const refreshTokenRes = await refreshToken() + // 2.1 刷新成功,则回放队列的请求 + 当前请求 + setToken((await refreshTokenRes).data.data) + config.headers!.Authorization = 'Bearer ' + getAccessToken() + requestList.forEach((cb: any) => { + cb() + }) + requestList = [] + return service(config) + } catch (e) { + // 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。 + // 2.2 刷新失败,只回放队列的请求 + requestList.forEach((cb: any) => { + cb() + }) + // 提示是否要登出。即不回放当前请求!不然会形成递归 + return handleAuthorized() + } finally { + requestList = [] + isRefreshToken = false + } + } else { + // 添加到队列,等待刷新获取到新的令牌 + return new Promise((resolve) => { + requestList.push(() => { + config.headers!.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token 请根据实际情况自行修改 + resolve(service(config)) + }) + }) + } + } else if (code === 500) { + ElMessage.error(t('sys.api.errMsg500')) + return Promise.reject(new Error(msg)) + } else if (code === 901) { + ElMessage.error({ + offset: 300, + dangerouslyUseHTMLString: true, + message: + '<div>' + + t('sys.api.errMsg901') + + '</div>' + + '<div> </div>' + + '<div>参考 https://doc.iocoder.cn/ 教程</div>' + + '<div> </div>' + + '<div>5 分钟搭建本地环境</div>' + }) + return Promise.reject(new Error(msg)) + } else if (code !== 200) { + if (msg === '无效的刷新令牌') { + // hard coding:忽略这个提示,直接登出 + console.log(msg) + return handleAuthorized() + } else { + ElNotification.error({ title: msg }) + } + return Promise.reject('error') + } else { + return data + } + }, + (error: AxiosError) => { + console.log('err' + error) // for debug + let { message } = error + const { t } = useI18n() + if (message === 'Network Error') { + message = t('sys.api.errorMessage') + } else if (message.includes('timeout')) { + message = t('sys.api.apiTimeoutMessage') + } else if (message.includes('Request failed with status code')) { + message = t('sys.api.apiRequestFailed') + message.substr(message.length - 3) + } + ElMessage.error(message) + return Promise.reject(error) + } +) + +const refreshToken = async () => { + axios.defaults.headers.common['tenant-id'] = getTenantId() + return await axios.post(base_url + '/system/auth/refresh-token?refreshToken=' + getRefreshToken()) +} +const handleAuthorized = () => { + const { t } = useI18n() + if (!isRelogin.show) { + // 如果已经到重新登录页面则不进行弹窗提示 + if (window.location.href.includes('login?redirect=')) { + return + } + isRelogin.show = true + ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), { + showCancelButton: false, + closeOnClickModal: false, + showClose: false, + confirmButtonText: t('login.relogin'), + type: 'warning' + }).then(() => { + resetRouter() // 重置静态路由表 + deleteUserCache() // 删除用户缓存 + removeToken() + isRelogin.show = false + // 干掉token后再走一次路由让它过router.beforeEach的校验 + window.location.href = window.location.href + }) + } + return Promise.reject(t('sys.api.timeoutMessage')) +} +export { service } diff --git a/src/directives/index.ts b/src/directives/index.ts new file mode 100644 index 0000000..89cc8ba --- /dev/null +++ b/src/directives/index.ts @@ -0,0 +1,13 @@ +import type { App } from 'vue' +import { hasRole } from './permission/hasRole' +import { hasPermi } from './permission/hasPermi' + +/** + * 导出指令:v-xxx + * @methods hasRole 用户权限,用法: v-hasRole + * @methods hasPermi 按钮权限,用法: v-hasPermi + */ +export const setupAuth = (app: App<Element>) => { + hasRole(app) + hasPermi(app) +} diff --git a/src/directives/permission/hasPermi.ts b/src/directives/permission/hasPermi.ts new file mode 100644 index 0000000..d86d2f5 --- /dev/null +++ b/src/directives/permission/hasPermi.ts @@ -0,0 +1,27 @@ +import type { App } from 'vue' +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' + +const { t } = useI18n() // 国际化 + +export function hasPermi(app: App<Element>) { + app.directive('hasPermi', (el, binding) => { + const { wsCache } = useCache() + const { value } = binding + const all_permission = '*:*:*' + const permissions = wsCache.get(CACHE_KEY.USER).permissions + + if (value && value instanceof Array && value.length > 0) { + const permissionFlag = value + + const hasPermissions = permissions.some((permission: string) => { + return all_permission === permission || permissionFlag.includes(permission) + }) + + if (!hasPermissions) { + el.parentNode && el.parentNode.removeChild(el) + } + } else { + throw new Error(t('permission.hasPermission')) + } + }) +} diff --git a/src/directives/permission/hasRole.ts b/src/directives/permission/hasRole.ts new file mode 100644 index 0000000..31a352a --- /dev/null +++ b/src/directives/permission/hasRole.ts @@ -0,0 +1,27 @@ +import type { App } from 'vue' +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' + +const { t } = useI18n() // 国际化 + +export function hasRole(app: App<Element>) { + app.directive('hasRole', (el, binding) => { + const { wsCache } = useCache() + const { value } = binding + const super_admin = 'admin' + const roles = wsCache.get(CACHE_KEY.USER).roles + + if (value && value instanceof Array && value.length > 0) { + const roleFlag = value + + const hasRole = roles.some((role: string) => { + return super_admin === role || roleFlag.includes(role) + }) + + if (!hasRole) { + el.parentNode && el.parentNode.removeChild(el) + } + } else { + throw new Error(t('permission.hasRole')) + } + }) +} diff --git a/src/hooks/event/useScrollTo.ts b/src/hooks/event/useScrollTo.ts new file mode 100644 index 0000000..92aec87 --- /dev/null +++ b/src/hooks/event/useScrollTo.ts @@ -0,0 +1,60 @@ +export interface ScrollToParams { + el: HTMLElement + to: number + position: string + duration?: number + callback?: () => void +} + +const easeInOutQuad = (t: number, b: number, c: number, d: number) => { + t /= d / 2 + if (t < 1) { + return (c / 2) * t * t + b + } + t-- + return (-c / 2) * (t * (t - 2) - 1) + b +} +const move = (el: HTMLElement, position: string, amount: number) => { + el[position] = amount +} + +export function useScrollTo({ + el, + position = 'scrollLeft', + to, + duration = 500, + callback +}: ScrollToParams) { + const isActiveRef = ref(false) + const start = el[position] + const change = to - start + const increment = 20 + let currentTime = 0 + + function animateScroll() { + if (!unref(isActiveRef)) { + return + } + currentTime += increment + const val = easeInOutQuad(currentTime, start, change, duration) + move(el, position, val) + if (currentTime < duration && unref(isActiveRef)) { + requestAnimationFrame(animateScroll) + } else { + if (callback) { + callback() + } + } + } + + function run() { + isActiveRef.value = true + animateScroll() + } + + function stop() { + isActiveRef.value = false + } + + return { start: run, stop } +} diff --git a/src/hooks/web/useCache.ts b/src/hooks/web/useCache.ts new file mode 100644 index 0000000..4f39f30 --- /dev/null +++ b/src/hooks/web/useCache.ts @@ -0,0 +1,39 @@ +/** + * 配置浏览器本地存储的方式,可直接存储对象数组。 + */ + +import WebStorageCache from 'web-storage-cache' + +type CacheType = 'localStorage' | 'sessionStorage' + +export const CACHE_KEY = { + // 用户相关 + ROLE_ROUTERS: 'roleRouters', + USER: 'user', + // 系统设置 + IS_DARK: 'isDark', + LANG: 'lang', + THEME: 'theme', + LAYOUT: 'layout', + DICT_CACHE: 'dictCache', + // 登录表单 + LoginForm: 'loginForm', + TenantId: 'tenantId' +} + +export const useCache = (type: CacheType = 'localStorage') => { + const wsCache: WebStorageCache = new WebStorageCache({ + storage: type + }) + + return { + wsCache + } +} + +export const deleteUserCache = () => { + const { wsCache } = useCache() + wsCache.delete(CACHE_KEY.USER) + wsCache.delete(CACHE_KEY.ROLE_ROUTERS) + // 注意,不要清理 LoginForm 登录表单 +} diff --git a/src/hooks/web/useConfigGlobal.ts b/src/hooks/web/useConfigGlobal.ts new file mode 100644 index 0000000..afb3db3 --- /dev/null +++ b/src/hooks/web/useConfigGlobal.ts @@ -0,0 +1,9 @@ +import { ConfigGlobalTypes } from '@/types/configGlobal' + +export const useConfigGlobal = () => { + const configGlobal = inject('configGlobal', {}) as ConfigGlobalTypes + + return { + configGlobal + } +} diff --git a/src/hooks/web/useCrudSchemas.ts b/src/hooks/web/useCrudSchemas.ts new file mode 100644 index 0000000..458b57e --- /dev/null +++ b/src/hooks/web/useCrudSchemas.ts @@ -0,0 +1,326 @@ +import { reactive } from 'vue' +import { AxiosPromise } from 'axios' +import { findIndex } from '@/utils' +import { eachTree, filter, treeMap } from '@/utils/tree' +import { getBoolDictOptions, getDictOptions, getIntDictOptions } from '@/utils/dict' + +import { FormSchema } from '@/types/form' +import { TableColumn } from '@/types/table' +import { DescriptionsSchema } from '@/types/descriptions' +import { ComponentOptions, ComponentProps } from '@/types/components' +import { DictTag } from '@/components/DictTag' +import { cloneDeep, merge } from 'lodash-es' + +export type CrudSchema = Omit<TableColumn, 'children'> & { + isSearch?: boolean // 是否在查询显示 + search?: CrudSearchParams // 查询的详细配置 + isTable?: boolean // 是否在列表显示 + table?: CrudTableParams // 列表的详细配置 + isForm?: boolean // 是否在表单显示 + form?: CrudFormParams // 表单的详细配置 + isDetail?: boolean // 是否在详情显示 + detail?: CrudDescriptionsParams // 详情的详细配置 + children?: CrudSchema[] + dictType?: string // 字典类型 + dictClass?: 'string' | 'number' | 'boolean' // 字典数据类型 string | number | boolean +} + +type CrudSearchParams = { + // 是否显示在查询项 + show?: boolean + // 接口 + api?: () => Promise<any> + // 搜索字段 + field?: string +} & Omit<FormSchema, 'field'> + +type CrudTableParams = { + // 是否显示表头 + show?: boolean + // 列宽配置 + width?: number | string + // 列是否固定在左侧或者右侧 + fixed?: 'left' | 'right' +} & Omit<FormSchema, 'field'> +type CrudFormParams = { + // 是否显示表单项 + show?: boolean + // 接口 + api?: () => Promise<any> +} & Omit<FormSchema, 'field'> + +type CrudDescriptionsParams = { + // 是否显示表单项 + show?: boolean +} & Omit<DescriptionsSchema, 'field'> + +interface AllSchemas { + searchSchema: FormSchema[] + tableColumns: TableColumn[] + formSchema: FormSchema[] + detailSchema: DescriptionsSchema[] +} + +const { t } = useI18n() + +// 过滤所有结构 +export const useCrudSchemas = ( + crudSchema: CrudSchema[] +): { + allSchemas: AllSchemas +} => { + // 所有结构数据 + const allSchemas = reactive<AllSchemas>({ + searchSchema: [], + tableColumns: [], + formSchema: [], + detailSchema: [] + }) + + const searchSchema = filterSearchSchema(crudSchema, allSchemas) + allSchemas.searchSchema = searchSchema || [] + + const tableColumns = filterTableSchema(crudSchema) + allSchemas.tableColumns = tableColumns || [] + + const formSchema = filterFormSchema(crudSchema, allSchemas) + allSchemas.formSchema = formSchema + + const detailSchema = filterDescriptionsSchema(crudSchema) + allSchemas.detailSchema = detailSchema + + return { + allSchemas + } +} + +// 过滤 Search 结构 +const filterSearchSchema = (crudSchema: CrudSchema[], allSchemas: AllSchemas): FormSchema[] => { + const searchSchema: FormSchema[] = [] + + // 获取字典列表队列 + const searchRequestTask: Array<() => Promise<void>> = [] + eachTree(crudSchema, (schemaItem: CrudSchema) => { + // 判断是否显示 + if (schemaItem?.isSearch || schemaItem.search?.show) { + let component = schemaItem?.search?.component || 'Input' + const options: ComponentOptions[] = [] + let comonentProps: ComponentProps = {} + if (schemaItem.dictType) { + const allOptions: ComponentOptions = { label: '全部', value: '' } + options.push(allOptions) + getDictOptions(schemaItem.dictType).forEach((dict) => { + options.push(dict) + }) + comonentProps = { + options: options + } + if (!schemaItem.search?.component) component = 'Select' + } + + // updated by AKing: 解决了当使用默认的dict选项时,form中事件不能触发的问题 + const searchSchemaItem = merge( + { + // 默认为 input + component, + ...schemaItem.search, + field: schemaItem.field, + label: schemaItem.search?.label || schemaItem.label + }, + { componentProps: comonentProps } + ) + if (searchSchemaItem.api) { + searchRequestTask.push(async () => { + const res = await (searchSchemaItem.api as () => AxiosPromise)() + if (res) { + const index = findIndex(allSchemas.searchSchema, (v: FormSchema) => { + return v.field === searchSchemaItem.field + }) + if (index !== -1) { + allSchemas.searchSchema[index]!.componentProps!.options = filterOptions( + res, + searchSchemaItem.componentProps.optionsAlias?.labelField + ) + } + } + }) + } + // 删除不必要的字段 + delete searchSchemaItem.show + + searchSchema.push(searchSchemaItem) + } + }) + for (const task of searchRequestTask) { + task() + } + return searchSchema +} + +// 过滤 table 结构 +const filterTableSchema = (crudSchema: CrudSchema[]): TableColumn[] => { + const tableColumns = treeMap<CrudSchema>(crudSchema, { + conversion: (schema: CrudSchema) => { + if (schema?.isTable !== false && schema?.table?.show !== false) { + // add by 芋艿:增加对 dict 字典数据的支持 + if (!schema.formatter && schema.dictType) { + schema.formatter = (_: Recordable, __: TableColumn, cellValue: any) => { + return h(DictTag, { + type: schema.dictType!, // ! 表示一定不为空 + value: cellValue + }) + } + } + return { + ...schema.table, + ...schema + } + } + } + }) + + // 第一次过滤会有 undefined 所以需要二次过滤 + return filter<TableColumn>(tableColumns as TableColumn[], (data) => { + if (data.children === void 0) { + delete data.children + } + return !!data.field + }) +} + +// 过滤 form 结构 +const filterFormSchema = (crudSchema: CrudSchema[], allSchemas: AllSchemas): FormSchema[] => { + const formSchema: FormSchema[] = [] + + // 获取字典列表队列 + const formRequestTask: Array<() => Promise<void>> = [] + + eachTree(crudSchema, (schemaItem: CrudSchema) => { + // 判断是否显示 + if (schemaItem?.isForm !== false && schemaItem?.form?.show !== false) { + let component = schemaItem?.form?.component || 'Input' + let defaultValue: any = '' + if (schemaItem.form?.value) { + defaultValue = schemaItem.form?.value + } else { + if (component === 'InputNumber') { + defaultValue = 0 + } + } + let comonentProps: ComponentProps = {} + if (schemaItem.dictType) { + const options: ComponentOptions[] = [] + if (schemaItem.dictClass && schemaItem.dictClass === 'number') { + getIntDictOptions(schemaItem.dictType).forEach((dict) => { + options.push(dict) + }) + } else if (schemaItem.dictClass && schemaItem.dictClass === 'boolean') { + getBoolDictOptions(schemaItem.dictType).forEach((dict) => { + options.push(dict) + }) + } else { + getDictOptions(schemaItem.dictType).forEach((dict) => { + options.push(dict) + }) + } + comonentProps = { + options: options + } + if (!(schemaItem.form && schemaItem.form.component)) component = 'Select' + } + + // updated by AKing: 解决了当使用默认的dict选项时,form中事件不能触发的问题 + const formSchemaItem = merge( + { + // 默认为 input + component, + value: defaultValue, + ...schemaItem.form, + field: schemaItem.field, + label: schemaItem.form?.label || schemaItem.label + }, + { componentProps: comonentProps } + ) + + if (formSchemaItem.api) { + formRequestTask.push(async () => { + const res = await (formSchemaItem.api as () => AxiosPromise)() + if (res) { + const index = findIndex(allSchemas.formSchema, (v: FormSchema) => { + return v.field === formSchemaItem.field + }) + if (index !== -1) { + allSchemas.formSchema[index]!.componentProps!.options = filterOptions( + res, + formSchemaItem.componentProps.optionsAlias?.labelField + ) + } + } + }) + } + + // 删除不必要的字段 + delete formSchemaItem.show + + formSchema.push(formSchemaItem) + } + }) + + for (const task of formRequestTask) { + task() + } + return formSchema +} + +// 过滤 descriptions 结构 +const filterDescriptionsSchema = (crudSchema: CrudSchema[]): DescriptionsSchema[] => { + const descriptionsSchema: FormSchema[] = [] + + eachTree(crudSchema, (schemaItem: CrudSchema) => { + // 判断是否显示 + if (schemaItem?.isDetail !== false && schemaItem.detail?.show !== false) { + const descriptionsSchemaItem = { + ...schemaItem.detail, + field: schemaItem.field, + label: schemaItem.detail?.label || schemaItem.label + } + if (schemaItem.dictType) { + descriptionsSchemaItem.dictType = schemaItem.dictType + } + if (schemaItem.detail?.dateFormat || schemaItem.formatter == 'formatDate') { + // 优先使用 detail 下的配置,如果没有默认为 YYYY-MM-DD HH:mm:ss + descriptionsSchemaItem.dateFormat = schemaItem?.detail?.dateFormat + ? schemaItem?.detail?.dateFormat + : 'YYYY-MM-DD HH:mm:ss' + } + + // 删除不必要的字段 + delete descriptionsSchemaItem.show + + descriptionsSchema.push(descriptionsSchemaItem) + } + }) + + return descriptionsSchema +} + +// 给options添加国际化 +const filterOptions = (options: Recordable, labelField?: string) => { + return options?.map((v: Recordable) => { + if (labelField) { + v['labelField'] = t(v.labelField) + } else { + v['label'] = t(v.label) + } + return v + }) +} + +// 将 tableColumns 指定 fields 放到最前面 +export const sortTableColumns = (tableColumns: TableColumn[], field: string) => { + const fieldIndex = tableColumns.findIndex((item) => item.field === field) + const fieldColumn = cloneDeep(tableColumns[fieldIndex]) + tableColumns.splice(fieldIndex, 1) + // 添加到开头 + tableColumns.unshift(fieldColumn) +} diff --git a/src/hooks/web/useDesign.ts b/src/hooks/web/useDesign.ts new file mode 100644 index 0000000..8ee3b38 --- /dev/null +++ b/src/hooks/web/useDesign.ts @@ -0,0 +1,18 @@ +import variables from '@/styles/global.module.scss' + +export const useDesign = () => { + const scssVariables = variables + + /** + * @param scope 类名 + * @returns 返回空间名-类名 + */ + const getPrefixCls = (scope: string) => { + return `${scssVariables.namespace}-${scope}` + } + + return { + variables: scssVariables, + getPrefixCls + } +} diff --git a/src/hooks/web/useEmitt.ts b/src/hooks/web/useEmitt.ts new file mode 100644 index 0000000..d4efea7 --- /dev/null +++ b/src/hooks/web/useEmitt.ts @@ -0,0 +1,22 @@ +import mitt from 'mitt' + +interface Option { + name: string // 事件名称 + callback: Fn // 回调 +} + +const emitter = mitt() + +export const useEmitt = (option?: Option) => { + if (option) { + emitter.on(option.name, option.callback) + + onBeforeUnmount(() => { + emitter.off(option.name) + }) + } + + return { + emitter + } +} diff --git a/src/hooks/web/useForm.ts b/src/hooks/web/useForm.ts new file mode 100644 index 0000000..53a8a94 --- /dev/null +++ b/src/hooks/web/useForm.ts @@ -0,0 +1,94 @@ +import type { Form, FormExpose } from '@/components/Form' +import type { ElForm } from 'element-plus' +import type { FormProps } from '@/components/Form/src/types' +import { FormSchema, FormSetPropsType } from '@/types/form' + +export const useForm = (props?: FormProps) => { + // From实例 + const formRef = ref<typeof Form & FormExpose>() + + // ElForm实例 + const elFormRef = ref<ComponentRef<typeof ElForm>>() + + /** + * @param ref Form实例 + * @param elRef ElForm实例 + */ + const register = (ref: typeof Form & FormExpose, elRef: ComponentRef<typeof ElForm>) => { + formRef.value = ref + elFormRef.value = elRef + } + + const getForm = async () => { + await nextTick() + const form = unref(formRef) + if (!form) { + console.error('The form is not registered. Please use the register method to register') + } + return form + } + + // 一些内置的方法 + const methods: { + setProps: (props: Recordable) => void + setValues: (data: Recordable) => void + getFormData: <T = Recordable | undefined>() => Promise<T> + setSchema: (schemaProps: FormSetPropsType[]) => void + addSchema: (formSchema: FormSchema, index?: number) => void + delSchema: (field: string) => void + } = { + setProps: async (props: FormProps = {}) => { + const form = await getForm() + form?.setProps(props) + if (props.model) { + form?.setValues(props.model) + } + }, + + setValues: async (data: Recordable) => { + const form = await getForm() + form?.setValues(data) + }, + + /** + * @param schemaProps 需要设置的schemaProps + */ + setSchema: async (schemaProps: FormSetPropsType[]) => { + const form = await getForm() + form?.setSchema(schemaProps) + }, + + /** + * @param formSchema 需要新增数据 + * @param index 在哪里新增 + */ + addSchema: async (formSchema: FormSchema, index?: number) => { + const form = await getForm() + form?.addSchema(formSchema, index) + }, + + /** + * @param field 删除哪个数据 + */ + delSchema: async (field: string) => { + const form = await getForm() + form?.delSchema(field) + }, + + /** + * @returns form data + */ + getFormData: async <T = Recordable>(): Promise<T> => { + const form = await getForm() + return form?.formModel as T + } + } + + props && methods.setProps(props) + + return { + register, + elFormRef, + methods + } +} diff --git a/src/hooks/web/useGuide.ts b/src/hooks/web/useGuide.ts new file mode 100644 index 0000000..7fd2fb0 --- /dev/null +++ b/src/hooks/web/useGuide.ts @@ -0,0 +1,49 @@ +import { Config, driver } from 'driver.js' +import 'driver.js/dist/driver.css' +import { useDesign } from '@/hooks/web/useDesign' +import { useI18n } from '@/hooks/web/useI18n' + +const { t } = useI18n() + +const { variables } = useDesign() + +export const useGuide = (options?: Config) => { + const driverObj = driver( + options || { + showProgress: true, + nextBtnText: t('common.nextLabel'), + prevBtnText: t('common.prevLabel'), + doneBtnText: t('common.doneLabel'), + steps: [ + { + element: `#${variables.namespace}-menu`, + popover: { + title: t('common.menu'), + description: t('common.menuDes'), + side: 'right' + } + }, + { + element: `#${variables.namespace}-tool-header`, + popover: { + title: t('common.tool'), + description: t('common.toolDes'), + side: 'left' + } + }, + { + element: `#${variables.namespace}-tags-view`, + popover: { + title: t('common.tagsView'), + description: t('common.tagsViewDes'), + side: 'bottom' + } + } + ] + } + ) + + return { + ...driverObj + } +} diff --git a/src/hooks/web/useI18n.ts b/src/hooks/web/useI18n.ts new file mode 100644 index 0000000..d1ab70f --- /dev/null +++ b/src/hooks/web/useI18n.ts @@ -0,0 +1,53 @@ +import { i18n } from '@/plugins/vueI18n' + +type I18nGlobalTranslation = { + (key: string): string + (key: string, locale: string): string + (key: string, locale: string, list: unknown[]): string + (key: string, locale: string, named: Record<string, unknown>): string + (key: string, list: unknown[]): string + (key: string, named: Record<string, unknown>): string +} + +type I18nTranslationRestParameters = [string, any] + +const getKey = (namespace: string | undefined, key: string) => { + if (!namespace) { + return key + } + if (key.startsWith(namespace)) { + return key + } + return `${namespace}.${key}` +} + +export const useI18n = ( + namespace?: string +): { + t: I18nGlobalTranslation +} => { + const normalFn = { + t: (key: string) => { + return getKey(namespace, key) + } + } + + if (!i18n) { + return normalFn + } + + const { t, ...methods } = i18n.global + + const tFn: I18nGlobalTranslation = (key: string, ...arg: any[]) => { + if (!key) return '' + if (!key.includes('.') && !namespace) return key + //@ts-ignore + return t(getKey(namespace, key), ...(arg as I18nTranslationRestParameters)) + } + return { + ...methods, + t: tFn + } +} + +export const t = (key: string) => key diff --git a/src/hooks/web/useIcon.ts b/src/hooks/web/useIcon.ts new file mode 100644 index 0000000..3500204 --- /dev/null +++ b/src/hooks/web/useIcon.ts @@ -0,0 +1,8 @@ +import { h } from 'vue' +import type { VNode } from 'vue' +import { Icon } from '@/components/Icon' +import { IconTypes } from '@/types/icon' + +export const useIcon = (props: IconTypes): VNode => { + return h(Icon, props) +} diff --git a/src/hooks/web/useLocale.ts b/src/hooks/web/useLocale.ts new file mode 100644 index 0000000..c65070e --- /dev/null +++ b/src/hooks/web/useLocale.ts @@ -0,0 +1,35 @@ +import { i18n } from '@/plugins/vueI18n' +import { useLocaleStoreWithOut } from '@/store/modules/locale' +import { setHtmlPageLang } from '@/plugins/vueI18n/helper' + +const setI18nLanguage = (locale: LocaleType) => { + const localeStore = useLocaleStoreWithOut() + + if (i18n.mode === 'legacy') { + i18n.global.locale = locale + } else { + ;(i18n.global.locale as any).value = locale + } + localeStore.setCurrentLocale({ + lang: locale + }) + setHtmlPageLang(locale) +} + +export const useLocale = () => { + // Switching the language will change the locale of useI18n + // And submit to configuration modification + const changeLocale = async (locale: LocaleType) => { + const globalI18n = i18n.global + + const langModule = await import(`../../locales/${locale}.ts`) + + globalI18n.setLocaleMessage(locale, langModule.default) + + setI18nLanguage(locale) + } + + return { + changeLocale + } +} diff --git a/src/hooks/web/useMessage.ts b/src/hooks/web/useMessage.ts new file mode 100644 index 0000000..ac2b552 --- /dev/null +++ b/src/hooks/web/useMessage.ts @@ -0,0 +1,95 @@ +import { ElMessage, ElMessageBox, ElNotification } from 'element-plus' +import { useI18n } from './useI18n' +export const useMessage = () => { + const { t } = useI18n() + return { + // 消息提示 + info(content: string) { + ElMessage.info(content) + }, + // 错误消息 + error(content: string) { + ElMessage.error(content) + }, + // 成功消息 + success(content: string) { + ElMessage.success(content) + }, + // 警告消息 + warning(content: string) { + ElMessage.warning(content) + }, + // 弹出提示 + alert(content: string) { + ElMessageBox.alert(content, t('common.confirmTitle')) + }, + // 错误提示 + alertError(content: string) { + ElMessageBox.alert(content, t('common.confirmTitle'), { type: 'error' }) + }, + // 成功提示 + alertSuccess(content: string) { + ElMessageBox.alert(content, t('common.confirmTitle'), { type: 'success' }) + }, + // 警告提示 + alertWarning(content: string) { + ElMessageBox.alert(content, t('common.confirmTitle'), { type: 'warning' }) + }, + // 通知提示 + notify(content: string) { + ElNotification.info(content) + }, + // 错误通知 + notifyError(content: string) { + ElNotification.error(content) + }, + // 成功通知 + notifySuccess(content: string) { + ElNotification.success(content) + }, + // 警告通知 + notifyWarning(content: string) { + ElNotification.warning(content) + }, + // 确认窗体 + confirm(content: string, tip?: string) { + return ElMessageBox.confirm(content, tip ? tip : t('common.confirmTitle'), { + confirmButtonText: t('common.ok'), + cancelButtonText: t('common.cancel'), + type: 'warning' + }) + }, + // 删除窗体 + delConfirm(content?: string, tip?: string) { + return ElMessageBox.confirm( + content ? content : t('common.delMessage'), + tip ? tip : t('common.confirmTitle'), + { + confirmButtonText: t('common.ok'), + cancelButtonText: t('common.cancel'), + type: 'warning' + } + ) + }, + // 导出窗体 + exportConfirm(content?: string, tip?: string) { + return ElMessageBox.confirm( + content ? content : t('common.exportMessage'), + tip ? tip : t('common.confirmTitle'), + { + confirmButtonText: t('common.ok'), + cancelButtonText: t('common.cancel'), + type: 'warning' + } + ) + }, + // 提交内容 + prompt(content: string, tip: string) { + return ElMessageBox.prompt(content, tip, { + confirmButtonText: t('common.ok'), + cancelButtonText: t('common.cancel'), + type: 'warning' + }) + } + } +} diff --git a/src/hooks/web/useNProgress.ts b/src/hooks/web/useNProgress.ts new file mode 100644 index 0000000..6d8c0b9 --- /dev/null +++ b/src/hooks/web/useNProgress.ts @@ -0,0 +1,33 @@ +import { useCssVar } from '@vueuse/core' +import type { NProgressOptions } from 'nprogress' +import NProgress from 'nprogress' +import 'nprogress/nprogress.css' + +const primaryColor = useCssVar('--el-color-primary', document.documentElement) + +export const useNProgress = () => { + NProgress.configure({ showSpinner: false } as NProgressOptions) + + const initColor = async () => { + await nextTick() + const bar = document.getElementById('nprogress')?.getElementsByClassName('bar')[0] as ElRef + if (bar) { + bar.style.background = unref(primaryColor.value) + } + } + + initColor() + + const start = () => { + NProgress.start() + } + + const done = () => { + NProgress.done() + } + + return { + start, + done + } +} diff --git a/src/hooks/web/useNetwork.ts b/src/hooks/web/useNetwork.ts new file mode 100644 index 0000000..66fa446 --- /dev/null +++ b/src/hooks/web/useNetwork.ts @@ -0,0 +1,21 @@ +import { ref, onBeforeUnmount } from 'vue' + +const useNetwork = () => { + const online = ref(true) + + const updateNetwork = () => { + online.value = navigator.onLine + } + + window.addEventListener('online', updateNetwork) + window.addEventListener('offline', updateNetwork) + + onBeforeUnmount(() => { + window.removeEventListener('online', updateNetwork) + window.removeEventListener('offline', updateNetwork) + }) + + return { online } +} + +export { useNetwork } diff --git a/src/hooks/web/useNow.ts b/src/hooks/web/useNow.ts new file mode 100644 index 0000000..09d3176 --- /dev/null +++ b/src/hooks/web/useNow.ts @@ -0,0 +1,60 @@ +import { dateUtil } from '@/utils/dateUtil' +import { reactive, toRefs } from 'vue' +import { tryOnMounted, tryOnUnmounted } from '@vueuse/core' + +export const useNow = (immediate = true) => { + let timer: IntervalHandle + + const state = reactive({ + year: 0, + month: 0, + week: '', + day: 0, + hour: '', + minute: '', + second: 0, + meridiem: '' + }) + + const update = () => { + const now = dateUtil() + + const h = now.format('HH') + const m = now.format('mm') + const s = now.get('s') + + state.year = now.get('y') + state.month = now.get('M') + 1 + state.week = '星期' + ['日', '一', '二', '三', '四', '五', '六'][now.day()] + state.day = now.get('date') + state.hour = h + state.minute = m + state.second = s + + state.meridiem = now.format('A') + } + + function start() { + update() + clearInterval(timer) + timer = setInterval(() => update(), 1000) + } + + function stop() { + clearInterval(timer) + } + + tryOnMounted(() => { + immediate && start() + }) + + tryOnUnmounted(() => { + stop() + }) + + return { + ...toRefs(state), + start, + stop + } +} diff --git a/src/hooks/web/usePageLoading.ts b/src/hooks/web/usePageLoading.ts new file mode 100644 index 0000000..bb89457 --- /dev/null +++ b/src/hooks/web/usePageLoading.ts @@ -0,0 +1,18 @@ +import { useAppStoreWithOut } from '@/store/modules/app' + +const appStore = useAppStoreWithOut() + +export const usePageLoading = () => { + const loadStart = () => { + appStore.setPageLoading(true) + } + + const loadDone = () => { + appStore.setPageLoading(false) + } + + return { + loadStart, + loadDone + } +} diff --git a/src/hooks/web/useTable.ts b/src/hooks/web/useTable.ts new file mode 100644 index 0000000..361dd67 --- /dev/null +++ b/src/hooks/web/useTable.ts @@ -0,0 +1,223 @@ +import download from '@/utils/download' +import { Table, TableExpose } from '@/components/Table' +import { ElMessage, ElMessageBox, ElTable } from 'element-plus' +import { computed, nextTick, reactive, ref, unref, watch } from 'vue' +import type { TableProps } from '@/components/Table/src/types' + +import { TableSetPropsType } from '@/types/table' + +const { t } = useI18n() +interface ResponseType<T = any> { + list: T[] + total?: number +} + +interface UseTableConfig<T = any> { + getListApi: (option: any) => Promise<T> + delListApi?: (option: any) => Promise<T> + exportListApi?: (option: any) => Promise<T> + // 返回数据格式配置 + response?: ResponseType + // 默认传递的参数 + defaultParams?: Recordable + props?: TableProps +} + +interface TableObject<T = any> { + pageSize: number + currentPage: number + total: number + tableList: T[] + params: any + loading: boolean + exportLoading: boolean + currentRow: Nullable<T> +} + +export const useTable = <T = any>(config?: UseTableConfig<T>) => { + const tableObject = reactive<TableObject<T>>({ + // 页数 + pageSize: 10, + // 当前页 + currentPage: 1, + // 总条数 + total: 10, + // 表格数据 + tableList: [], + // AxiosConfig 配置 + params: { + ...(config?.defaultParams || {}) + }, + // 加载中 + loading: true, + // 导出加载中 + exportLoading: false, + // 当前行的数据 + currentRow: null + }) + + const paramsObj = computed(() => { + return { + ...tableObject.params, + pageSize: tableObject.pageSize, + pageNo: tableObject.currentPage + } + }) + + watch( + () => tableObject.currentPage, + () => { + methods.getList() + } + ) + + watch( + () => tableObject.pageSize, + () => { + // 当前页不为1时,修改页数后会导致多次调用getList方法 + if (tableObject.currentPage === 1) { + methods.getList() + } else { + tableObject.currentPage = 1 + methods.getList() + } + } + ) + + // Table实例 + const tableRef = ref<typeof Table & TableExpose>() + + // ElTable实例 + const elTableRef = ref<ComponentRef<typeof ElTable>>() + + const register = (ref: typeof Table & TableExpose, elRef: ComponentRef<typeof ElTable>) => { + tableRef.value = ref + elTableRef.value = elRef + } + + const getTable = async () => { + await nextTick() + const table = unref(tableRef) + if (!table) { + console.error('The table is not registered. Please use the register method to register') + } + return table + } + + const delData = async (ids: string | number | string[] | number[]) => { + let idsLength = 1 + if (ids instanceof Array) { + idsLength = ids.length + await Promise.all( + ids.map(async (id: string | number) => { + await (config?.delListApi && config?.delListApi(id)) + }) + ) + } else { + await (config?.delListApi && config?.delListApi(ids)) + } + ElMessage.success(t('common.delSuccess')) + + // 计算出临界点 + tableObject.currentPage = + tableObject.total % tableObject.pageSize === idsLength || tableObject.pageSize === 1 + ? tableObject.currentPage > 1 + ? tableObject.currentPage - 1 + : tableObject.currentPage + : tableObject.currentPage + await methods.getList() + } + + const methods = { + getList: async () => { + tableObject.loading = true + const res = await config?.getListApi(unref(paramsObj)).finally(() => { + tableObject.loading = false + }) + if (res) { + tableObject.tableList = (res as unknown as ResponseType).list + tableObject.total = (res as unknown as ResponseType).total ?? 0 + } + }, + setProps: async (props: TableProps = {}) => { + const table = await getTable() + table?.setProps(props) + }, + setColumn: async (columnProps: TableSetPropsType[]) => { + const table = await getTable() + table?.setColumn(columnProps) + }, + getSelections: async () => { + const table = await getTable() + return (table?.selections || []) as T[] + }, + // 与Search组件结合 + setSearchParams: (data: Recordable) => { + tableObject.params = Object.assign(tableObject.params, { + pageSize: tableObject.pageSize, + pageNo: 1, + ...data + }) + // 页码不等于1时更新页码重新获取数据,页码等于1时重新获取数据 + if (tableObject.currentPage !== 1) { + tableObject.currentPage = 1 + } else { + methods.getList() + } + }, + // 删除数据 + delList: async ( + ids: string | number | string[] | number[], + multiple: boolean, + message = true + ) => { + const tableRef = await getTable() + if (multiple) { + if (!tableRef?.selections.length) { + ElMessage.warning(t('common.delNoData')) + return + } + } + if (message) { + ElMessageBox.confirm(t('common.delMessage'), t('common.confirmTitle'), { + confirmButtonText: t('common.ok'), + cancelButtonText: t('common.cancel'), + type: 'warning' + }).then(async () => { + await delData(ids) + }) + } else { + await delData(ids) + } + }, + // 导出列表 + exportList: async (fileName: string) => { + tableObject.exportLoading = true + ElMessageBox.confirm(t('common.exportMessage'), t('common.confirmTitle'), { + confirmButtonText: t('common.ok'), + cancelButtonText: t('common.cancel'), + type: 'warning' + }) + .then(async () => { + const res = await config?.exportListApi?.(unref(paramsObj) as unknown as T) + if (res) { + download.excel(res as unknown as Blob, fileName) + } + }) + .finally(() => { + tableObject.exportLoading = false + }) + } + } + + config?.props && methods.setProps(config.props) + + return { + register, + elTableRef, + tableObject, + methods, + // add by 芋艿:返回 tableMethods 属性,和 tableObject 更统一 + tableMethods: methods + } +} diff --git a/src/hooks/web/useTagsView.ts b/src/hooks/web/useTagsView.ts new file mode 100644 index 0000000..31eadb0 --- /dev/null +++ b/src/hooks/web/useTagsView.ts @@ -0,0 +1,63 @@ +import { useTagsViewStoreWithOut } from '@/store/modules/tagsView' +import { RouteLocationNormalizedLoaded, useRouter } from 'vue-router' +import { computed, nextTick, unref } from 'vue' + +export const useTagsView = () => { + const tagsViewStore = useTagsViewStoreWithOut() + + const { replace, currentRoute } = useRouter() + + const selectedTag = computed(() => tagsViewStore.getSelectedTag) + + const closeAll = (callback?: Fn) => { + tagsViewStore.delAllViews() + callback?.() + } + + const closeLeft = (callback?: Fn) => { + tagsViewStore.delLeftViews(unref(selectedTag) as RouteLocationNormalizedLoaded) + callback?.() + } + + const closeRight = (callback?: Fn) => { + tagsViewStore.delRightViews(unref(selectedTag) as RouteLocationNormalizedLoaded) + callback?.() + } + + const closeOther = (callback?: Fn) => { + tagsViewStore.delOthersViews(unref(selectedTag) as RouteLocationNormalizedLoaded) + callback?.() + } + + const closeCurrent = (view?: RouteLocationNormalizedLoaded, callback?: Fn) => { + if (view?.meta?.affix) return + tagsViewStore.delView(view || unref(currentRoute)) + + callback?.() + } + + const refreshPage = async (view?: RouteLocationNormalizedLoaded, callback?: Fn) => { + tagsViewStore.delCachedView() + const { path, query } = view || unref(currentRoute) + await nextTick() + replace({ + path: '/redirect' + path, + query: query + }) + callback?.() + } + + const setTitle = (title: string, path?: string) => { + tagsViewStore.setTitle(title, path) + } + + return { + closeAll, + closeLeft, + closeRight, + closeOther, + closeCurrent, + refreshPage, + setTitle + } +} diff --git a/src/hooks/web/useTimeAgo.ts b/src/hooks/web/useTimeAgo.ts new file mode 100644 index 0000000..a6da281 --- /dev/null +++ b/src/hooks/web/useTimeAgo.ts @@ -0,0 +1,49 @@ +import { useTimeAgo as useTimeAgoCore, UseTimeAgoMessages } from '@vueuse/core' +import { useLocaleStoreWithOut } from '@/store/modules/locale' + +const TIME_AGO_MESSAGE_MAP: { + 'zh-CN': UseTimeAgoMessages + en: UseTimeAgoMessages +} = { + // @ts-ignore + 'zh-CN': { + justNow: '刚刚', + past: (n) => (n.match(/\d/) ? `${n}前` : n), + future: (n) => (n.match(/\d/) ? `${n}后` : n), + month: (n, past) => (n === 1 ? (past ? '上个月' : '下个月') : `${n} 个月`), + year: (n, past) => (n === 1 ? (past ? '去年' : '明年') : `${n} 年`), + day: (n, past) => (n === 1 ? (past ? '昨天' : '明天') : `${n} 天`), + week: (n, past) => (n === 1 ? (past ? '上周' : '下周') : `${n} 周`), + hour: (n) => `${n} 小时`, + minute: (n) => `${n} 分钟`, + second: (n) => `${n} 秒` + }, + // @ts-ignore + en: { + justNow: 'just now', + past: (n) => (n.match(/\d/) ? `${n} ago` : n), + future: (n) => (n.match(/\d/) ? `in ${n}` : n), + month: (n, past) => + n === 1 ? (past ? 'last month' : 'next month') : `${n} month${n > 1 ? 's' : ''}`, + year: (n, past) => + n === 1 ? (past ? 'last year' : 'next year') : `${n} year${n > 1 ? 's' : ''}`, + day: (n, past) => (n === 1 ? (past ? 'yesterday' : 'tomorrow') : `${n} day${n > 1 ? 's' : ''}`), + week: (n, past) => + n === 1 ? (past ? 'last week' : 'next week') : `${n} week${n > 1 ? 's' : ''}`, + hour: (n) => `${n} hour${n > 1 ? 's' : ''}`, + minute: (n) => `${n} minute${n > 1 ? 's' : ''}`, + second: (n) => `${n} second${n > 1 ? 's' : ''}` + } +} + +export const useTimeAgo = (time: Date | number | string) => { + const localeStore = useLocaleStoreWithOut() + + const currentLocale = computed(() => localeStore.getCurrentLocale) + + const timeAgo = useTimeAgoCore(time, { + messages: TIME_AGO_MESSAGE_MAP[unref(currentLocale).lang] + }) + + return timeAgo +} diff --git a/src/hooks/web/useTitle.ts b/src/hooks/web/useTitle.ts new file mode 100644 index 0000000..020a9b7 --- /dev/null +++ b/src/hooks/web/useTitle.ts @@ -0,0 +1,24 @@ +import { watch, ref } from 'vue' +import { isString } from '@/utils/is' +import { useAppStoreWithOut } from '@/store/modules/app' + +const appStore = useAppStoreWithOut() + +export const useTitle = (newTitle?: string) => { + const { t } = useI18n() + const title = ref( + newTitle ? `${appStore.getTitle} - ${t(newTitle as string)}` : appStore.getTitle + ) + + watch( + title, + (n, o) => { + if (isString(n) && n !== o && document) { + document.title = n + } + }, + { immediate: true } + ) + + return title +} diff --git a/src/hooks/web/useValidator.ts b/src/hooks/web/useValidator.ts new file mode 100644 index 0000000..151e35b --- /dev/null +++ b/src/hooks/web/useValidator.ts @@ -0,0 +1,60 @@ +import { useI18n } from '@/hooks/web/useI18n' +import { FormItemRule } from 'element-plus' + +const { t } = useI18n() + +interface LengthRange { + min: number + max: number + message?: string +} + +export const useValidator = () => { + const required = (message?: string): FormItemRule => { + return { + required: true, + message: message || t('common.required') + } + } + + const lengthRange = (options: LengthRange): FormItemRule => { + const { min, max, message } = options + + return { + min, + max, + message: message || t('common.lengthRange', { min, max }) + } + } + + const notSpace = (message?: string): FormItemRule => { + return { + validator: (_, val, callback) => { + if (val?.indexOf(' ') !== -1) { + callback(new Error(message || t('common.notSpace'))) + } else { + callback() + } + } + } + } + + const notSpecialCharacters = (message?: string): FormItemRule => { + return { + validator: (_, val, callback) => { + if (/[`~!@#$%^&*()_+<>?:"{},.\/;'[\]]/gi.test(val)) { + callback(new Error(message || t('common.notSpecialCharacters'))) + } else { + callback() + } + } + } + } + + return { + required, + lengthRange, + notSpace, + notSpecialCharacters + } +} diff --git a/src/hooks/web/useWatermark.ts b/src/hooks/web/useWatermark.ts new file mode 100644 index 0000000..4a31359 --- /dev/null +++ b/src/hooks/web/useWatermark.ts @@ -0,0 +1,55 @@ +const domSymbol = Symbol('watermark-dom') + +export function useWatermark(appendEl: HTMLElement | null = document.body) { + let func: Fn = () => {} + const id = domSymbol.toString() + const clear = () => { + const domId = document.getElementById(id) + if (domId) { + const el = appendEl + el && el.removeChild(domId) + } + window.removeEventListener('resize', func) + } + const createWatermark = (str: string) => { + clear() + + const can = document.createElement('canvas') + can.width = 300 + can.height = 240 + + const cans = can.getContext('2d') + if (cans) { + cans.rotate((-20 * Math.PI) / 120) + cans.font = '15px Vedana' + cans.fillStyle = 'rgba(0, 0, 0, 0.15)' + cans.textAlign = 'left' + cans.textBaseline = 'middle' + cans.fillText(str, can.width / 20, can.height) + } + + const div = document.createElement('div') + div.id = id + div.style.pointerEvents = 'none' + div.style.top = '0px' + div.style.left = '0px' + div.style.position = 'absolute' + div.style.zIndex = '100000000' + div.style.width = document.documentElement.clientWidth + 'px' + div.style.height = document.documentElement.clientHeight + 'px' + div.style.background = 'url(' + can.toDataURL('image/png') + ') left top repeat' + const el = appendEl + el && el.appendChild(div) + return id + } + + function setWatermark(str: string) { + createWatermark(str) + func = () => { + createWatermark(str) + } + window.addEventListener('resize', func) + } + + return { setWatermark, clear } +} diff --git a/src/layout/Layout.vue b/src/layout/Layout.vue new file mode 100644 index 0000000..af51970 --- /dev/null +++ b/src/layout/Layout.vue @@ -0,0 +1,78 @@ +<script lang="tsx"> +import { computed, defineComponent, unref } from 'vue' +import { useAppStore } from '@/store/modules/app' +import { Backtop } from '@/components/Backtop' +import { Setting } from '@/layout/components/Setting' +import { useRenderLayout } from './components/useRenderLayout' +import { useDesign } from '@/hooks/web/useDesign' + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('layout') + +const appStore = useAppStore() + +// 是否是移动端 +const mobile = computed(() => appStore.getMobile) + +// 菜单折叠 +const collapse = computed(() => appStore.getCollapse) + +const layout = computed(() => appStore.getLayout) + +const handleClickOutside = () => { + appStore.setCollapse(true) +} + +const renderLayout = () => { + switch (unref(layout)) { + case 'classic': + const { renderClassic } = useRenderLayout() + return renderClassic() + case 'topLeft': + const { renderTopLeft } = useRenderLayout() + return renderTopLeft() + case 'top': + const { renderTop } = useRenderLayout() + return renderTop() + case 'cutMenu': + const { renderCutMenu } = useRenderLayout() + return renderCutMenu() + default: + break + } +} + +export default defineComponent({ + name: 'Layout', + setup() { + return () => ( + <section class={[prefixCls, `${prefixCls}__${layout.value}`, 'w-[100%] h-[100%] relative']}> + {mobile.value && !collapse.value ? ( + <div + class="absolute left-0 top-0 z-99 h-full w-full bg-[var(--el-color-black)] opacity-30" + onClick={handleClickOutside} + ></div> + ) : undefined} + + {renderLayout()} + + <Backtop></Backtop> + + <Setting></Setting> + </section> + ) + } +}) +</script> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-layout; + +.#{$prefix-cls} { + background-color: var(--app-content-bg-color); + :deep(.#{$elNamespace}-scrollbar__view) { + height: 99% !important; + } +} +</style> diff --git a/src/layout/components/AppView.vue b/src/layout/components/AppView.vue new file mode 100644 index 0000000..4434187 --- /dev/null +++ b/src/layout/components/AppView.vue @@ -0,0 +1,72 @@ +<script lang="ts" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import { useAppStore } from '@/store/modules/app' +import { Footer } from '@/layout/components/Footer' + +defineOptions({ name: 'AppView' }) + +const appStore = useAppStore() + +const layout = computed(() => appStore.getLayout) + +const fixedHeader = computed(() => appStore.getFixedHeader) + +const footer = computed(() => appStore.getFooter) + +const tagsViewStore = useTagsViewStore() + +const getCaches = computed((): string[] => { + return tagsViewStore.getCachedViews +}) + +const tagsView = computed(() => appStore.getTagsView) + +//region 无感刷新 +const routerAlive = ref(true) +// 无感刷新,防止出现页面闪烁白屏 +const reload = () => { + routerAlive.value = false + nextTick(() => (routerAlive.value = true)) +} +// 为组件后代提供刷新方法 +provide('reload', reload) +//endregion +</script> + +<template> + <section + :class="[ + 'p-[var(--app-content-padding)] w-[calc(100%-var(--app-content-padding)-var(--app-content-padding))] bg-[var(--app-content-bg-color)] dark:bg-[var(--el-bg-color)]', + { + '!min-h-[calc(100%-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height))]': + (fixedHeader && + (layout === 'classic' || layout === 'topLeft' || layout === 'top') && + footer) || + (!tagsView && layout === 'top' && footer), + '!min-h-[calc(100%-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height)-var(--tags-view-height))]': + tagsView && layout === 'top' && footer, + + '!min-h-[calc(100%-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-var(--top-tool-height)-var(--app-footer-height))]': + !fixedHeader && layout === 'classic' && footer, + + '!min-h-[calc(100%-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height))]': + !fixedHeader && layout === 'topLeft' && footer, + + '!min-h-[calc(100%-var(--top-tool-height)-var(--app-content-padding)-var(--app-content-padding))]': + fixedHeader && layout === 'cutMenu' && footer, + + '!min-h-[calc(100%-var(--top-tool-height)-var(--app-content-padding)-var(--app-content-padding)-var(--tags-view-height))]': + !fixedHeader && layout === 'cutMenu' && footer + } + ]" + > + <router-view v-if="routerAlive"> + <template #default="{ Component, route }"> + <keep-alive :include="getCaches"> + <component :is="Component" :key="route.fullPath" /> + </keep-alive> + </template> + </router-view> + </section> + <Footer v-if="footer" /> +</template> diff --git a/src/layout/components/Breadcrumb/index.ts b/src/layout/components/Breadcrumb/index.ts new file mode 100644 index 0000000..93ffe70 --- /dev/null +++ b/src/layout/components/Breadcrumb/index.ts @@ -0,0 +1,3 @@ +import Breadcrumb from './src/Breadcrumb.vue' + +export { Breadcrumb } diff --git a/src/layout/components/Breadcrumb/src/Breadcrumb.vue b/src/layout/components/Breadcrumb/src/Breadcrumb.vue new file mode 100644 index 0000000..4079a06 --- /dev/null +++ b/src/layout/components/Breadcrumb/src/Breadcrumb.vue @@ -0,0 +1,130 @@ +<script lang="tsx"> +import { ElBreadcrumb, ElBreadcrumbItem } from 'element-plus' +import { ref, watch, computed, unref, defineComponent, TransitionGroup } from 'vue' +import { useRouter } from 'vue-router' +import { usePermissionStore } from '@/store/modules/permission' +import { filterBreadcrumb } from './helper' +import { filter, treeToList } from '@/utils/tree' +import type { RouteLocationNormalizedLoaded, RouteMeta } from 'vue-router' + +import { Icon } from '@/components/Icon' +import { useAppStore } from '@/store/modules/app' +import { useDesign } from '@/hooks/web/useDesign' + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('breadcrumb') + +const appStore = useAppStore() + +// 面包屑图标 +const breadcrumbIcon = computed(() => appStore.getBreadcrumbIcon) + +export default defineComponent({ + name: 'Breadcrumb', + setup() { + const { currentRoute } = useRouter() + + const { t } = useI18n() + + const levelList = ref<AppRouteRecordRaw[]>([]) + + const permissionStore = usePermissionStore() + + const menuRouters = computed(() => { + const routers = permissionStore.getRouters + return filterBreadcrumb(routers) + }) + + const getBreadcrumb = () => { + const currentPath = currentRoute.value.matched.slice(-1)[0].path + + levelList.value = filter<AppRouteRecordRaw>(unref(menuRouters), (node: AppRouteRecordRaw) => { + return node.path === currentPath + }) + } + + const renderBreadcrumb = () => { + const breadcrumbList = treeToList<AppRouteRecordRaw[]>(unref(levelList)) + return breadcrumbList.map((v) => { + const disabled = !v.redirect || v.redirect === 'noredirect' + const meta = v.meta as RouteMeta + return ( + <ElBreadcrumbItem to={{ path: disabled ? '' : v.path }} key={v.name}> + {meta?.icon && breadcrumbIcon.value ? ( + <div class="flex items-center"> + <Icon icon={meta.icon} class="mr-[2px]" svgClass="inline-block"></Icon> + {t(v?.meta?.title)} + </div> + ) : ( + t(v?.meta?.title) + )} + </ElBreadcrumbItem> + ) + }) + } + + watch( + () => currentRoute.value, + (route: RouteLocationNormalizedLoaded) => { + if (route.path.startsWith('/redirect/')) { + return + } + getBreadcrumb() + }, + { + immediate: true + } + ) + + return () => ( + <ElBreadcrumb separator="/" class={`${prefixCls} flex items-center h-full ml-[10px]`}> + <TransitionGroup appear enter-active-class="animate__animated animate__fadeInRight"> + {renderBreadcrumb()} + </TransitionGroup> + </ElBreadcrumb> + ) + } +}) +</script> + +<style lang="scss" scoped> +$prefix-cls: #{$elNamespace}-breadcrumb; + +.#{$prefix-cls} { + :deep(&__item) { + display: flex; + .#{$prefix-cls}__inner { + display: flex; + align-items: center; + color: var(--top-header-text-color); + + &:hover { + color: var(--el-color-primary); + } + } + } + + :deep(&__item):not(:last-child) { + .#{$prefix-cls}__inner { + color: var(--top-header-text-color); + + &:hover { + color: var(--el-color-primary); + } + } + } + + :deep(&__item):last-child { + .#{$prefix-cls}__inner { + display: flex; + align-items: center; + color: var(--el-text-color-placeholder); + + &:hover { + color: var(--el-text-color-placeholder); + } + } + } +} +</style> diff --git a/src/layout/components/Breadcrumb/src/helper.ts b/src/layout/components/Breadcrumb/src/helper.ts new file mode 100644 index 0000000..fb3ec19 --- /dev/null +++ b/src/layout/components/Breadcrumb/src/helper.ts @@ -0,0 +1,31 @@ +import { pathResolve } from '@/utils/routerHelper' +import type { RouteMeta } from 'vue-router' + +export const filterBreadcrumb = ( + routes: AppRouteRecordRaw[], + parentPath = '' +): AppRouteRecordRaw[] => { + const res: AppRouteRecordRaw[] = [] + + for (const route of routes) { + const meta = route?.meta as RouteMeta + if (meta.hidden && !meta.canTo) { + continue + } + + const data: AppRouteRecordRaw = + !meta.alwaysShow && route.children?.length === 1 + ? { ...route.children[0], path: pathResolve(route.path, route.children[0].path) } + : { ...route } + + data.path = pathResolve(parentPath, data.path) + + if (data.children) { + data.children = filterBreadcrumb(data.children, data.path) + } + if (data) { + res.push(data) + } + } + return res +} diff --git a/src/layout/components/Collapse/index.ts b/src/layout/components/Collapse/index.ts new file mode 100644 index 0000000..73f65a3 --- /dev/null +++ b/src/layout/components/Collapse/index.ts @@ -0,0 +1,3 @@ +import Collapse from './src/Collapse.vue' + +export { Collapse } diff --git a/src/layout/components/Collapse/src/Collapse.vue b/src/layout/components/Collapse/src/Collapse.vue new file mode 100644 index 0000000..a8fc7ee --- /dev/null +++ b/src/layout/components/Collapse/src/Collapse.vue @@ -0,0 +1,35 @@ +<script lang="ts" setup> +import { useAppStore } from '@/store/modules/app' +import { propTypes } from '@/utils/propTypes' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'Collapse' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('collapse') + +defineProps({ + color: propTypes.string.def('') +}) + +const appStore = useAppStore() + +const collapse = computed(() => appStore.getCollapse) + +const toggleCollapse = () => { + const collapsed = unref(collapse) + appStore.setCollapse(!collapsed) +} +</script> + +<template> + <div :class="prefixCls" @click="toggleCollapse"> + <Icon + :color="color" + :icon="collapse ? 'ep:expand' : 'ep:fold'" + :size="18" + class="cursor-pointer" + /> + </div> +</template> diff --git a/src/layout/components/ContextMenu/index.ts b/src/layout/components/ContextMenu/index.ts new file mode 100644 index 0000000..2a7c1f0 --- /dev/null +++ b/src/layout/components/ContextMenu/index.ts @@ -0,0 +1,10 @@ +import ContextMenu from './src/ContextMenu.vue' +import { ElDropdown } from 'element-plus' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +export interface ContextMenuExpose { + elDropdownMenuRef: ComponentRef<typeof ElDropdown> + tagItem: RouteLocationNormalizedLoaded +} + +export { ContextMenu } diff --git a/src/layout/components/ContextMenu/src/ContextMenu.vue b/src/layout/components/ContextMenu/src/ContextMenu.vue new file mode 100644 index 0000000..90eea4c --- /dev/null +++ b/src/layout/components/ContextMenu/src/ContextMenu.vue @@ -0,0 +1,76 @@ +<script lang="ts" setup> +import { PropType } from 'vue' + +import { useDesign } from '@/hooks/web/useDesign' +import type { RouteLocationNormalizedLoaded } from 'vue-router' +import { contextMenuSchema } from '@/types/contextMenu' +import type { ElDropdown } from 'element-plus' + +defineOptions({ name: 'ContextMenu' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('context-menu') + +const { t } = useI18n() + +const emit = defineEmits(['visibleChange']) + +const props = defineProps({ + schema: { + type: Array as PropType<contextMenuSchema[]>, + default: () => [] + }, + trigger: { + type: String as PropType<'click' | 'hover' | 'focus' | 'contextmenu'>, + default: 'contextmenu' + }, + tagItem: { + type: Object as PropType<RouteLocationNormalizedLoaded>, + default: () => ({}) + } +}) + +const command = (item: contextMenuSchema) => { + item.command && item.command(item) +} + +const visibleChange = (visible: boolean) => { + emit('visibleChange', visible, props.tagItem) +} + +const elDropdownMenuRef = ref<ComponentRef<typeof ElDropdown>>() + +defineExpose({ + elDropdownMenuRef, + tagItem: props.tagItem +}) +</script> + +<template> + <ElDropdown + ref="elDropdownMenuRef" + :class="prefixCls" + :trigger="trigger" + placement="bottom-start" + popper-class="v-context-menu-popper" + @command="command" + @visible-change="visibleChange" + > + <slot></slot> + <template #dropdown> + <ElDropdownMenu> + <ElDropdownItem + v-for="(item, index) in schema" + :key="`dropdown${index}`" + :command="item" + :disabled="item.disabled" + :divided="item.divided" + > + <Icon :icon="item.icon" /> + {{ t(item.label) }} + </ElDropdownItem> + </ElDropdownMenu> + </template> + </ElDropdown> +</template> diff --git a/src/layout/components/Footer/index.ts b/src/layout/components/Footer/index.ts new file mode 100644 index 0000000..bd052e0 --- /dev/null +++ b/src/layout/components/Footer/index.ts @@ -0,0 +1,3 @@ +import Footer from './src/Footer.vue' + +export { Footer } diff --git a/src/layout/components/Footer/src/Footer.vue b/src/layout/components/Footer/src/Footer.vue new file mode 100644 index 0000000..3eede38 --- /dev/null +++ b/src/layout/components/Footer/src/Footer.vue @@ -0,0 +1,24 @@ +<script lang="ts" setup> +import { useAppStore } from '@/store/modules/app' +import { useDesign } from '@/hooks/web/useDesign' + +// eslint-disable-next-line vue/no-reserved-component-names +defineOptions({ name: 'Footer' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('footer') + +const appStore = useAppStore() + +const title = computed(() => appStore.getTitle) +</script> + +<template> + <div + :class="prefixCls" + class="h-[var(--app-footer-height)] bg-[var(--app-content-bg-color)] text-center leading-[var(--app-footer-height)] text-[var(--el-text-color-placeholder)] dark:bg-[var(--el-bg-color)]" + > + <span class="text-14px">Copyright ©2022-{{ title }}</span> + </div> +</template> diff --git a/src/layout/components/LocaleDropdown/index.ts b/src/layout/components/LocaleDropdown/index.ts new file mode 100644 index 0000000..d02e640 --- /dev/null +++ b/src/layout/components/LocaleDropdown/index.ts @@ -0,0 +1,3 @@ +import LocaleDropdown from './src/LocaleDropdown.vue' + +export { LocaleDropdown } diff --git a/src/layout/components/LocaleDropdown/src/LocaleDropdown.vue b/src/layout/components/LocaleDropdown/src/LocaleDropdown.vue new file mode 100644 index 0000000..95132db --- /dev/null +++ b/src/layout/components/LocaleDropdown/src/LocaleDropdown.vue @@ -0,0 +1,52 @@ +<script lang="ts" setup> +import { useLocaleStore } from '@/store/modules/locale' +import { useLocale } from '@/hooks/web/useLocale' +import { propTypes } from '@/utils/propTypes' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'LocaleDropdown' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('locale-dropdown') + +defineProps({ + color: propTypes.string.def('') +}) + +const localeStore = useLocaleStore() + +const langMap = computed(() => localeStore.getLocaleMap) + +const currentLang = computed(() => localeStore.getCurrentLocale) + +const setLang = (lang: LocaleType) => { + if (lang === unref(currentLang).lang) return + // 需要重新加载页面让整个语言多初始化 + window.location.reload() + localeStore.setCurrentLocale({ + lang + }) + const { changeLocale } = useLocale() + changeLocale(lang) +} +</script> + +<template> + <ElDropdown :class="prefixCls" trigger="click" @command="setLang"> + <Icon + :class="$attrs.class" + :color="color" + :size="18" + class="cursor-pointer !p-0" + icon="ion:language-sharp" + /> + <template #dropdown> + <ElDropdownMenu> + <ElDropdownItem v-for="item in langMap" :key="item.lang" :command="item.lang"> + {{ item.name }} + </ElDropdownItem> + </ElDropdownMenu> + </template> + </ElDropdown> +</template> diff --git a/src/layout/components/Logo/index.ts b/src/layout/components/Logo/index.ts new file mode 100644 index 0000000..1c4224c --- /dev/null +++ b/src/layout/components/Logo/index.ts @@ -0,0 +1,3 @@ +import Logo from './src/Logo.vue' + +export { Logo } diff --git a/src/layout/components/Logo/src/Logo.vue b/src/layout/components/Logo/src/Logo.vue new file mode 100644 index 0000000..d241130 --- /dev/null +++ b/src/layout/components/Logo/src/Logo.vue @@ -0,0 +1,88 @@ +<script lang="ts" setup> +import { computed, onMounted, ref, unref, watch } from 'vue' +import { useAppStore } from '@/store/modules/app' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'Logo' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('logo') + +const appStore = useAppStore() + +const show = ref(true) + +const title = computed(() => appStore.getTitle) + +const layout = computed(() => appStore.getLayout) + +const collapse = computed(() => appStore.getCollapse) + +onMounted(() => { + if (unref(collapse)) show.value = false +}) + +watch( + () => collapse.value, + (collapse: boolean) => { + if (unref(layout) === 'topLeft' || unref(layout) === 'cutMenu') { + show.value = true + return + } + if (!collapse) { + setTimeout(() => { + show.value = !collapse + }, 400) + } else { + show.value = !collapse + } + } +) + +watch( + () => layout.value, + (layout) => { + if (layout === 'top' || layout === 'cutMenu') { + show.value = true + } else { + if (unref(collapse)) { + show.value = false + } else { + show.value = true + } + } + } +) +</script> + +<template> + <div> + <router-link + :class="[ + prefixCls, + layout !== 'classic' ? `${prefixCls}__Top` : '', + 'flex !h-[var(--logo-height)] items-center cursor-pointer pl-8px relative decoration-none overflow-hidden' + ]" + to="/" + > + <img + class="h-[calc(var(--logo-height)-10px)] w-[calc(var(--logo-height)-10px)]" + src="@/assets/imgs/logo.png" + /> + <div + v-if="show" + :class="[ + 'ml-10px text-16px font-700', + { + 'text-[var(--logo-title-text-color)]': layout === 'classic', + 'text-[var(--top-header-text-color)]': + layout === 'topLeft' || layout === 'top' || layout === 'cutMenu' + } + ]" + > + {{ title }} + </div> + </router-link> + </div> +</template> diff --git a/src/layout/components/Menu/index.ts b/src/layout/components/Menu/index.ts new file mode 100644 index 0000000..a6ec696 --- /dev/null +++ b/src/layout/components/Menu/index.ts @@ -0,0 +1,3 @@ +import Menu from './src/Menu.vue' + +export { Menu } diff --git a/src/layout/components/Menu/src/Menu.vue b/src/layout/components/Menu/src/Menu.vue new file mode 100644 index 0000000..77f9a30 --- /dev/null +++ b/src/layout/components/Menu/src/Menu.vue @@ -0,0 +1,262 @@ +<script lang="tsx"> +import { PropType } from 'vue' +import { ElMenu, ElScrollbar } from 'element-plus' +import { useAppStore } from '@/store/modules/app' +import { usePermissionStore } from '@/store/modules/permission' +import { useRenderMenuItem } from './components/useRenderMenuItem' +import { isUrl } from '@/utils/is' +import { useDesign } from '@/hooks/web/useDesign' +import { LayoutType } from '@/types/layout' + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('menu') + +export default defineComponent({ + // eslint-disable-next-line vue/no-reserved-component-names + name: 'Menu', + props: { + menuSelect: { + type: Function as PropType<(index: string) => void>, + default: undefined + } + }, + setup(props) { + const appStore = useAppStore() + + const layout = computed(() => appStore.getLayout) + + const { push, currentRoute } = useRouter() + + const permissionStore = usePermissionStore() + + const menuMode = computed((): 'vertical' | 'horizontal' => { + // 竖 + const vertical: LayoutType[] = ['classic', 'topLeft', 'cutMenu'] + + if (vertical.includes(unref(layout))) { + return 'vertical' + } else { + return 'horizontal' + } + }) + + const routers = computed(() => + unref(layout) === 'cutMenu' ? permissionStore.getMenuTabRouters : permissionStore.getRouters + ) + + const collapse = computed(() => appStore.getCollapse) + + const uniqueOpened = computed(() => appStore.getUniqueOpened) + + const activeMenu = computed(() => { + const { meta, path } = unref(currentRoute) + // if set path, the sidebar will highlight the path you set + if (meta.activeMenu) { + return meta.activeMenu as string + } + return path + }) + + const menuSelect = (index: string) => { + if (props.menuSelect) { + props.menuSelect(index) + } + // 自定义事件 + if (isUrl(index)) { + window.open(index) + } else { + push(index) + } + } + + const renderMenuWrap = () => { + if (unref(layout) === 'top') { + return renderMenu() + } else { + return <ElScrollbar>{renderMenu()}</ElScrollbar> + } + } + + const renderMenu = () => { + return ( + <ElMenu + defaultActive={unref(activeMenu)} + mode={unref(menuMode)} + collapse={ + unref(layout) === 'top' || unref(layout) === 'cutMenu' ? false : unref(collapse) + } + uniqueOpened={unref(layout) === 'top' ? false : unref(uniqueOpened)} + backgroundColor="var(--left-menu-bg-color)" + textColor="var(--left-menu-text-color)" + activeTextColor="var(--left-menu-text-active-color)" + popperClass={ + unref(menuMode) === 'vertical' + ? `${prefixCls}-popper--vertical` + : `${prefixCls}-popper--horizontal` + } + onSelect={menuSelect} + > + {{ + default: () => { + const { renderMenuItem } = useRenderMenuItem(unref(menuMode)) + return renderMenuItem(unref(routers)) + } + }} + </ElMenu> + ) + } + + return () => ( + <div + id={prefixCls} + class={[ + `${prefixCls} ${prefixCls}__${unref(menuMode)}`, + 'h-[100%] overflow-hidden flex-col bg-[var(--left-menu-bg-color)]', + { + 'w-[var(--left-menu-min-width)]': unref(collapse) && unref(layout) !== 'cutMenu', + 'w-[var(--left-menu-max-width)]': !unref(collapse) && unref(layout) !== 'cutMenu' + } + ]} + > + {renderMenuWrap()} + </div> + ) + } +}) +</script> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-menu; + +.#{$prefix-cls} { + position: relative; + transition: width var(--transition-time-02); + + :deep(.#{$elNamespace}-menu) { + width: 100% !important; + border-right: none; + + // 设置选中时子标题的颜色 + .is-active { + & > .#{$elNamespace}-sub-menu__title { + color: var(--left-menu-text-active-color) !important; + } + } + + // 设置子菜单悬停的高亮和背景色 + .#{$elNamespace}-sub-menu__title, + .#{$elNamespace}-menu-item { + &:hover { + color: var(--left-menu-text-active-color) !important; + background-color: var(--left-menu-bg-color) !important; + } + } + + // 设置选中时的高亮背景和高亮颜色 + .#{$elNamespace}-menu-item.is-active { + color: var(--left-menu-text-active-color) !important; + background-color: var(--left-menu-bg-active-color) !important; + + &:hover { + background-color: var(--left-menu-bg-active-color) !important; + } + } + + .#{$elNamespace}-menu-item.is-active { + position: relative; + } + + // 设置子菜单的背景颜色 + .#{$elNamespace}-menu { + .#{$elNamespace}-sub-menu__title, + .#{$elNamespace}-menu-item:not(.is-active) { + background-color: var(--left-menu-bg-light-color) !important; + } + } + } + + // 折叠时的最小宽度 + :deep(.#{$elNamespace}-menu--collapse) { + width: var(--left-menu-min-width); + + & > .is-active, + & > .is-active > .#{$elNamespace}-sub-menu__title { + position: relative; + background-color: var(--left-menu-collapse-bg-active-color) !important; + } + } + + // 折叠动画的时候,就需要把文字给隐藏掉 + :deep(.horizontal-collapse-transition) { + // transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out !important; + .#{$prefix-cls}__title { + display: none; + } + } + + // 水平菜单 + &__horizontal { + height: calc(var(--top-tool-height)) !important; + + :deep(.#{$elNamespace}-menu--horizontal) { + height: calc(var(--top-tool-height)); + border-bottom: none; + // 重新设置底部高亮颜色 + & > .#{$elNamespace}-sub-menu.is-active { + .#{$elNamespace}-sub-menu__title { + border-bottom-color: var(--el-color-primary) !important; + } + } + + .#{$elNamespace}-menu-item.is-active { + position: relative; + + &::after { + display: none !important; + } + } + + .#{$prefix-cls}__title { + /* stylelint-disable-next-line */ + max-height: calc(var(--top-tool-height) - 2px) !important; + /* stylelint-disable-next-line */ + line-height: calc(var(--top-tool-height) - 2px); + } + } + } +} +</style> + +<style lang="scss"> +$prefix-cls: #{$namespace}-menu-popper; + +.#{$prefix-cls}--vertical, +.#{$prefix-cls}--horizontal { + // 设置选中时子标题的颜色 + .is-active { + & > .el-sub-menu__title { + color: var(--left-menu-text-active-color) !important; + } + } + + // 设置子菜单悬停的高亮和背景色 + .el-sub-menu__title, + .el-menu-item { + &:hover { + color: var(--left-menu-text-active-color) !important; + background-color: var(--left-menu-bg-color) !important; + } + } + + // 设置选中时的高亮背景 + .el-menu-item.is-active { + position: relative; + background-color: var(--left-menu-bg-active-color) !important; + + &:hover { + background-color: var(--left-menu-bg-active-color) !important; + } + } +} +</style> diff --git a/src/layout/components/Menu/src/components/useRenderMenuItem.tsx b/src/layout/components/Menu/src/components/useRenderMenuItem.tsx new file mode 100644 index 0000000..301313f --- /dev/null +++ b/src/layout/components/Menu/src/components/useRenderMenuItem.tsx @@ -0,0 +1,50 @@ +import { ElSubMenu, ElMenuItem } from 'element-plus' +import { hasOneShowingChild } from '../helper' +import { isUrl } from '@/utils/is' +import { useRenderMenuTitle } from './useRenderMenuTitle' +import { pathResolve } from '@/utils/routerHelper' + +const { renderMenuTitle } = useRenderMenuTitle() + +export const useRenderMenuItem = () => + // allRouters: AppRouteRecordRaw[] = [], + { + const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/') => { + return routers + .filter((v) => !v.meta?.hidden) + .map((v) => { + const meta = v.meta ?? {} + const { oneShowingChild, onlyOneChild } = hasOneShowingChild(v.children, v) + const fullPath = isUrl(v.path) ? v.path : pathResolve(parentPath, v.path) // getAllParentPath<AppRouteRecordRaw>(allRouters, v.path).join('/') + + if ( + oneShowingChild && + (!onlyOneChild?.children || onlyOneChild?.noShowingChildren) && + !meta?.alwaysShow + ) { + return ( + <ElMenuItem + index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath} + > + {{ + default: () => renderMenuTitle(onlyOneChild ? onlyOneChild?.meta : meta) + }} + </ElMenuItem> + ) + } else { + return ( + <ElSubMenu index={fullPath}> + {{ + title: () => renderMenuTitle(meta), + default: () => renderMenuItem(v.children!, fullPath) + }} + </ElSubMenu> + ) + } + }) + } + + return { + renderMenuItem + } + } diff --git a/src/layout/components/Menu/src/components/useRenderMenuTitle.tsx b/src/layout/components/Menu/src/components/useRenderMenuTitle.tsx new file mode 100644 index 0000000..8941d9d --- /dev/null +++ b/src/layout/components/Menu/src/components/useRenderMenuTitle.tsx @@ -0,0 +1,27 @@ +import type { RouteMeta } from 'vue-router' +import { Icon } from '@/components/Icon' +import { useI18n } from '@/hooks/web/useI18n' + +export const useRenderMenuTitle = () => { + const renderMenuTitle = (meta: RouteMeta) => { + const { t } = useI18n() + const { title = 'Please set title', icon } = meta + + return icon ? ( + <> + <Icon icon={meta.icon}></Icon> + <span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap"> + {t(title as string)} + </span> + </> + ) : ( + <span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap"> + {t(title as string)} + </span> + ) + } + + return { + renderMenuTitle + } +} diff --git a/src/layout/components/Menu/src/helper.ts b/src/layout/components/Menu/src/helper.ts new file mode 100644 index 0000000..c26f5f4 --- /dev/null +++ b/src/layout/components/Menu/src/helper.ts @@ -0,0 +1,54 @@ +import type { RouteMeta } from 'vue-router' +import { findPath } from '@/utils/tree' + +type OnlyOneChildType = AppRouteRecordRaw & { noShowingChildren?: boolean } + +interface HasOneShowingChild { + oneShowingChild?: boolean + onlyOneChild?: OnlyOneChildType +} + +export const getAllParentPath = <T = Recordable>(treeData: T[], path: string) => { + const menuList = findPath(treeData, (n) => n.path === path) as AppRouteRecordRaw[] + return (menuList || []).map((item) => item.path) +} + +export const hasOneShowingChild = ( + children: AppRouteRecordRaw[] = [], + parent: AppRouteRecordRaw +): HasOneShowingChild => { + const onlyOneChild = ref<OnlyOneChildType>() + + const showingChildren = children.filter((v) => { + const meta = (v.meta ?? {}) as RouteMeta + if (meta.hidden) { + return false + } else { + // Temp set(will be used if only has one showing child) + onlyOneChild.value = v + return true + } + }) + + // When there is only one child router, the child router is displayed by default + if (showingChildren.length === 1) { + return { + oneShowingChild: true, + onlyOneChild: unref(onlyOneChild) + } + } + + // Show parent if there are no child router to display + if (!showingChildren.length) { + onlyOneChild.value = { ...parent, path: '', noShowingChildren: true } + return { + oneShowingChild: true, + onlyOneChild: unref(onlyOneChild) + } + } + + return { + oneShowingChild: false, + onlyOneChild: unref(onlyOneChild) + } +} diff --git a/src/layout/components/Message/index.ts b/src/layout/components/Message/index.ts new file mode 100644 index 0000000..dfe0207 --- /dev/null +++ b/src/layout/components/Message/index.ts @@ -0,0 +1,3 @@ +import Message from './src/Message.vue' + +export { Message } diff --git a/src/layout/components/Message/src/Message.vue b/src/layout/components/Message/src/Message.vue new file mode 100644 index 0000000..6bd7724 --- /dev/null +++ b/src/layout/components/Message/src/Message.vue @@ -0,0 +1,126 @@ +<script lang="ts" setup> +import { formatDate } from '@/utils/formatTime' +import * as NotifyMessageApi from '@/api/system/notify/message' + +defineOptions({ name: 'Message' }) + +const { push } = useRouter() +const activeName = ref('notice') +const unreadCount = ref(0) // 未读消息数量 +const list = ref<any[]>([]) // 消息列表 + +// 获得消息列表 +const getList = async () => { + list.value = await NotifyMessageApi.getUnreadNotifyMessageList() + // 强制设置 unreadCount 为 0,避免小红点因为轮询太慢,不消除 + unreadCount.value = 0 +} + +// 获得未读消息数 +const getUnreadCount = async () => { + NotifyMessageApi.getUnreadNotifyMessageCount().then((data) => { + unreadCount.value = data + }) +} + +// 跳转我的站内信 +const goMyList = () => { + push({ + name: 'MyNotifyMessage' + }) +} + +// ========== 初始化 ========= +onMounted(() => { + // 首次加载小红点 + getUnreadCount() + // 轮询刷新小红点 + setInterval( + () => { + getUnreadCount() + }, + 1000 * 60 * 2 + ) +}) +</script> +<template> + <div class="message"> + <ElPopover :width="400" placement="bottom" trigger="click"> + <template #reference> + <ElBadge :is-dot="unreadCount > 0" class="item"> + <Icon :size="18" class="cursor-pointer" icon="ep:bell" @click="getList" /> + </ElBadge> + </template> + <ElTabs v-model="activeName"> + <ElTabPane label="我的站内信" name="notice"> + <el-scrollbar class="message-list"> + <template v-for="item in list" :key="item.id"> + <div class="message-item"> + <img alt="" class="message-icon" src="@/assets/imgs/avatar.gif" /> + <div class="message-content"> + <span class="message-title"> + {{ item.templateNickname }}:{{ item.templateContent }} + </span> + <span class="message-date"> + {{ formatDate(item.createTime) }} + </span> + </div> + </div> + </template> + </el-scrollbar> + </ElTabPane> + </ElTabs> + <!-- 更多 --> + <div style="margin-top: 10px; text-align: right"> + <XButton preIcon="ep:view" title="查看全部" type="primary" @click="goMyList" /> + </div> + </ElPopover> + </div> +</template> +<style lang="scss" scoped> +.message-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 260px; + line-height: 45px; +} + +.message-list { + display: flex; + height: 400px; + flex-direction: column; + + .message-item { + display: flex; + align-items: center; + padding: 20px 0; + border-bottom: 1px solid var(--el-border-color-light); + + &:last-child { + border: none; + } + + .message-icon { + width: 40px; + height: 40px; + margin: 0 20px 0 5px; + } + + .message-content { + display: flex; + flex-direction: column; + + .message-title { + margin-bottom: 5px; + } + + .message-date { + font-size: 12px; + color: var(--el-text-color-secondary); + } + } + } +} +</style> diff --git a/src/layout/components/Screenfull/index.ts b/src/layout/components/Screenfull/index.ts new file mode 100644 index 0000000..faec2d8 --- /dev/null +++ b/src/layout/components/Screenfull/index.ts @@ -0,0 +1,3 @@ +import Screenfull from './src/Screenfull.vue' + +export { Screenfull } diff --git a/src/layout/components/Screenfull/src/Screenfull.vue b/src/layout/components/Screenfull/src/Screenfull.vue new file mode 100644 index 0000000..4c045f2 --- /dev/null +++ b/src/layout/components/Screenfull/src/Screenfull.vue @@ -0,0 +1,32 @@ +<script lang="ts" setup> +import { Icon } from '@/components/Icon' +import { useFullscreen } from '@vueuse/core' +import { propTypes } from '@/utils/propTypes' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'ScreenFull' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('screenfull') + +defineProps({ + color: propTypes.string.def('') +}) + +const { toggle, isFullscreen } = useFullscreen() + +const toggleFullscreen = () => { + toggle() +} +</script> + +<template> + <div :class="prefixCls" @click="toggleFullscreen"> + <Icon + :color="color" + :icon="isFullscreen ? 'zmdi:fullscreen-exit' : 'zmdi:fullscreen'" + :size="18" + /> + </div> +</template> diff --git a/src/layout/components/Setting/index.ts b/src/layout/components/Setting/index.ts new file mode 100644 index 0000000..b64c9ad --- /dev/null +++ b/src/layout/components/Setting/index.ts @@ -0,0 +1,3 @@ +import Setting from './src/Setting.vue' + +export { Setting } diff --git a/src/layout/components/Setting/src/Setting.vue b/src/layout/components/Setting/src/Setting.vue new file mode 100644 index 0000000..e1908b6 --- /dev/null +++ b/src/layout/components/Setting/src/Setting.vue @@ -0,0 +1,299 @@ +<script lang="ts" setup> +import { ElMessage } from 'element-plus' +import { useClipboard, useCssVar } from '@vueuse/core' + +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' +import { useDesign } from '@/hooks/web/useDesign' + +import { setCssVar, trim } from '@/utils' +import { colorIsDark, hexToRGB, lighten } from '@/utils/color' +import { useAppStore } from '@/store/modules/app' +import { ThemeSwitch } from '@/layout/components/ThemeSwitch' +import ColorRadioPicker from './components/ColorRadioPicker.vue' +import InterfaceDisplay from './components/InterfaceDisplay.vue' +import LayoutRadioPicker from './components/LayoutRadioPicker.vue' + +defineOptions({ name: 'Setting' }) + +const { t } = useI18n() +const appStore = useAppStore() + +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('setting') +const layout = computed(() => appStore.getLayout) +const drawer = ref(false) + +// 主题色相关 +const systemTheme = ref(appStore.getTheme.elColorPrimary) + +const setSystemTheme = (color: string) => { + setCssVar('--el-color-primary', color) + appStore.setTheme({ elColorPrimary: color }) + const leftMenuBgColor = useCssVar('--left-menu-bg-color', document.documentElement) + setMenuTheme(trim(unref(leftMenuBgColor))) +} + +// 头部主题相关 +const headerTheme = ref(appStore.getTheme.topHeaderBgColor || '') + +const setHeaderTheme = (color: string) => { + const isDarkColor = colorIsDark(color) + const textColor = isDarkColor ? '#fff' : 'inherit' + const textHoverColor = isDarkColor ? lighten(color!, 6) : '#f6f6f6' + const topToolBorderColor = isDarkColor ? color : '#eee' + setCssVar('--top-header-bg-color', color) + setCssVar('--top-header-text-color', textColor) + setCssVar('--top-header-hover-color', textHoverColor) + appStore.setTheme({ + topHeaderBgColor: color, + topHeaderTextColor: textColor, + topHeaderHoverColor: textHoverColor, + topToolBorderColor + }) + if (unref(layout) === 'top') { + setMenuTheme(color) + } +} + +// 菜单主题相关 +const menuTheme = ref(appStore.getTheme.leftMenuBgColor || '') + +const setMenuTheme = (color: string) => { + const primaryColor = useCssVar('--el-color-primary', document.documentElement) + const isDarkColor = colorIsDark(color) + const theme: Recordable = { + // 左侧菜单边框颜色 + leftMenuBorderColor: isDarkColor ? 'inherit' : '#eee', + // 左侧菜单背景颜色 + leftMenuBgColor: color, + // 左侧菜单浅色背景颜色 + leftMenuBgLightColor: isDarkColor ? lighten(color!, 6) : color, + // 左侧菜单选中背景颜色 + leftMenuBgActiveColor: isDarkColor + ? 'var(--el-color-primary)' + : hexToRGB(unref(primaryColor), 0.1), + // 左侧菜单收起选中背景颜色 + leftMenuCollapseBgActiveColor: isDarkColor + ? 'var(--el-color-primary)' + : hexToRGB(unref(primaryColor), 0.1), + // 左侧菜单字体颜色 + leftMenuTextColor: isDarkColor ? '#bfcbd9' : '#333', + // 左侧菜单选中字体颜色 + leftMenuTextActiveColor: isDarkColor ? '#fff' : 'var(--el-color-primary)', + // logo字体颜色 + logoTitleTextColor: isDarkColor ? '#fff' : 'inherit', + // logo边框颜色 + logoBorderColor: isDarkColor ? color : '#eee' + } + appStore.setTheme(theme) + appStore.setCssVarTheme() +} +if (layout.value === 'top' && !appStore.getIsDark) { + headerTheme.value = '#fff' + setHeaderTheme('#fff') +} + +// 监听layout变化,重置一些主题色 +watch( + () => layout.value, + (n) => { + if (n === 'top' && !appStore.getIsDark) { + headerTheme.value = '#fff' + setHeaderTheme('#fff') + } else { + setMenuTheme(unref(menuTheme)) + } + } +) + +// 拷贝 +const copyConfig = async () => { + const { copy, copied, isSupported } = useClipboard({ + source: ` + // 面包屑 + breadcrumb: ${appStore.getBreadcrumb}, + // 面包屑图标 + breadcrumbIcon: ${appStore.getBreadcrumbIcon}, + // 折叠图标 + hamburger: ${appStore.getHamburger}, + // 全屏图标 + screenfull: ${appStore.getScreenfull}, + // 尺寸图标 + size: ${appStore.getSize}, + // 多语言图标 + locale: ${appStore.getLocale}, + // 消息图标 + message: ${appStore.getMessage}, + // 标签页 + tagsView: ${appStore.getTagsView}, + // 标签页图标 + getTagsViewIcon: ${appStore.getTagsViewIcon}, + // logo + logo: ${appStore.getLogo}, + // 菜单手风琴 + uniqueOpened: ${appStore.getUniqueOpened}, + // 固定header + fixedHeader: ${appStore.getFixedHeader}, + // 页脚 + footer: ${appStore.getFooter}, + // 灰色模式 + greyMode: ${appStore.getGreyMode}, + // layout布局 + layout: '${appStore.getLayout}', + // 暗黑模式 + isDark: ${appStore.getIsDark}, + // 组件尺寸 + currentSize: '${appStore.getCurrentSize}', + // 主题相关 + theme: { + // 主题色 + elColorPrimary: '${appStore.getTheme.elColorPrimary}', + // 左侧菜单边框颜色 + leftMenuBorderColor: '${appStore.getTheme.leftMenuBorderColor}', + // 左侧菜单背景颜色 + leftMenuBgColor: '${appStore.getTheme.leftMenuBgColor}', + // 左侧菜单浅色背景颜色 + leftMenuBgLightColor: '${appStore.getTheme.leftMenuBgLightColor}', + // 左侧菜单选中背景颜色 + leftMenuBgActiveColor: '${appStore.getTheme.leftMenuBgActiveColor}', + // 左侧菜单收起选中背景颜色 + leftMenuCollapseBgActiveColor: '${appStore.getTheme.leftMenuCollapseBgActiveColor}', + // 左侧菜单字体颜色 + leftMenuTextColor: '${appStore.getTheme.leftMenuTextColor}', + // 左侧菜单选中字体颜色 + leftMenuTextActiveColor: '${appStore.getTheme.leftMenuTextActiveColor}', + // logo字体颜色 + logoTitleTextColor: '${appStore.getTheme.logoTitleTextColor}', + // logo边框颜色 + logoBorderColor: '${appStore.getTheme.logoBorderColor}', + // 头部背景颜色 + topHeaderBgColor: '${appStore.getTheme.topHeaderBgColor}', + // 头部字体颜色 + topHeaderTextColor: '${appStore.getTheme.topHeaderTextColor}', + // 头部悬停颜色 + topHeaderHoverColor: '${appStore.getTheme.topHeaderHoverColor}', + // 头部边框颜色 + topToolBorderColor: '${appStore.getTheme.topToolBorderColor}' + } + ` + }) + if (!isSupported) { + ElMessage.error(t('setting.copyFailed')) + } else { + await copy() + if (unref(copied)) { + ElMessage.success(t('setting.copySuccess')) + } + } +} + +// 清空缓存 +const clear = () => { + const { wsCache } = useCache() + wsCache.delete(CACHE_KEY.LAYOUT) + wsCache.delete(CACHE_KEY.THEME) + wsCache.delete(CACHE_KEY.IS_DARK) + window.location.reload() +} +</script> + +<template> + <div + :class="prefixCls" + class="fixed right-0 top-[45%] h-40px w-40px cursor-pointer bg-[var(--el-color-primary)] text-center leading-40px" + @click="drawer = true" + > + <Icon color="#fff" icon="ep:setting" /> + </div> + + <ElDrawer v-model="drawer" :z-index="4000" direction="rtl" size="350px"> + <template #header> + <span class="text-16px font-700">{{ t('setting.projectSetting') }}</span> + </template> + + <div class="text-center"> + <!-- 主题 --> + <ElDivider>{{ t('setting.theme') }}</ElDivider> + <ThemeSwitch /> + + <!-- 布局 --> + <ElDivider>{{ t('setting.layout') }}</ElDivider> + <LayoutRadioPicker /> + + <!-- 系统主题 --> + <ElDivider>{{ t('setting.systemTheme') }}</ElDivider> + <ColorRadioPicker + v-model="systemTheme" + :schema="[ + '#409eff', + '#009688', + '#536dfe', + '#ff5c93', + '#ee4f12', + '#0096c7', + '#9c27b0', + '#ff9800' + ]" + @change="setSystemTheme" + /> + + <!-- 头部主题 --> + <ElDivider>{{ t('setting.headerTheme') }}</ElDivider> + <ColorRadioPicker + v-model="headerTheme" + :schema="[ + '#fff', + '#151515', + '#5172dc', + '#e74c3c', + '#24292e', + '#394664', + '#009688', + '#383f45' + ]" + @change="setHeaderTheme" + /> + + <!-- 菜单主题 --> + <template v-if="layout !== 'top'"> + <ElDivider>{{ t('setting.menuTheme') }}</ElDivider> + <ColorRadioPicker + v-model="menuTheme" + :schema="[ + '#fff', + '#001529', + '#212121', + '#273352', + '#191b24', + '#383f45', + '#001628', + '#344058' + ]" + @change="setMenuTheme" + /> + </template> + </div> + + <!-- 界面显示 --> + <ElDivider>{{ t('setting.interfaceDisplay') }}</ElDivider> + <InterfaceDisplay /> + + <ElDivider /> + <div> + <ElButton class="w-full" type="primary" @click="copyConfig">{{ t('setting.copy') }}</ElButton> + </div> + <div class="mt-5px"> + <ElButton class="w-full" type="danger" @click="clear"> + {{ t('setting.clearAndReset') }} + </ElButton> + </div> + </ElDrawer> +</template> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-setting; + +.#{$prefix-cls} { + border-radius: 6px 0 0 6px; +} +</style> diff --git a/src/layout/components/Setting/src/components/ColorRadioPicker.vue b/src/layout/components/Setting/src/components/ColorRadioPicker.vue new file mode 100644 index 0000000..fcc5e75 --- /dev/null +++ b/src/layout/components/Setting/src/components/ColorRadioPicker.vue @@ -0,0 +1,67 @@ +<script lang="ts" setup> +import { PropType } from 'vue' +import { propTypes } from '@/utils/propTypes' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'ColorRadioPicker' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('color-radio-picker') + +const props = defineProps({ + schema: { + type: Array as PropType<string[]>, + default: () => [] + }, + modelValue: propTypes.string.def('') +}) + +const emit = defineEmits(['update:modelValue', 'change']) + +const colorVal = ref(props.modelValue) + +watch( + () => props.modelValue, + (val: string) => { + if (val === unref(colorVal)) return + colorVal.value = val + } +) + +// 监听 +watch( + () => colorVal.value, + (val: string) => { + emit('update:modelValue', val) + emit('change', val) + } +) +</script> + +<template> + <div :class="prefixCls" class="flex flex-wrap space-x-14px"> + <span + v-for="(item, i) in schema" + :key="`radio-${i}`" + :class="{ 'is-active': colorVal === item }" + :style="{ + background: item + }" + class="mb-5px h-20px w-20px cursor-pointer border-2px border-gray-300 rounded-2px border-solid text-center leading-20px" + @click="colorVal = item" + > + <Icon v-if="colorVal === item" :size="16" color="#fff" icon="ep:check" /> + </span> + </div> +</template> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-color-radio-picker; + +.#{$prefix-cls} { + .is-active { + border-color: var(--el-color-primary); + } +} +</style> diff --git a/src/layout/components/Setting/src/components/InterfaceDisplay.vue b/src/layout/components/Setting/src/components/InterfaceDisplay.vue new file mode 100644 index 0000000..ebbbf4b --- /dev/null +++ b/src/layout/components/Setting/src/components/InterfaceDisplay.vue @@ -0,0 +1,224 @@ +<script lang="ts" setup> +import { setCssVar } from '@/utils' + +import { useDesign } from '@/hooks/web/useDesign' +import { useWatermark } from '@/hooks/web/useWatermark' +import { useAppStore } from '@/store/modules/app' + +defineOptions({ name: 'InterfaceDisplay' }) + +const { t } = useI18n() +const { getPrefixCls } = useDesign() +const { setWatermark } = useWatermark() +const prefixCls = getPrefixCls('interface-display') +const appStore = useAppStore() + +const water = ref() + +// 面包屑 +const breadcrumb = ref(appStore.getBreadcrumb) + +const breadcrumbChange = (show: boolean) => { + appStore.setBreadcrumb(show) +} + +// 面包屑图标 +const breadcrumbIcon = ref(appStore.getBreadcrumbIcon) + +const breadcrumbIconChange = (show: boolean) => { + appStore.setBreadcrumbIcon(show) +} + +// 折叠图标 +const hamburger = ref(appStore.getHamburger) + +const hamburgerChange = (show: boolean) => { + appStore.setHamburger(show) +} + +// 全屏图标 +const screenfull = ref(appStore.getScreenfull) + +const screenfullChange = (show: boolean) => { + appStore.setScreenfull(show) +} + +// 尺寸图标 +const size = ref(appStore.getSize) + +const sizeChange = (show: boolean) => { + appStore.setSize(show) +} + +// 多语言图标 +const locale = ref(appStore.getLocale) + +const localeChange = (show: boolean) => { + appStore.setLocale(show) +} + +// 消息图标 +const message = ref(appStore.getMessage) + +const messageChange = (show: boolean) => { + appStore.setMessage(show) +} + +// 标签页 +const tagsView = ref(appStore.getTagsView) + +const tagsViewChange = (show: boolean) => { + // 切换标签栏显示时,同步切换标签栏的高度 + setCssVar('--tags-view-height', show ? '35px' : '0px') + appStore.setTagsView(show) +} + +// 标签页图标 +const tagsViewIcon = ref(appStore.getTagsViewIcon) + +const tagsViewIconChange = (show: boolean) => { + appStore.setTagsViewIcon(show) +} + +// logo +const logo = ref(appStore.getLogo) + +const logoChange = (show: boolean) => { + appStore.setLogo(show) +} + +// 菜单手风琴 +const uniqueOpened = ref(appStore.getUniqueOpened) + +const uniqueOpenedChange = (uniqueOpened: boolean) => { + appStore.setUniqueOpened(uniqueOpened) +} + +// 固定头部 +const fixedHeader = ref(appStore.getFixedHeader) + +const fixedHeaderChange = (show: boolean) => { + appStore.setFixedHeader(show) +} + +// 页脚 +const footer = ref(appStore.getFooter) + +const footerChange = (show: boolean) => { + appStore.setFooter(show) +} + +// 灰色模式 +const greyMode = ref(appStore.getGreyMode) + +const greyModeChange = (show: boolean) => { + appStore.setGreyMode(show) +} + +// 固定菜单 +const fixedMenu = ref(appStore.getFixedMenu) + +const fixedMenuChange = (show: boolean) => { + appStore.setFixedMenu(show) +} + +// 设置水印 +const setWater = () => { + setWatermark(water.value) +} + +const layout = computed(() => appStore.getLayout) + +watch( + () => layout.value, + (n) => { + if (n === 'top') { + appStore.setCollapse(false) + } + } +) +</script> + +<template> + <div :class="prefixCls"> + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.breadcrumb') }}</span> + <ElSwitch v-model="breadcrumb" @change="breadcrumbChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.breadcrumbIcon') }}</span> + <ElSwitch v-model="breadcrumbIcon" @change="breadcrumbIconChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.hamburgerIcon') }}</span> + <ElSwitch v-model="hamburger" @change="hamburgerChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.screenfullIcon') }}</span> + <ElSwitch v-model="screenfull" @change="screenfullChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.sizeIcon') }}</span> + <ElSwitch v-model="size" @change="sizeChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.localeIcon') }}</span> + <ElSwitch v-model="locale" @change="localeChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.messageIcon') }}</span> + <ElSwitch v-model="message" @change="messageChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.tagsView') }}</span> + <ElSwitch v-model="tagsView" @change="tagsViewChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.tagsViewIcon') }}</span> + <ElSwitch v-model="tagsViewIcon" @change="tagsViewIconChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.logo') }}</span> + <ElSwitch v-model="logo" @change="logoChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.uniqueOpened') }}</span> + <ElSwitch v-model="uniqueOpened" @change="uniqueOpenedChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.fixedHeader') }}</span> + <ElSwitch v-model="fixedHeader" @change="fixedHeaderChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.footer') }}</span> + <ElSwitch v-model="footer" @change="footerChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.greyMode') }}</span> + <ElSwitch v-model="greyMode" @change="greyModeChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('setting.fixedMenu') }}</span> + <ElSwitch v-model="fixedMenu" @change="fixedMenuChange" /> + </div> + + <div class="flex items-center justify-between"> + <span class="text-14px">{{ t('watermark.watermark') }}</span> + <ElInput v-model="water" class="right-1 w-20" @change="setWater()" /> + </div> + </div> +</template> diff --git a/src/layout/components/Setting/src/components/LayoutRadioPicker.vue b/src/layout/components/Setting/src/components/LayoutRadioPicker.vue new file mode 100644 index 0000000..801686c --- /dev/null +++ b/src/layout/components/Setting/src/components/LayoutRadioPicker.vue @@ -0,0 +1,172 @@ +<script lang="ts" setup> +import { useAppStore } from '@/store/modules/app' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'LayoutRadioPicker' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('layout-radio-picker') + +const appStore = useAppStore() + +const layout = computed(() => appStore.getLayout) +</script> + +<template> + <div :class="prefixCls" class="flex flex-wrap space-x-14px"> + <div + :class="[ + `${prefixCls}__classic`, + 'relative w-56px h-48px cursor-pointer bg-gray-300', + { + 'is-acitve': layout === 'classic' + } + ]" + @click="appStore.setLayout('classic')" + ></div> + <div + :class="[ + `${prefixCls}__top-left`, + 'relative w-56px h-48px cursor-pointer bg-gray-300', + { + 'is-acitve': layout === 'topLeft' + } + ]" + @click="appStore.setLayout('topLeft')" + ></div> + <div + :class="[ + `${prefixCls}__top`, + 'relative w-56px h-48px cursor-pointer bg-gray-300', + { + 'is-acitve': layout === 'top' + } + ]" + @click="appStore.setLayout('top')" + ></div> + <div + :class="[ + `${prefixCls}__cut-menu`, + 'relative w-56px h-48px cursor-pointer bg-gray-300', + { + 'is-acitve': layout === 'cutMenu' + } + ]" + @click="appStore.setLayout('cutMenu')" + > + <div class="absolute left-[10%] top-0 h-full w-[33%] bg-gray-200"></div> + </div> + </div> +</template> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-layout-radio-picker; + +.#{$prefix-cls} { + &__classic { + border: 2px solid #e5e7eb; + border-radius: 4px; + + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 33%; + height: 100%; + background-color: #273352; + border-radius: 4px 0 0 4px; + content: ''; + } + + &::after { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 25%; + background-color: #fff; + border-radius: 4px 4px 0; + content: ''; + } + } + + &__top-left { + border: 2px solid #e5e7eb; + border-radius: 4px; + + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 33%; + background-color: #273352; + border-radius: 4px 4px 0 0; + content: ''; + } + + &::after { + position: absolute; + top: 0; + left: 0; + width: 33%; + height: 100%; + background-color: #fff; + border-radius: 4px 0 0 4px; + content: ''; + } + } + + &__top { + border: 2px solid #e5e7eb; + border-radius: 4px; + + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 33%; + background-color: #273352; + border-radius: 4px 4px 0 0; + content: ''; + } + } + + &__cut-menu { + border: 2px solid #e5e7eb; + border-radius: 4px; + + &::before { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 33%; + background-color: #273352; + border-radius: 4px 4px 0 0; + content: ''; + } + + &::after { + position: absolute; + top: 0; + left: 0; + width: 10%; + height: 100%; + background-color: #fff; + border-radius: 4px 0 0 4px; + content: ''; + } + } + + .is-acitve { + border-color: var(--el-color-primary); + } +} +</style> diff --git a/src/layout/components/SizeDropdown/index.ts b/src/layout/components/SizeDropdown/index.ts new file mode 100644 index 0000000..516488d --- /dev/null +++ b/src/layout/components/SizeDropdown/index.ts @@ -0,0 +1,3 @@ +import SizeDropdown from './src/SizeDropdown.vue' + +export { SizeDropdown } diff --git a/src/layout/components/SizeDropdown/src/SizeDropdown.vue b/src/layout/components/SizeDropdown/src/SizeDropdown.vue new file mode 100644 index 0000000..3e15224 --- /dev/null +++ b/src/layout/components/SizeDropdown/src/SizeDropdown.vue @@ -0,0 +1,40 @@ +<script lang="ts" setup> +import { useAppStore } from '@/store/modules/app' + +import { propTypes } from '@/utils/propTypes' +import { useDesign } from '@/hooks/web/useDesign' +import { ElementPlusSize } from '@/types/elementPlus' + +defineOptions({ name: 'SizeDropdown' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('size-dropdown') + +defineProps({ + color: propTypes.string.def('') +}) + +const { t } = useI18n() + +const appStore = useAppStore() + +const sizeMap = computed(() => appStore.sizeMap) + +const setCurrentSize = (size: ElementPlusSize) => { + appStore.setCurrentSize(size) +} +</script> + +<template> + <ElDropdown :class="prefixCls" trigger="click" @command="setCurrentSize"> + <Icon :color="color" :size="18" class="cursor-pointer" icon="mdi:format-size" /> + <template #dropdown> + <ElDropdownMenu> + <ElDropdownItem v-for="item in sizeMap" :key="item" :command="item"> + {{ t(`size.${item}`) }} + </ElDropdownItem> + </ElDropdownMenu> + </template> + </ElDropdown> +</template> diff --git a/src/layout/components/TabMenu/index.ts b/src/layout/components/TabMenu/index.ts new file mode 100644 index 0000000..b5fd71c --- /dev/null +++ b/src/layout/components/TabMenu/index.ts @@ -0,0 +1,3 @@ +import TabMenu from './src/TabMenu.vue' + +export { TabMenu } diff --git a/src/layout/components/TabMenu/src/TabMenu.vue b/src/layout/components/TabMenu/src/TabMenu.vue new file mode 100644 index 0000000..b70464c --- /dev/null +++ b/src/layout/components/TabMenu/src/TabMenu.vue @@ -0,0 +1,240 @@ +<script lang="tsx"> +import { usePermissionStore } from '@/store/modules/permission' +import { useAppStore } from '@/store/modules/app' + +import { ElScrollbar } from 'element-plus' +import { Icon } from '@/components/Icon' +import { Menu } from '@/layout/components/Menu' +import { pathResolve } from '@/utils/routerHelper' +import { cloneDeep } from 'lodash-es' +import { filterMenusPath, initTabMap, tabPathMap } from './helper' +import { useDesign } from '@/hooks/web/useDesign' +import { isUrl } from '@/utils/is' + +const { getPrefixCls, variables } = useDesign() + +const prefixCls = getPrefixCls('tab-menu') + +export default defineComponent({ + name: 'TabMenu', + setup() { + const { push, currentRoute } = useRouter() + + const { t } = useI18n() + + const appStore = useAppStore() + + const collapse = computed(() => appStore.getCollapse) + + const fixedMenu = computed(() => appStore.getFixedMenu) + + const permissionStore = usePermissionStore() + + const routers = computed(() => permissionStore.getRouters) + + const tabRouters = computed(() => unref(routers).filter((v) => !v?.meta?.hidden)) + + const setCollapse = () => { + appStore.setCollapse(!unref(collapse)) + } + + onMounted(() => { + if (unref(fixedMenu)) { + const path = `/${unref(currentRoute).path.split('/')[1]}` + const children = unref(tabRouters).find( + (v) => + (v.meta?.alwaysShow || (v?.children?.length && v?.children?.length > 1)) && + v.path === path + )?.children + + tabActive.value = path + if (children) { + permissionStore.setMenuTabRouters( + cloneDeep(children).map((v) => { + v.path = pathResolve(unref(tabActive), v.path) + return v + }) + ) + } + } + }) + + watch( + () => routers.value, + (routers: AppRouteRecordRaw[]) => { + initTabMap(routers) + filterMenusPath(routers, routers) + }, + { + immediate: true, + deep: true + } + ) + + const showTitle = ref(true) + + watch( + () => collapse.value, + (collapse: boolean) => { + if (!collapse) { + setTimeout(() => { + showTitle.value = !collapse + }, 200) + } else { + showTitle.value = !collapse + } + } + ) + + // 是否显示菜单 + const showMenu = ref(unref(fixedMenu) ? true : false) + + // tab高亮 + const tabActive = ref('') + + // tab点击事件 + const tabClick = (item: AppRouteRecordRaw) => { + if (isUrl(item.path)) { + window.open(item.path) + return + } + const newPath = item.children ? item.path : item.path.split('/')[0] + const oldPath = unref(tabActive) + tabActive.value = item.children ? item.path : item.path.split('/')[0] + if (item.children) { + if (newPath === oldPath || !unref(showMenu)) { + showMenu.value = unref(fixedMenu) ? true : !unref(showMenu) + } + if (unref(showMenu)) { + permissionStore.setMenuTabRouters( + cloneDeep(item.children).map((v) => { + v.path = pathResolve(unref(tabActive), v.path) + return v + }) + ) + } + } else { + push(item.path) + permissionStore.setMenuTabRouters([]) + showMenu.value = false + } + } + + // 设置高亮 + const isActive = (currentPath: string) => { + const { path } = unref(currentRoute) + if (tabPathMap[currentPath].includes(path)) { + return true + } + return false + } + + const mouseleave = () => { + if (!unref(showMenu) || unref(fixedMenu)) return + showMenu.value = false + } + + return () => ( + <div + id={`${variables.namespace}-menu`} + class={[ + prefixCls, + 'relative bg-[var(--left-menu-bg-color)] top-1px layout-border__right', + { + 'w-[var(--tab-menu-max-width)]': !unref(collapse), + 'w-[var(--tab-menu-min-width)]': unref(collapse) + } + ]} + onMouseleave={mouseleave} + > + <ElScrollbar class="!h-[calc(100%-var(--tab-menu-collapse-height)-1px)]"> + <div> + {() => { + return unref(tabRouters).map((v) => { + const item = ( + v.meta?.alwaysShow || (v?.children?.length && v?.children?.length > 1) + ? v + : { + ...(v?.children && v?.children[0]), + path: pathResolve(v.path, (v?.children && v?.children[0])?.path as string) + } + ) as AppRouteRecordRaw + return ( + <div + class={[ + `${prefixCls}__item`, + 'text-center text-12px relative py-12px cursor-pointer', + { + 'is-active': isActive(v.path) + } + ]} + onClick={() => { + tabClick(item) + }} + > + <div> + <Icon icon={item?.meta?.icon}></Icon> + </div> + {!unref(showTitle) ? undefined : ( + <p class="mt-5px break-words px-2px">{t(item.meta?.title)}</p> + )} + </div> + ) + }) + }} + </div> + </ElScrollbar> + <div + class={[ + `${prefixCls}--collapse`, + 'text-center h-[var(--tab-menu-collapse-height)] leading-[var(--tab-menu-collapse-height)] cursor-pointer' + ]} + onClick={setCollapse} + > + <Icon icon={unref(collapse) ? 'ep:d-arrow-right' : 'ep:d-arrow-left'}></Icon> + </div> + <Menu + class={[ + '!absolute top-0 z-11', + { + '!left-[var(--tab-menu-min-width)]': unref(collapse), + '!left-[var(--tab-menu-max-width)]': !unref(collapse), + '!w-[calc(var(--left-menu-max-width)+1px)]': unref(showMenu) || unref(fixedMenu), + '!w-0': !unref(showMenu) && !unref(fixedMenu) + } + ]} + style="transition: width var(--transition-time-02), left var(--transition-time-02);" + ></Menu> + </div> + ) + } +}) +</script> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-tab-menu; + +.#{$prefix-cls} { + transition: all var(--transition-time-02); + + &__item { + color: var(--left-menu-text-color); + transition: all var(--transition-time-02); + + &:hover { + color: var(--left-menu-text-active-color); + // background-color: var(--left-menu-bg-active-color); + } + } + + &--collapse { + color: var(--left-menu-text-color); + background-color: var(--left-menu-bg-light-color); + } + + .is-active { + color: var(--left-menu-text-active-color); + background-color: var(--left-menu-bg-active-color); + } +} +</style> diff --git a/src/layout/components/TabMenu/src/helper.ts b/src/layout/components/TabMenu/src/helper.ts new file mode 100644 index 0000000..cce3932 --- /dev/null +++ b/src/layout/components/TabMenu/src/helper.ts @@ -0,0 +1,51 @@ +import { getAllParentPath } from '@/layout/components/Menu/src/helper' +import type { RouteMeta } from 'vue-router' +import { isUrl } from '@/utils/is' +import { cloneDeep } from 'lodash-es' + +export type TabMapTypes = { + [key: string]: string[] +} + +export const tabPathMap = reactive<TabMapTypes>({}) + +export const initTabMap = (routes: AppRouteRecordRaw[]) => { + for (const v of routes) { + const meta = (v.meta ?? {}) as RouteMeta + if (!meta?.hidden) { + tabPathMap[v.path] = [] + } + } +} + +export const filterMenusPath = ( + routes: AppRouteRecordRaw[], + allRoutes: AppRouteRecordRaw[] +): AppRouteRecordRaw[] => { + const res: AppRouteRecordRaw[] = [] + for (const v of routes) { + let data: Nullable<AppRouteRecordRaw> = null + const meta = (v.meta ?? {}) as RouteMeta + if (!meta.hidden || meta.canTo) { + const allParentPath = getAllParentPath<AppRouteRecordRaw>(allRoutes, v.path) + + const fullPath = isUrl(v.path) ? v.path : allParentPath.join('/') + + data = cloneDeep(v) + data.path = fullPath + if (v.children && data) { + data.children = filterMenusPath(v.children, allRoutes) + } + + if (data) { + res.push(data) + } + + if (allParentPath.length && Reflect.has(tabPathMap, allParentPath[0])) { + tabPathMap[allParentPath[0]].push(fullPath) + } + } + } + + return res +} diff --git a/src/layout/components/TagsView/index.ts b/src/layout/components/TagsView/index.ts new file mode 100644 index 0000000..30e604a --- /dev/null +++ b/src/layout/components/TagsView/index.ts @@ -0,0 +1,3 @@ +import TagsView from './src/TagsView.vue' + +export { TagsView } diff --git a/src/layout/components/TagsView/src/TagsView.vue b/src/layout/components/TagsView/src/TagsView.vue new file mode 100644 index 0000000..0161096 --- /dev/null +++ b/src/layout/components/TagsView/src/TagsView.vue @@ -0,0 +1,586 @@ +<script lang="ts" setup> +import { onMounted, watch, computed, unref, ref, nextTick } from 'vue' +import { useRouter } from 'vue-router' +import type { RouteLocationNormalizedLoaded, RouterLinkProps } from 'vue-router' +import { usePermissionStore } from '@/store/modules/permission' +import { useTagsViewStore } from '@/store/modules/tagsView' +import { useAppStore } from '@/store/modules/app' +import { useI18n } from '@/hooks/web/useI18n' +import { filterAffixTags } from './helper' +import { ContextMenu, ContextMenuExpose } from '@/layout/components/ContextMenu' +import { useDesign } from '@/hooks/web/useDesign' +import { useTemplateRefsList } from '@vueuse/core' +import { ElScrollbar } from 'element-plus' +import { useScrollTo } from '@/hooks/event/useScrollTo' + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('tags-view') + +const { t } = useI18n() + +const { currentRoute, push, replace } = useRouter() + +const permissionStore = usePermissionStore() + +const routers = computed(() => permissionStore.getRouters) + +const tagsViewStore = useTagsViewStore() + +const visitedViews = computed(() => tagsViewStore.getVisitedViews) + +const affixTagArr = ref<RouteLocationNormalizedLoaded[]>([]) + +const appStore = useAppStore() + +const tagsViewIcon = computed(() => appStore.getTagsViewIcon) + +const isDark = computed(() => appStore.getIsDark) + +// 初始化tag +const initTags = () => { + affixTagArr.value = filterAffixTags(unref(routers)) + for (const tag of unref(affixTagArr)) { + // Must have tag name + if (tag.name) { + tagsViewStore.addVisitedView(tag) + } + } +} + +const selectedTag = ref<RouteLocationNormalizedLoaded>() + +// 新增tag +const addTags = () => { + const { name } = unref(currentRoute) + if (name) { + selectedTag.value = unref(currentRoute) + tagsViewStore.addView(unref(currentRoute)) + } + return false +} + +// 关闭选中的tag +const closeSelectedTag = (view: RouteLocationNormalizedLoaded) => { + if (view?.meta?.affix) return + tagsViewStore.delView(view) + if (isActive(view)) { + toLastView() + } +} + +// 关闭全部 +const closeAllTags = () => { + tagsViewStore.delAllViews() + toLastView() +} + +// 关闭其它 +const closeOthersTags = () => { + tagsViewStore.delOthersViews(unref(selectedTag) as RouteLocationNormalizedLoaded) +} + +// 重新加载 +const refreshSelectedTag = async (view?: RouteLocationNormalizedLoaded) => { + if (!view) return + tagsViewStore.delCachedView() + const { path, query } = view + await nextTick() + replace({ + path: '/redirect' + path, + query: query + }) +} + +// 关闭左侧 +const closeLeftTags = () => { + tagsViewStore.delLeftViews(unref(selectedTag) as RouteLocationNormalizedLoaded) +} + +// 关闭右侧 +const closeRightTags = () => { + tagsViewStore.delRightViews(unref(selectedTag) as RouteLocationNormalizedLoaded) +} + +// 跳转到最后一个 +const toLastView = () => { + const visitedViews = tagsViewStore.getVisitedViews + const latestView = visitedViews.slice(-1)[0] + if (latestView) { + push(latestView) + } else { + if ( + unref(currentRoute).path === permissionStore.getAddRouters[0].path || + unref(currentRoute).path === permissionStore.getAddRouters[0].redirect + ) { + addTags() + return + } + // TODO: You can set another route + push('/') + } +} + +// 滚动到选中的tag +const moveToCurrentTag = async () => { + await nextTick() + for (const v of unref(visitedViews)) { + if (v.fullPath === unref(currentRoute).path) { + moveToTarget(v) + if (v.fullPath !== unref(currentRoute).fullPath) { + tagsViewStore.updateVisitedView(unref(currentRoute)) + } + + break + } + } +} + +const tagLinksRefs = useTemplateRefsList<RouterLinkProps>() + +const moveToTarget = (currentTag: RouteLocationNormalizedLoaded) => { + const wrap$ = unref(scrollbarRef)?.wrapRef + let firstTag: Nullable<RouterLinkProps> = null + let lastTag: Nullable<RouterLinkProps> = null + + const tagList = unref(tagLinksRefs) + // find first tag and last tag + if (tagList.length > 0) { + firstTag = tagList[0] + lastTag = tagList[tagList.length - 1] + } + if ((firstTag?.to as RouteLocationNormalizedLoaded).fullPath === currentTag.fullPath) { + // 直接滚动到0的位置 + const { start } = useScrollTo({ + el: wrap$!, + position: 'scrollLeft', + to: 0, + duration: 500 + }) + start() + } else if ((lastTag?.to as RouteLocationNormalizedLoaded).fullPath === currentTag.fullPath) { + // 滚动到最后的位置 + const { start } = useScrollTo({ + el: wrap$!, + position: 'scrollLeft', + to: wrap$!.scrollWidth - wrap$!.offsetWidth, + duration: 500 + }) + start() + } else { + // find preTag and nextTag + const currentIndex: number = tagList.findIndex( + (item) => (item?.to as RouteLocationNormalizedLoaded).fullPath === currentTag.fullPath + ) + const tgsRefs = document.getElementsByClassName(`${prefixCls}__item`) + + const prevTag = tgsRefs[currentIndex - 1] as HTMLElement + const nextTag = tgsRefs[currentIndex + 1] as HTMLElement + + // the tag's offsetLeft after of nextTag + const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + 4 + + // the tag's offsetLeft before of prevTag + const beforePrevTagOffsetLeft = prevTag.offsetLeft - 4 + + if (afterNextTagOffsetLeft > unref(scrollLeftNumber) + wrap$!.offsetWidth) { + const { start } = useScrollTo({ + el: wrap$!, + position: 'scrollLeft', + to: afterNextTagOffsetLeft - wrap$!.offsetWidth, + duration: 500 + }) + start() + } else if (beforePrevTagOffsetLeft < unref(scrollLeftNumber)) { + const { start } = useScrollTo({ + el: wrap$!, + position: 'scrollLeft', + to: beforePrevTagOffsetLeft, + duration: 500 + }) + start() + } + } +} + +// 是否是当前tag +const isActive = (route: RouteLocationNormalizedLoaded): boolean => { + return route.path === unref(currentRoute).path +} + +// 所有右键菜单组件的元素 +const itemRefs = useTemplateRefsList<ComponentRef<typeof ContextMenu & ContextMenuExpose>>() + +// 右键菜单装填改变的时候 +const visibleChange = (visible: boolean, tagItem: RouteLocationNormalizedLoaded) => { + if (visible) { + for (const v of unref(itemRefs)) { + const elDropdownMenuRef = v.elDropdownMenuRef + if (tagItem.fullPath !== v.tagItem.fullPath) { + elDropdownMenuRef?.handleClose() + } + } + } +} + +// elscroll 实例 +const scrollbarRef = ref<ComponentRef<typeof ElScrollbar>>() + +// 保存滚动位置 +const scrollLeftNumber = ref(0) + +const scroll = ({ scrollLeft }) => { + scrollLeftNumber.value = scrollLeft as number +} + +// 移动到某个位置 +const move = (to: number) => { + const wrap$ = unref(scrollbarRef)?.wrapRef + const { start } = useScrollTo({ + el: wrap$!, + position: 'scrollLeft', + to: unref(scrollLeftNumber) + to, + duration: 500 + }) + start() +} + +onMounted(() => { + initTags() + addTags() +}) + +watch( + () => currentRoute.value, + () => { + addTags() + moveToCurrentTag() + } +) +</script> + +<template> + <div + :id="prefixCls" + :class="prefixCls" + class="relative w-full flex bg-[#fff] dark:bg-[var(--el-bg-color)]" + > + <span + :class="`${prefixCls}__tool ${prefixCls}__tool--first`" + class="h-[var(--tags-view-height)] w-[var(--tags-view-height)] flex cursor-pointer items-center justify-center" + @click="move(-200)" + > + <Icon + icon="ep:d-arrow-left" + color="var(--el-text-color-placeholder)" + :hover-color="isDark ? '#fff' : 'var(--el-color-black)'" + /> + </span> + <div class="flex-1 overflow-hidden"> + <ElScrollbar ref="scrollbarRef" class="h-full" @scroll="scroll"> + <div class="h-full flex"> + <ContextMenu + :ref="itemRefs.set" + :schema="[ + { + icon: 'ep:refresh', + label: t('common.reload'), + disabled: selectedTag?.fullPath !== item.fullPath, + command: () => { + refreshSelectedTag(item) + } + }, + { + icon: 'ep:close', + label: t('common.closeTab'), + disabled: !!visitedViews?.length && selectedTag?.meta.affix, + command: () => { + closeSelectedTag(item) + } + }, + { + divided: true, + icon: 'ep:d-arrow-left', + label: t('common.closeTheLeftTab'), + disabled: + !!visitedViews?.length && + (item.fullPath === visitedViews[0].fullPath || + selectedTag?.fullPath !== item.fullPath), + command: () => { + closeLeftTags() + } + }, + { + icon: 'ep:d-arrow-right', + label: t('common.closeTheRightTab'), + disabled: + !!visitedViews?.length && + (item.fullPath === visitedViews[visitedViews.length - 1].fullPath || + selectedTag?.fullPath !== item.fullPath), + command: () => { + closeRightTags() + } + }, + { + divided: true, + icon: 'ep:discount', + label: t('common.closeOther'), + disabled: selectedTag?.fullPath !== item.fullPath, + command: () => { + closeOthersTags() + } + }, + { + icon: 'ep:minus', + label: t('common.closeAll'), + command: () => { + closeAllTags() + } + } + ]" + v-for="item in visitedViews" + :key="item.fullPath" + :tag-item="item" + :class="[ + `${prefixCls}__item`, + item?.meta?.affix ? `${prefixCls}__item--affix` : '', + { + 'is-active': isActive(item) + } + ]" + @visible-change="visibleChange" + > + <div> + <router-link :ref="tagLinksRefs.set" :to="{ ...item }" custom v-slot="{ navigate }"> + <div + @click="navigate" + class="h-full flex items-center justify-center whitespace-nowrap pl-15px" + > + <Icon + v-if=" + tagsViewIcon && + (item?.meta?.icon || + (item?.matched && + item.matched[0] && + item.matched[item.matched.length - 1].meta?.icon)) + " + :icon="item?.meta?.icon || item.matched[item.matched.length - 1].meta.icon" + :size="12" + class="mr-5px" + /> + {{ t(item?.meta?.title as string) }} + <Icon + :class="`${prefixCls}__item--close`" + color="#333" + icon="ep:close" + :size="12" + @click.prevent.stop="closeSelectedTag(item)" + /> + </div> + </router-link> + </div> + </ContextMenu> + </div> + </ElScrollbar> + </div> + <span + :class="`${prefixCls}__tool`" + class="h-[var(--tags-view-height)] w-[var(--tags-view-height)] flex cursor-pointer items-center justify-center" + @click="move(200)" + > + <Icon + icon="ep:d-arrow-right" + color="var(--el-text-color-placeholder)" + :hover-color="isDark ? '#fff' : 'var(--el-color-black)'" + /> + </span> + <span + :class="`${prefixCls}__tool`" + class="h-[var(--tags-view-height)] w-[var(--tags-view-height)] flex cursor-pointer items-center justify-center" + @click="refreshSelectedTag(selectedTag)" + > + <Icon + icon="ep:refresh-right" + color="var(--el-text-color-placeholder)" + :hover-color="isDark ? '#fff' : 'var(--el-color-black)'" + /> + </span> + <ContextMenu + trigger="click" + :schema="[ + { + icon: 'ep:refresh', + label: t('common.reload'), + command: () => { + refreshSelectedTag(selectedTag) + } + }, + { + icon: 'ep:close', + label: t('common.closeTab'), + disabled: !!visitedViews?.length && selectedTag?.meta.affix, + command: () => { + closeSelectedTag(selectedTag!) + } + }, + { + divided: true, + icon: 'ep:d-arrow-left', + label: t('common.closeTheLeftTab'), + disabled: !!visitedViews?.length && selectedTag?.fullPath === visitedViews[0].fullPath, + command: () => { + closeLeftTags() + } + }, + { + icon: 'ep:d-arrow-right', + label: t('common.closeTheRightTab'), + disabled: + !!visitedViews?.length && + selectedTag?.fullPath === visitedViews[visitedViews.length - 1].fullPath, + command: () => { + closeRightTags() + } + }, + { + divided: true, + icon: 'ep:discount', + label: t('common.closeOther'), + command: () => { + closeOthersTags() + } + }, + { + icon: 'ep:minus', + label: t('common.closeAll'), + command: () => { + closeAllTags() + } + } + ]" + > + <span + :class="`${prefixCls}__tool`" + class="block h-[var(--tags-view-height)] w-[var(--tags-view-height)] flex cursor-pointer items-center justify-center" + > + <Icon + icon="ep:menu" + color="var(--el-text-color-placeholder)" + :hover-color="isDark ? '#fff' : 'var(--el-color-black)'" + /> + </span> + </ContextMenu> + </div> +</template> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-tags-view; + +.#{$prefix-cls} { + :deep(.#{$elNamespace}-scrollbar__view) { + height: 100%; + } + + &__tool { + position: relative; + + &::before { + position: absolute; + top: 1px; + left: 0; + width: 100%; + height: calc(100% - 1px); + border-left: 1px solid var(--el-border-color); + content: ''; + } + + &--first { + &::before { + position: absolute; + top: 1px; + left: 0; + width: 100%; + height: calc(100% - 1px); + border-right: 1px solid var(--el-border-color); + border-left: none; + content: ''; + } + } + } + + &__item { + position: relative; + top: 2px; + height: calc(100% - 6px); + padding-right: 25px; + margin-left: 4px; + font-size: 12px; + cursor: pointer; + border: 1px solid #d9d9d9; + border-radius: 2px; + + &--close { + position: absolute; + top: 50%; + right: 5px; + display: none; + transform: translate(0, -50%); + } + &:not(.#{$prefix-cls}__item--affix):hover { + .#{$prefix-cls}__item--close { + display: block; + } + } + } + + &__item:not(.is-active) { + &:hover { + color: var(--el-color-primary); + } + } + + &__item.is-active { + color: var(--el-color-white); + background-color: var(--el-color-primary); + border: 1px solid var(--el-color-primary); + .#{$prefix-cls}__item--close { + :deep(span) { + color: var(--el-color-white) !important; + } + } + } +} + +.dark { + .#{$prefix-cls} { + &__tool { + &--first { + &::after { + display: none; + } + } + } + + &__item { + border: 1px solid var(--el-border-color); + } + + &__item:not(.is-active) { + &:hover { + color: var(--el-color-primary); + } + } + + &__item.is-active { + color: var(--el-color-white); + background-color: var(--el-color-primary); + border: 1px solid var(--el-color-primary); + .#{$prefix-cls}__item--close { + :deep(span) { + color: var(--el-color-white) !important; + } + } + } + } +} +</style> diff --git a/src/layout/components/TagsView/src/helper.ts b/src/layout/components/TagsView/src/helper.ts new file mode 100644 index 0000000..22f6a50 --- /dev/null +++ b/src/layout/components/TagsView/src/helper.ts @@ -0,0 +1,21 @@ +import type { RouteMeta, RouteLocationNormalizedLoaded } from 'vue-router' +import { pathResolve } from '@/utils/routerHelper' + +export const filterAffixTags = (routes: AppRouteRecordRaw[], parentPath = '') => { + let tags: RouteLocationNormalizedLoaded[] = [] + routes.forEach((route) => { + const meta = route.meta as RouteMeta + const tagPath = pathResolve(parentPath, route.path) + if (meta?.affix) { + tags.push({ ...route, path: tagPath, fullPath: tagPath } as RouteLocationNormalizedLoaded) + } + if (route.children) { + const tempTags: RouteLocationNormalizedLoaded[] = filterAffixTags(route.children, tagPath) + if (tempTags.length >= 1) { + tags = [...tags, ...tempTags] + } + } + }) + + return tags +} diff --git a/src/layout/components/ThemeSwitch/index.ts b/src/layout/components/ThemeSwitch/index.ts new file mode 100644 index 0000000..823a276 --- /dev/null +++ b/src/layout/components/ThemeSwitch/index.ts @@ -0,0 +1,3 @@ +import ThemeSwitch from './src/ThemeSwitch.vue' + +export { ThemeSwitch } diff --git a/src/layout/components/ThemeSwitch/src/ThemeSwitch.vue b/src/layout/components/ThemeSwitch/src/ThemeSwitch.vue new file mode 100644 index 0000000..39a8cfd --- /dev/null +++ b/src/layout/components/ThemeSwitch/src/ThemeSwitch.vue @@ -0,0 +1,46 @@ +<script lang="ts" setup> +import { useAppStore } from '@/store/modules/app' +import { useIcon } from '@/hooks/web/useIcon' +import { useDesign } from '@/hooks/web/useDesign' + +defineOptions({ name: 'ThemeSwitch' }) + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('theme-switch') + +const Sun = useIcon({ icon: 'emojione-monotone:sun', color: '#fde047' }) + +const CrescentMoon = useIcon({ icon: 'emojione-monotone:crescent-moon', color: '#fde047' }) + +const appStore = useAppStore() + +// 初始化获取是否是暗黑主题 +const isDark = ref(appStore.getIsDark) + +// 设置switch的背景颜色 +const blackColor = 'var(--el-color-black)' + +const themeChange = (val: boolean) => { + appStore.setIsDark(val) +} +</script> + +<template> + <ElSwitch + v-model="isDark" + :active-color="blackColor" + :active-icon="Sun" + :border-color="blackColor" + :class="prefixCls" + :inactive-color="blackColor" + :inactive-icon="CrescentMoon" + inline-prompt + @change="themeChange" + /> +</template> +<style lang="scss" scoped> +:deep(.el-switch__core .el-switch__inner .is-icon) { + overflow: visible; +} +</style> diff --git a/src/layout/components/ToolHeader.vue b/src/layout/components/ToolHeader.vue new file mode 100644 index 0000000..0b8d00d --- /dev/null +++ b/src/layout/components/ToolHeader.vue @@ -0,0 +1,95 @@ +<script lang="tsx"> +import { defineComponent, computed } from 'vue' +import { Message } from '@/layout/components//Message' +import { Collapse } from '@/layout/components/Collapse' +import { UserInfo } from '@/layout/components/UserInfo' +import { Screenfull } from '@/layout/components/Screenfull' +import { Breadcrumb } from '@/layout/components/Breadcrumb' +import { SizeDropdown } from '@/layout/components/SizeDropdown' +import { LocaleDropdown } from '@/layout/components/LocaleDropdown' +import RouterSearch from '@/components/RouterSearch/index.vue' +import { useAppStore } from '@/store/modules/app' +import { useDesign } from '@/hooks/web/useDesign' + +const { getPrefixCls, variables } = useDesign() + +const prefixCls = getPrefixCls('tool-header') + +const appStore = useAppStore() + +// 面包屑 +const breadcrumb = computed(() => appStore.getBreadcrumb) + +// 折叠图标 +const hamburger = computed(() => appStore.getHamburger) + +// 全屏图标 +const screenfull = computed(() => appStore.getScreenfull) + +// 搜索图片 +const search = computed(() => appStore.search) + +// 尺寸图标 +const size = computed(() => appStore.getSize) + +// 布局 +const layout = computed(() => appStore.getLayout) + +// 多语言图标 +const locale = computed(() => appStore.getLocale) + +// 消息图标 +const message = computed(() => appStore.getMessage) + +export default defineComponent({ + name: 'ToolHeader', + setup() { + return () => ( + <div + id={`${variables.namespace}-tool-header`} + class={[ + prefixCls, + 'h-[var(--top-tool-height)] relative px-[var(--top-tool-p-x)] flex items-center justify-between', + 'dark:bg-[var(--el-bg-color)]' + ]} + > + {layout.value !== 'top' ? ( + <div class="h-full flex items-center"> + {hamburger.value && layout.value !== 'cutMenu' ? ( + <Collapse class="custom-hover" color="var(--top-header-text-color)"></Collapse> + ) : undefined} + {breadcrumb.value ? <Breadcrumb class="lt-md:hidden"></Breadcrumb> : undefined} + </div> + ) : undefined} + <div class="h-full flex items-center"> + {screenfull.value ? ( + <Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull> + ) : undefined} + {search.value ? <RouterSearch isModal={false} /> : undefined} + {size.value ? ( + <SizeDropdown class="custom-hover" color="var(--top-header-text-color)"></SizeDropdown> + ) : undefined} + {locale.value ? ( + <LocaleDropdown + class="custom-hover" + color="var(--top-header-text-color)" + ></LocaleDropdown> + ) : undefined} + {message.value ? ( + <Message class="custom-hover" color="var(--top-header-text-color)"></Message> + ) : undefined} + <UserInfo></UserInfo> + </div> + </div> + ) + } +}) +</script> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-tool-header; + +.#{$prefix-cls} { + transition: left var(--transition-time-02); +} +</style> diff --git a/src/layout/components/UserInfo/index.ts b/src/layout/components/UserInfo/index.ts new file mode 100644 index 0000000..c3a34ab --- /dev/null +++ b/src/layout/components/UserInfo/index.ts @@ -0,0 +1,3 @@ +import UserInfo from './src/UserInfo.vue' + +export { UserInfo } diff --git a/src/layout/components/UserInfo/src/UserInfo.vue b/src/layout/components/UserInfo/src/UserInfo.vue new file mode 100644 index 0000000..5c5e373 --- /dev/null +++ b/src/layout/components/UserInfo/src/UserInfo.vue @@ -0,0 +1,113 @@ +<script lang="ts" setup> +import { ElMessageBox } from 'element-plus' + +import avatarImg from '@/assets/imgs/avatar.gif' +import { useDesign } from '@/hooks/web/useDesign' +import { useTagsViewStore } from '@/store/modules/tagsView' +import { useUserStore } from '@/store/modules/user' +import LockDialog from './components/LockDialog.vue' +import LockPage from './components/LockPage.vue' +import { useLockStore } from '@/store/modules/lock' + +defineOptions({ name: 'UserInfo' }) + +const { t } = useI18n() + +const { push, replace } = useRouter() + +const userStore = useUserStore() + +const tagsViewStore = useTagsViewStore() + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('user-info') + +const avatar = computed(() => userStore.user.avatar ?? avatarImg) +const userName = computed(() => userStore.user.nickname ?? 'Admin') + +// 锁定屏幕 +const lockStore = useLockStore() +const getIsLock = computed(() => lockStore.getLockInfo?.isLock ?? false) +const dialogVisible = ref<boolean>(false) +const lockScreen = () => { + dialogVisible.value = true +} + +const loginOut = async () => { + try { + await ElMessageBox.confirm(t('common.loginOutMessage'), t('common.reminder'), { + confirmButtonText: t('common.ok'), + cancelButtonText: t('common.cancel'), + type: 'warning' + }) + await userStore.loginOut() + tagsViewStore.delAllViews() + replace('/login?redirect=/index') + } catch {} +} +const toProfile = async () => { + push('/user/profile') +} +const toDocument = () => { + window.open('https://doc.iocoder.cn/') +} +</script> + +<template> + <ElDropdown class="custom-hover" :class="prefixCls" trigger="click"> + <div class="flex items-center"> + <ElAvatar :src="avatar" alt="" class="w-[calc(var(--logo-height)-25px)] rounded-[50%]" /> + <span class="pl-[5px] text-14px text-[var(--top-header-text-color)] <lg:hidden"> + {{ userName }} + </span> + </div> + <template #dropdown> + <ElDropdownMenu> + <ElDropdownItem> + <Icon icon="ep:tools" /> + <div @click="toProfile">{{ t('common.profile') }}</div> + </ElDropdownItem> + <ElDropdownItem> + <Icon icon="ep:menu" /> + <div @click="toDocument">{{ t('common.document') }}</div> + </ElDropdownItem> + <ElDropdownItem divided> + <Icon icon="ep:lock" /> + <div @click="lockScreen">{{ t('lock.lockScreen') }}</div> + </ElDropdownItem> + <ElDropdownItem divided @click="loginOut"> + <Icon icon="ep:switch-button" /> + <div>{{ t('common.loginOut') }}</div> + </ElDropdownItem> + </ElDropdownMenu> + </template> + </ElDropdown> + + <LockDialog v-if="dialogVisible" v-model="dialogVisible" /> + + <teleport to="body"> + <transition name="fade-bottom" mode="out-in"> + <LockPage v-if="getIsLock" /> + </transition> + </teleport> +</template> + +<style scoped lang="scss"> +.fade-bottom-enter-active, +.fade-bottom-leave-active { + transition: + opacity 0.25s, + transform 0.3s; +} + +.fade-bottom-enter-from { + opacity: 0; + transform: translateY(-10%); +} + +.fade-bottom-leave-to { + opacity: 0; + transform: translateY(10%); +} +</style> diff --git a/src/layout/components/UserInfo/src/components/LockDialog.vue b/src/layout/components/UserInfo/src/components/LockDialog.vue new file mode 100644 index 0000000..f4ab7d4 --- /dev/null +++ b/src/layout/components/UserInfo/src/components/LockDialog.vue @@ -0,0 +1,98 @@ +<script setup lang="ts"> +import { useValidator } from '@/hooks/web/useValidator' +import { useDesign } from '@/hooks/web/useDesign' +import { useLockStore } from '@/store/modules/lock' +import avatarImg from '@/assets/imgs/avatar.gif' +import { useUserStore } from '@/store/modules/user' + +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('lock-dialog') + +const { required } = useValidator() + +const { t } = useI18n() + +const lockStore = useLockStore() + +const props = defineProps({ + modelValue: { + type: Boolean + } +}) + +const userStore = useUserStore() +const avatar = computed(() => userStore.user.avatar ?? avatarImg) +const userName = computed(() => userStore.user.nickname ?? 'Admin') + +const emit = defineEmits(['update:modelValue']) + +const dialogVisible = computed({ + get: () => props.modelValue, + set: (val) => { + console.log('set: ', val) + emit('update:modelValue', val) + } +}) + +const dialogTitle = ref(t('lock.lockScreen')) + +const formData = ref({ + password: undefined +}) +const formRules = reactive({ + password: [required()] +}) + +const formRef = ref() // 表单 Ref +const handleLock = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + dialogVisible.value = false + lockStore.setLockInfo({ + ...formData.value, + isLock: true + }) +} +</script> + +<template> + <Dialog + v-model="dialogVisible" + width="500px" + max-height="170px" + :class="prefixCls" + :title="dialogTitle" + > + <div class="flex flex-col items-center"> + <img :src="avatar" alt="" class="w-70px h-70px rounded-[50%]" /> + <span class="text-14px my-10px text-[var(--top-header-text-color)]"> + {{ userName }} + </span> + </div> + <el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px"> + <el-form-item :label="t('lock.lockPassword')" prop="password"> + <el-input + type="password" + v-model="formData.password" + :placeholder="'请输入' + t('lock.lockPassword')" + clearable + show-password + /> + </el-form-item> + </el-form> + <template #footer> + <ElButton type="primary" @click="handleLock">{{ t('lock.lock') }}</ElButton> + </template> + </Dialog> +</template> + +<style lang="scss" scoped> +:global(.v-lock-dialog) { + @media (max-width: 767px) { + max-width: calc(100vw - 16px); + } +} +</style> diff --git a/src/layout/components/UserInfo/src/components/LockPage.vue b/src/layout/components/UserInfo/src/components/LockPage.vue new file mode 100644 index 0000000..e53443f --- /dev/null +++ b/src/layout/components/UserInfo/src/components/LockPage.vue @@ -0,0 +1,270 @@ +<script lang="ts" setup> +import { resetRouter } from '@/router' +import { deleteUserCache } from '@/hooks/web/useCache' +import { useLockStore } from '@/store/modules/lock' +import { useNow } from '@/hooks/web/useNow' +import { useDesign } from '@/hooks/web/useDesign' +import { useTagsViewStore } from '@/store/modules/tagsView' +import { useUserStore } from '@/store/modules/user' +import avatarImg from '@/assets/imgs/avatar.gif' + +const tagsViewStore = useTagsViewStore() + +const { replace } = useRouter() + +const userStore = useUserStore() + +const password = ref('') +const loading = ref(false) +const errMsg = ref(false) +const showDate = ref(true) + +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('lock-page') + +const avatar = computed(() => userStore.user.avatar ?? avatarImg) +const userName = computed(() => userStore.user.nickname ?? 'Admin') + +const lockStore = useLockStore() + +const { hour, month, minute, meridiem, year, day, week } = useNow(true) + +const { t } = useI18n() + +// 解锁 +async function unLock() { + if (!password.value) { + return + } + let pwd = password.value + try { + loading.value = true + const res = await lockStore.unLock(pwd) + errMsg.value = !res + } finally { + loading.value = false + } +} + +// 返回登录 +async function goLogin() { + await userStore.loginOut().catch(() => {}) + // 登出后清理 + deleteUserCache() // 清空用户缓存 + tagsViewStore.delAllViews() + // resetRouter() // 重置静态路由表 + lockStore.resetLockInfo() + replace('/login') +} + +function handleShowForm(show = false) { + showDate.value = show +} +</script> + +<template> + <div + :class="prefixCls" + class="fixed inset-0 flex h-screen w-screen bg-black items-center justify-center" + > + <div + :class="`${prefixCls}__unlock`" + class="absolute top-0 left-1/2 flex pt-5 h-16 items-center justify-center sm:text-md xl:text-xl text-white flex-col cursor-pointer transform translate-x-1/2" + @click="handleShowForm(false)" + v-show="showDate" + > + <Icon icon="ep:lock" /> + <span>{{ t('lock.unlock') }}</span> + </div> + + <div class="flex w-screen h-screen justify-center items-center"> + <div :class="`${prefixCls}__hour`" class="relative mr-5 md:mr-20 w-2/5 h-2/5 md:h-4/5"> + <span>{{ hour }}</span> + <span class="meridiem absolute left-5 top-5 text-md xl:text-xl" v-show="showDate"> + {{ meridiem }} + </span> + </div> + <div :class="`${prefixCls}__minute w-2/5 h-2/5 md:h-4/5 `"> + <span> {{ minute }}</span> + </div> + </div> + <transition name="fade-slide"> + <div :class="`${prefixCls}-entry`" v-show="!showDate"> + <div :class="`${prefixCls}-entry-content`"> + <div class="flex flex-col items-center"> + <img :src="avatar" alt="" class="w-70px h-70px rounded-[50%]" /> + <span class="text-14px my-10px text-[var(--logo-title-text-color)]"> + {{ userName }} + </span> + </div> + <ElInput + type="password" + :placeholder="t('lock.placeholder')" + class="enter-x" + v-model="password" + /> + <span :class="`text-14px ${prefixCls}-entry__err-msg enter-x`" v-if="errMsg"> + {{ t('lock.message') }} + </span> + <div :class="`${prefixCls}-entry__footer enter-x`"> + <ElButton + type="primary" + size="small" + class="mt-2 mr-2 enter-x" + link + :disabled="loading" + @click="handleShowForm(true)" + > + {{ t('common.back') }} + </ElButton> + <ElButton + type="primary" + size="small" + class="mt-2 mr-2 enter-x" + link + :disabled="loading" + @click="goLogin" + > + {{ t('lock.backToLogin') }} + </ElButton> + <ElButton + type="primary" + class="mt-2" + size="small" + link + @click="unLock()" + :disabled="loading" + > + {{ t('lock.entrySystem') }} + </ElButton> + </div> + </div> + </div> + </transition> + + <div class="absolute bottom-5 w-full text-gray-300 xl:text-xl 2xl:text-3xl text-center enter-y"> + <div class="text-5xl mb-4 enter-x" v-show="!showDate"> + {{ hour }}:{{ minute }} <span class="text-3xl">{{ meridiem }}</span> + </div> + <div class="text-2xl">{{ year }}/{{ month }}/{{ day }} {{ week }}</div> + </div> + </div> +</template> + +<style lang="scss" scoped> +$prefix-cls: '#{$namespace}-lock-page'; + +// Small screen / tablet +$screen-sm: 576px; + +// Medium screen / desktop +$screen-md: 768px; + +// Large screen / wide desktop +$screen-lg: 992px; + +// Extra large screen / full hd +$screen-xl: 1200px; + +// Extra extra large screen / large desktop +$screen-2xl: 1600px; + +$error-color: #ed6f6f; + +.#{$prefix-cls} { + z-index: 3000; + + &__unlock { + transform: translate(-50%, 0); + } + + &__hour, + &__minute { + display: flex; + font-weight: 700; + color: #bababa; + background-color: #141313; + border-radius: 30px; + justify-content: center; + align-items: center; + + @media screen and (max-width: $screen-md) { + span:not(.meridiem) { + font-size: 160px; + } + } + + @media screen and (min-width: $screen-md) { + span:not(.meridiem) { + font-size: 160px; + } + } + + @media screen and (max-width: $screen-sm) { + span:not(.meridiem) { + font-size: 90px; + } + } + @media screen and (min-width: $screen-lg) { + span:not(.meridiem) { + font-size: 220px; + } + } + + @media screen and (min-width: $screen-xl) { + span:not(.meridiem) { + font-size: 260px; + } + } + @media screen and (min-width: $screen-2xl) { + span:not(.meridiem) { + font-size: 320px; + } + } + } + + &-entry { + position: absolute; + top: 0; + left: 0; + display: flex; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(8px); + justify-content: center; + align-items: center; + + &-content { + width: 260px; + } + + &__header { + text-align: center; + + &-img { + width: 70px; + margin: 0 auto; + border-radius: 50%; + } + + &-name { + margin-top: 5px; + font-weight: 500; + color: #bababa; + } + } + + &__err-msg { + display: inline-block; + margin-top: 10px; + color: $error-color; + } + + &__footer { + display: flex; + justify-content: space-between; + } + } +} +</style> diff --git a/src/layout/components/useRenderLayout.tsx b/src/layout/components/useRenderLayout.tsx new file mode 100644 index 0000000..1110cd8 --- /dev/null +++ b/src/layout/components/useRenderLayout.tsx @@ -0,0 +1,306 @@ +import { computed } from 'vue' +import { useAppStore } from '@/store/modules/app' +import { Menu } from '@/layout/components/Menu' +import { TabMenu } from '@/layout/components/TabMenu' +import { TagsView } from '@/layout/components/TagsView' +import { Logo } from '@/layout/components/Logo' +import AppView from './AppView.vue' +import ToolHeader from './ToolHeader.vue' +import { ElScrollbar } from 'element-plus' +import { useDesign } from '@/hooks/web/useDesign' + +const { getPrefixCls } = useDesign() + +const prefixCls = getPrefixCls('layout') + +const appStore = useAppStore() + +const pageLoading = computed(() => appStore.getPageLoading) + +// 标签页 +const tagsView = computed(() => appStore.getTagsView) + +// 菜单折叠 +const collapse = computed(() => appStore.getCollapse) + +// logo +const logo = computed(() => appStore.logo) + +// 固定头部 +const fixedHeader = computed(() => appStore.getFixedHeader) + +// 是否是移动端 +const mobile = computed(() => appStore.getMobile) + +// 固定菜单 +const fixedMenu = computed(() => appStore.getFixedMenu) + +export const useRenderLayout = () => { + const renderClassic = () => { + return ( + <> + <div + class={[ + 'absolute top-0 left-0 h-full layout-border__right', + { '!fixed z-3000': mobile.value } + ]} + > + {logo.value ? ( + <Logo + class={[ + 'bg-[var(--left-menu-bg-color)] relative', + { + '!pl-0': mobile.value && collapse.value, + 'w-[var(--left-menu-min-width)]': appStore.getCollapse, + 'w-[var(--left-menu-max-width)]': !appStore.getCollapse + } + ]} + style="transition: all var(--transition-time-02);" + ></Logo> + ) : undefined} + <Menu class={[{ '!h-[calc(100%-var(--logo-height))]': logo.value }]}></Menu> + </div> + <div + class={[ + `${prefixCls}-content`, + 'absolute top-0 h-[100%]', + { + 'w-[calc(100%-var(--left-menu-min-width))] left-[var(--left-menu-min-width)]': + collapse.value && !mobile.value && !mobile.value, + 'w-[calc(100%-var(--left-menu-max-width))] left-[var(--left-menu-max-width)]': + !collapse.value && !mobile.value && !mobile.value, + 'fixed !w-full !left-0': mobile.value + } + ]} + style="transition: all var(--transition-time-02);" + > + <ElScrollbar + v-loading={pageLoading.value} + class={[ + `${prefixCls}-content-scrollbar`, + { + '!h-[calc(100%-var(--top-tool-height)-var(--tags-view-height))] mt-[calc(var(--top-tool-height)+var(--tags-view-height))]': + fixedHeader.value + } + ]} + > + <div + class={[ + { + 'fixed top-0 left-0 z-10': fixedHeader.value, + 'w-[calc(100%-var(--left-menu-min-width))] !left-[var(--left-menu-min-width)]': + collapse.value && fixedHeader.value && !mobile.value, + 'w-[calc(100%-var(--left-menu-max-width))] !left-[var(--left-menu-max-width)]': + !collapse.value && fixedHeader.value && !mobile.value, + '!w-full !left-0': mobile.value + } + ]} + style="transition: all var(--transition-time-02);" + > + <ToolHeader + class={[ + 'bg-[var(--top-header-bg-color)]', + { + 'layout-border__bottom': !tagsView.value + } + ]} + ></ToolHeader> + + {tagsView.value ? ( + <TagsView class="layout-border__top layout-border__bottom"></TagsView> + ) : undefined} + </div> + + <AppView></AppView> + </ElScrollbar> + </div> + </> + ) + } + + const renderTopLeft = () => { + return ( + <> + <div class="relative flex items-center bg-[var(--top-header-bg-color)] layout-border__bottom dark:bg-[var(--el-bg-color)]"> + {logo.value ? <Logo class="custom-hover"></Logo> : undefined} + + <ToolHeader class="flex-1"></ToolHeader> + </div> + <div class="absolute left-0 top-[var(--logo-height)+1px] h-[calc(100%-1px-var(--logo-height))] w-full flex"> + <Menu class="relative layout-border__right !h-full"></Menu> + <div + class={[ + `${prefixCls}-content`, + 'h-[100%]', + { + 'w-[calc(100%-var(--left-menu-min-width))] left-[var(--left-menu-min-width)]': + collapse.value, + 'w-[calc(100%-var(--left-menu-max-width))] left-[var(--left-menu-max-width)]': + !collapse.value + } + ]} + style="transition: all var(--transition-time-02);" + > + <ElScrollbar + v-loading={pageLoading.value} + class={[ + `${prefixCls}-content-scrollbar`, + { + '!h-[calc(100%-var(--tags-view-height))] mt-[calc(var(--tags-view-height))]': + fixedHeader.value && tagsView.value + } + ]} + > + {tagsView.value ? ( + <TagsView + class={[ + 'layout-border__bottom absolute', + { + '!fixed top-0 left-0 z-10': fixedHeader.value, + 'w-[calc(100%-var(--left-menu-min-width))] !left-[var(--left-menu-min-width)] mt-[calc(var(--logo-height)+1px)]': + collapse.value && fixedHeader.value, + 'w-[calc(100%-var(--left-menu-max-width))] !left-[var(--left-menu-max-width)] mt-[calc(var(--logo-height)+1px)]': + !collapse.value && fixedHeader.value + } + ]} + style="transition: width var(--transition-time-02), left var(--transition-time-02);" + ></TagsView> + ) : undefined} + + <AppView></AppView> + </ElScrollbar> + </div> + </div> + </> + ) + } + + const renderTop = () => { + return ( + <> + <div + class={[ + 'flex items-center justify-between bg-[var(--top-header-bg-color)] relative', + { + 'layout-border__bottom': !tagsView.value + } + ]} + > + {logo.value ? <Logo class="custom-hover"></Logo> : undefined} + <Menu class="h-[var(--top-tool-height)] flex-1 px-10px"></Menu> + <ToolHeader></ToolHeader> + </div> + <div + class={[ + `${prefixCls}-content`, + 'w-full', + { + 'h-[calc(100%-var(--app-footer-height))]': !fixedHeader.value, + 'h-[calc(100%-var(--tags-view-height)-var(--app-footer-height))]': fixedHeader.value + } + ]} + > + <ElScrollbar + v-loading={pageLoading.value} + class={[ + `${prefixCls}-content-scrollbar`, + { + 'mt-[var(--tags-view-height)] !pb-[calc(var(--tags-view-height)+var(--app-footer-height))]': + fixedHeader.value, + 'pb-[var(--app-footer-height)]': !fixedHeader.value + } + ]} + > + {tagsView.value ? ( + <TagsView + class={[ + 'layout-border__bottom layout-border__top relative', + { + '!fixed w-full top-[calc(var(--top-tool-height)+1px)] left-0': fixedHeader.value + } + ]} + style="transition: width var(--transition-time-02), left var(--transition-time-02);" + ></TagsView> + ) : undefined} + + <AppView></AppView> + </ElScrollbar> + </div> + </> + ) + } + + const renderCutMenu = () => { + return ( + <> + <div class="relative flex items-center bg-[var(--top-header-bg-color)] layout-border__bottom"> + {logo.value ? <Logo class="custom-hover !pr-15px"></Logo> : undefined} + + <ToolHeader class="flex-1"></ToolHeader> + </div> + <div class="absolute left-0 top-[var(--logo-height)] h-[calc(100%-var(--logo-height))] w-[calc(100%-2px)] flex"> + <TabMenu></TabMenu> + <div + class={[ + `${prefixCls}-content`, + 'h-[100%]', + { + 'w-[calc(100%-var(--tab-menu-min-width))] left-[var(--tab-menu-min-width)]': + collapse.value && !fixedMenu.value, + 'w-[calc(100%-var(--tab-menu-max-width))] left-[var(--tab-menu-max-width)]': + !collapse.value && !fixedMenu.value, + 'w-[calc(100%-var(--tab-menu-min-width)-var(--left-menu-max-width))] ml-[var(--left-menu-max-width)]': + collapse.value && fixedMenu.value, + 'w-[calc(100%-var(--tab-menu-max-width)-var(--left-menu-max-width))] ml-[var(--left-menu-max-width)]': + !collapse.value && fixedMenu.value + } + ]} + style="transition: all var(--transition-time-02);" + > + <ElScrollbar + v-loading={pageLoading.value} + class={[ + `${prefixCls}-content-scrollbar`, + { + '!h-[calc(100%-var(--tags-view-height))] mt-[calc(var(--tags-view-height))]': + fixedHeader.value && tagsView.value + } + ]} + > + {tagsView.value ? ( + <TagsView + class={[ + 'relative layout-border__bottom layout-border__top', + { + '!fixed top-0 left-0 z-10': fixedHeader.value, + 'w-[calc(100%-var(--tab-menu-min-width))] !left-[var(--tab-menu-min-width)] mt-[var(--logo-height)]': + collapse.value && fixedHeader.value, + 'w-[calc(100%-var(--tab-menu-max-width))] !left-[var(--tab-menu-max-width)] mt-[var(--logo-height)]': + !collapse.value && fixedHeader.value, + '!fixed top-0 !left-[var(--tab-menu-min-width)+var(--left-menu-max-width)] z-10': + fixedHeader.value && fixedMenu.value, + 'w-[calc(100%-var(--tab-menu-min-width)-var(--left-menu-max-width))] !left-[var(--tab-menu-min-width)+var(--left-menu-max-width)] mt-[var(--logo-height)]': + collapse.value && fixedHeader.value && fixedMenu.value, + 'w-[calc(100%-var(--tab-menu-max-width)-var(--left-menu-max-width))] !left-[var(--tab-menu-max-width)+var(--left-menu-max-width)] mt-[var(--logo-height)]': + !collapse.value && fixedHeader.value && fixedMenu.value + } + ]} + style="transition: width var(--transition-time-02), left var(--transition-time-02);" + ></TagsView> + ) : undefined} + + <AppView></AppView> + </ElScrollbar> + </div> + </div> + </> + ) + } + + return { + renderClassic, + renderTopLeft, + renderTop, + renderCutMenu + } +} diff --git a/src/locales/en.ts b/src/locales/en.ts new file mode 100644 index 0000000..6562c9b --- /dev/null +++ b/src/locales/en.ts @@ -0,0 +1,457 @@ +export default { + common: { + inputText: 'Please input', + selectText: 'Please select', + startTimeText: 'Start time', + endTimeText: 'End time', + login: 'Login', + required: 'This is required', + loginOut: 'Login out', + document: 'Document', + profile: 'User Center', + reminder: 'Reminder', + loginOutMessage: 'Exit the system?', + back: 'Back', + ok: 'OK', + save: 'Save', + cancel: 'Cancel', + close: 'Close', + reload: 'Reload current', + success: 'Success', + closeTab: 'Close current', + closeTheLeftTab: 'Close left', + closeTheRightTab: 'Close right', + closeOther: 'Close other', + closeAll: 'Close all', + prevLabel: 'Prev', + nextLabel: 'Next', + skipLabel: 'Jump', + doneLabel: 'End', + menu: 'Menu', + menuDes: 'Menu bar rendered in routed structure', + collapse: 'Collapse', + collapseDes: 'Expand and zoom the menu bar', + tagsView: 'Tags view', + tagsViewDes: 'Used to record routing history', + tool: 'Tool', + toolDes: 'Used to set up custom systems', + query: 'Query', + reset: 'Reset', + shrink: 'Put away', + expand: 'Expand', + confirmTitle: 'System Hint', + exportMessage: 'Whether to confirm export data item?', + importMessage: 'Whether to confirm import data item?', + createSuccess: 'Create Success', + updateSuccess: 'Update Success', + delMessage: 'Delete the selected data?', + delDataMessage: 'Delete the data?', + delNoData: 'Please select the data to delete', + delSuccess: 'Deleted successfully', + index: 'Index', + status: 'Status', + createTime: 'Create Time', + updateTime: 'Update Time', + copy: 'Copy', + copySuccess: 'Copy Success', + copyError: 'Copy Error' + }, + lock: { + lockScreen: 'Lock screen', + lock: 'Lock', + lockPassword: 'Lock screen password', + unlock: 'Click to unlock', + backToLogin: 'Back to login', + entrySystem: 'Entry the system', + placeholder: 'Please enter the lock screen password', + message: 'Lock screen password error' + }, + error: { + noPermission: `Sorry, you don't have permission to access this page.`, + pageError: 'Sorry, the page you visited does not exist.', + networkError: 'Sorry, the server reported an error.', + returnToHome: 'Return to home' + }, + permission: { + hasPermission: `Please set the operation permission label value`, + hasRole: `Please set the role permission tag value` + }, + setting: { + projectSetting: 'Project setting', + theme: 'Theme', + layout: 'Layout', + systemTheme: 'System theme', + menuTheme: 'Menu theme', + interfaceDisplay: 'Interface display', + breadcrumb: 'Breadcrumb', + breadcrumbIcon: 'Breadcrumb icon', + collapseMenu: 'Collapse menu', + hamburgerIcon: 'Hamburger icon', + screenfullIcon: 'Screenfull icon', + sizeIcon: 'Size icon', + localeIcon: 'Locale icon', + messageIcon: 'Message icon', + tagsView: 'Tags view', + logo: 'Logo', + greyMode: 'Grey mode', + fixedHeader: 'Fixed header', + headerTheme: 'Header theme', + cutMenu: 'Cut Menu', + copy: 'Copy', + clearAndReset: 'Clear cache and reset', + copySuccess: 'Copy success', + copyFailed: 'Copy failed', + footer: 'Footer', + uniqueOpened: 'Unique opened', + tagsViewIcon: 'Tags view icon', + reExperienced: 'Please exit the login experience again', + fixedMenu: 'Fixed menu' + }, + size: { + default: 'Default', + large: 'Large', + small: 'Small' + }, + login: { + welcome: 'Welcome to the system', + message: 'Backstage management system', + tenantname: 'TenantName', + username: 'Username', + password: 'Password', + code: 'verification code', + login: 'Sign in', + relogin: 'Sign in again', + otherLogin: 'Sign in with', + register: 'Register', + checkPassword: 'Confirm password', + remember: 'Remember me', + hasUser: 'Existing account? Go to login', + forgetPassword: 'Forget password?', + tenantNamePlaceholder: 'Please Enter Tenant Name', + usernamePlaceholder: 'Please Enter Username', + passwordPlaceholder: 'Please Enter Password', + codePlaceholder: 'Please Enter Verification Code', + mobileTitle: 'Mobile sign in', + mobileNumber: 'Mobile Number', + mobileNumberPlaceholder: 'Plaease Enter Mobile Number', + backLogin: 'back', + getSmsCode: 'Get SMS Code', + btnMobile: 'Mobile sign in', + btnQRCode: 'QR code sign in', + qrcode: 'Scan the QR code to log in', + btnRegister: 'Sign up', + SmsSendMsg: 'code has been sent' + }, + captcha: { + verification: 'Please complete security verification', + slide: 'Swipe right to complete verification', + point: 'Please click', + success: 'Verification succeeded', + fail: 'verification failed' + }, + router: { + login: 'Login', + home: 'Home', + analysis: 'Analysis', + workplace: 'Workplace' + }, + analysis: { + newUser: 'New user', + unreadInformation: 'Unread information', + transactionAmount: 'Transaction amount', + totalShopping: 'Total Shopping', + monthlySales: 'Monthly sales', + userAccessSource: 'User access source', + january: 'January', + february: 'February', + march: 'March', + april: 'April', + may: 'May', + june: 'June', + july: 'July', + august: 'August', + september: 'September', + october: 'October', + november: 'November', + december: 'December', + estimate: 'Estimate', + actual: 'Actual', + directAccess: 'Airect access', + mailMarketing: 'Mail marketing', + allianceAdvertising: 'Alliance advertising', + videoAdvertising: 'Video advertising', + searchEngines: 'Search engines', + weeklyUserActivity: 'Weekly user activity', + activeQuantity: 'Active quantity', + monday: 'Monday', + tuesday: 'Tuesday', + wednesday: 'Wednesday', + thursday: 'Thursday', + friday: 'Friday', + saturday: 'Saturday', + sunday: 'Sunday' + }, + workplace: { + welcome: 'Hello', + happyDay: 'Wish you happy every day!', + toady: `It's sunny today`, + notice: 'Announcement', + project: 'Project', + access: 'Project access', + toDo: 'To do', + introduction: 'A serious introduction', + shortcutOperation: 'Quick entry', + operation: 'Operation', + index: 'Index', + personal: 'Personal', + team: 'Team', + quote: 'Quote', + contribution: 'Contribution', + hot: 'Hot', + yield: 'Yield', + dynamic: 'Dynamic', + push: 'push', + follow: 'Follow' + }, + form: { + input: 'Input', + inputNumber: 'InputNumber', + default: 'Default', + icon: 'Icon', + mixed: 'Mixed', + textarea: 'Textarea', + slot: 'Slot', + position: 'Position', + autocomplete: 'Autocomplete', + select: 'Select', + selectGroup: 'Select Group', + selectV2: 'SelectV2', + cascader: 'Cascader', + switch: 'Switch', + rate: 'Rate', + colorPicker: 'Color Picker', + transfer: 'Transfer', + render: 'Render', + radio: 'Radio', + button: 'Button', + checkbox: 'Checkbox', + slider: 'Slider', + datePicker: 'Date Picker', + shortcuts: 'Shortcuts', + today: 'Today', + yesterday: 'Yesterday', + aWeekAgo: 'A week ago', + week: 'Week', + year: 'Year', + month: 'Month', + dates: 'Dates', + daterange: 'Date Range', + monthrange: 'Month Range', + dateTimePicker: 'DateTimePicker', + dateTimerange: 'Datetime Range', + timePicker: 'Time Picker', + timeSelect: 'Time Select', + inputPassword: 'input Password', + passwordStrength: 'Password Strength', + operate: 'operate', + change: 'Change', + restore: 'Restore', + disabled: 'Disabled', + disablement: 'Disablement', + delete: 'Delete', + add: 'Add', + setValue: 'Set value', + resetValue: 'Reset value', + set: 'Set', + subitem: 'Subitem', + formValidation: 'Form validation', + verifyReset: 'Verify reset', + remark: 'Remark' + }, + watermark: { + watermark: 'Watermark' + }, + table: { + table: 'Table', + index: 'Index', + title: 'Title', + author: 'Author', + createTime: 'Create time', + action: 'Action', + pagination: 'pagination', + reserveIndex: 'Reserve index', + restoreIndex: 'Restore index', + showSelections: 'Show selections', + hiddenSelections: 'Restore selections', + showExpandedRows: 'Show expanded rows', + hiddenExpandedRows: 'Hidden expanded rows', + header: 'Header' + }, + action: { + create: 'Create', + add: 'Add', + del: 'Delete', + delete: 'Delete', + edit: 'Edit', + update: 'Update', + preview: 'Preview', + more: 'More', + sync: 'Sync', + save: 'Save', + detail: 'Detail', + export: 'Export', + import: 'Import', + generate: 'Generate', + logout: 'Login Out', + test: 'Test', + typeCreate: 'Dict Type Create', + typeUpdate: 'Dict Type Eidt', + dataCreate: 'Dict Data Create', + dataUpdate: 'Dict Data Eidt', + fileUpload: 'File Upload' + }, + dialog: { + dialog: 'Dialog', + open: 'Open', + close: 'Close' + }, + sys: { + api: { + operationFailed: 'Operation failed', + errorTip: 'Error Tip', + errorMessage: 'The operation failed, the system is abnormal!', + timeoutMessage: 'Login timed out, please log in again!', + apiTimeoutMessage: 'The interface request timed out, please refresh the page and try again!', + apiRequestFailed: 'The interface request failed, please try again later!', + networkException: 'network anomaly', + networkExceptionMsg: + 'Please check if your network connection is normal! The network is abnormal', + + errMsg401: 'The user does not have permission (token, user name, password error)!', + errMsg403: 'The user is authorized, but access is forbidden!', + errMsg404: 'Network request error, the resource was not found!', + errMsg405: 'Network request error, request method not allowed!', + errMsg408: 'Network request timed out!', + errMsg500: 'Server error, please contact the administrator!', + errMsg501: 'The network is not implemented!', + errMsg502: 'Network Error!', + errMsg503: 'The service is unavailable, the server is temporarily overloaded or maintained!', + errMsg504: 'Network timeout!', + errMsg505: 'The http version does not support the request!', + errMsg901: 'Demo mode, no write operations are possible!' + }, + app: { + logoutTip: 'Reminder', + logoutMessage: 'Confirm to exit the system?', + menuLoading: 'Menu loading...' + }, + exception: { + backLogin: 'Back Login', + backHome: 'Back Home', + subTitle403: "Sorry, you don't have access to this page.", + subTitle404: 'Sorry, the page you visited does not exist.', + subTitle500: 'Sorry, the server is reporting an error.', + noDataTitle: 'No data on the current page.', + networkErrorTitle: 'Network Error', + networkErrorSubTitle: + 'Sorry, Your network connection has been disconnected, please check your network!' + }, + lock: { + unlock: 'Click to unlock', + alert: 'Lock screen password error', + backToLogin: 'Back to login', + entry: 'Enter the system', + placeholder: 'Please enter the lock screen password or user password' + }, + login: { + backSignIn: 'Back sign in', + mobileSignInFormTitle: 'Mobile sign in', + qrSignInFormTitle: 'Qr code sign in', + signInFormTitle: 'Sign in', + signUpFormTitle: 'Sign up', + forgetFormTitle: 'Reset password', + + signInTitle: 'Backstage management system', + signInDesc: 'Enter your personal details and get started!', + policy: 'I agree to the xxx Privacy Policy', + scanSign: `scanning the code to complete the login`, + + loginButton: 'Sign in', + registerButton: 'Sign up', + rememberMe: 'Remember me', + forgetPassword: 'Forget Password?', + otherSignIn: 'Sign in with', + + // notify + loginSuccessTitle: 'Login successful', + loginSuccessDesc: 'Welcome back', + + // placeholder + accountPlaceholder: 'Please input username', + passwordPlaceholder: 'Please input password', + smsPlaceholder: 'Please input sms code', + mobilePlaceholder: 'Please input mobile', + policyPlaceholder: 'Register after checking', + diffPwd: 'The two passwords are inconsistent', + + userName: 'Username', + password: 'Password', + confirmPassword: 'Confirm Password', + email: 'Email', + smsCode: 'SMS code', + mobile: 'Mobile' + } + }, + profile: { + user: { + title: 'Personal Information', + username: 'User Name', + nickname: 'Nick Name', + mobile: 'Phone Number', + email: 'User Mail', + dept: 'Department', + posts: 'Position', + roles: 'Own Role', + sex: 'Sex', + man: 'Man', + woman: 'Woman', + createTime: 'Created Date' + }, + info: { + title: 'Basic Information', + basicInfo: 'Basic Information', + resetPwd: 'Reset Password', + userSocial: 'Social Information' + }, + rules: { + nickname: 'Please Enter User Nickname', + mail: 'Please Input The Email Address', + truemail: 'Please Input The Correct Email Address', + phone: 'Please Enter The Phone Number', + truephone: 'Please Enter The Correct Phone Number' + }, + password: { + oldPassword: 'Old PassWord', + newPassword: 'New Password', + confirmPassword: 'Confirm Password', + oldPwdMsg: 'Please Enter Old Password', + newPwdMsg: 'Please Enter New Password', + cfPwdMsg: 'Please Enter Confirm Password', + diffPwd: 'The Passwords Entered Twice No Match' + } + }, + cropper: { + selectImage: 'Select Image', + uploadSuccess: 'Uploaded success!', + modalTitle: 'Avatar upload', + okText: 'Confirm and upload', + btn_reset: 'Reset', + btn_rotate_left: 'Counterclockwise rotation', + btn_rotate_right: 'Clockwise rotation', + btn_scale_x: 'Flip horizontal', + btn_scale_y: 'Flip vertical', + btn_zoom_in: 'Zoom in', + btn_zoom_out: 'Zoom out', + preview: 'Preivew' + } +} diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts new file mode 100644 index 0000000..0721651 --- /dev/null +++ b/src/locales/zh-CN.ts @@ -0,0 +1,452 @@ +export default { + common: { + inputText: '请输入', + selectText: '请选择', + startTimeText: '开始时间', + endTimeText: '结束时间', + login: '登录', + required: '该项为必填项', + loginOut: '退出系统', + document: '项目文档', + profile: '个人中心', + reminder: '温馨提示', + loginOutMessage: '是否退出本系统?', + back: '返回', + ok: '确定', + save: '保存', + cancel: '取消', + close: '关闭', + reload: '重新加载', + success: '成功', + closeTab: '关闭标签页', + closeTheLeftTab: '关闭左侧标签页', + closeTheRightTab: '关闭右侧标签页', + closeOther: '关闭其他标签页', + closeAll: '关闭全部标签页', + prevLabel: '上一步', + nextLabel: '下一步', + skipLabel: '跳过', + doneLabel: '结束', + menu: '菜单', + menuDes: '以路由的结构渲染的菜单栏', + collapse: '展开缩收', + collapseDes: '展开和缩放菜单栏', + tagsView: '标签页', + tagsViewDes: '用于记录路由历史记录', + tool: '工具', + toolDes: '用于设置定制系统', + query: '查询', + reset: '重置', + shrink: '收起', + expand: '展开', + confirmTitle: '系统提示', + exportMessage: '是否确认导出数据项?', + importMessage: '是否确认导入数据项?', + createSuccess: '新增成功', + updateSuccess: '修改成功', + delMessage: '是否删除所选中数据?', + delDataMessage: '是否删除数据?', + delNoData: '请选择需要删除的数据', + delSuccess: '删除成功', + index: '序号', + status: '状态', + createTime: '创建时间', + updateTime: '更新时间', + copy: '复制', + copySuccess: '复制成功', + copyError: '复制失败' + }, + lock: { + lockScreen: '锁定屏幕', + lock: '锁定', + lockPassword: '锁屏密码', + unlock: '点击解锁', + backToLogin: '返回登录', + entrySystem: '进入系统', + placeholder: '请输入锁屏密码', + message: '锁屏密码错误' + }, + error: { + noPermission: `抱歉,您无权访问此页面。`, + pageError: '抱歉,您访问的页面不存在。', + networkError: '抱歉,服务器报告错误。', + returnToHome: '返回首页' + }, + permission: { + hasPermission: `请设置操作权限标签值`, + hasRole: `请设置角色权限标签值` + }, + setting: { + projectSetting: '项目配置', + theme: '主题', + layout: '布局', + systemTheme: '系统主题', + menuTheme: '菜单主题', + interfaceDisplay: '界面显示', + breadcrumb: '面包屑', + breadcrumbIcon: '面包屑图标', + collapseMenu: '折叠菜单', + hamburgerIcon: '折叠图标', + screenfullIcon: '全屏图标', + sizeIcon: '尺寸图标', + localeIcon: '多语言图标', + messageIcon: '消息图标', + tagsView: '标签页', + logo: '标志', + greyMode: '灰色模式', + fixedHeader: '固定头部', + headerTheme: '头部主题', + cutMenu: '切割菜单', + copy: '拷贝', + clearAndReset: '清除缓存并且重置', + copySuccess: '拷贝成功', + copyFailed: '拷贝失败', + footer: '页脚', + uniqueOpened: '菜单手风琴', + tagsViewIcon: '标签页图标', + reExperienced: '请重新退出登录体验', + fixedMenu: '固定菜单' + }, + size: { + default: '默认', + large: '大', + small: '小' + }, + login: { + welcome: '欢迎使用本系统', + message: '开箱即用的中后台管理系统', + tenantname: '租户名称', + username: '用户名', + password: '密码', + code: '验证码', + login: '登录', + relogin: '重新登录', + otherLogin: '其他登录方式', + register: '注册', + checkPassword: '确认密码', + remember: '记住我', + hasUser: '已有账号?去登录', + forgetPassword: '忘记密码?', + tenantNamePlaceholder: '请输入租户名称', + usernamePlaceholder: '请输入用户名', + passwordPlaceholder: '请输入密码', + codePlaceholder: '请输入验证码', + mobileTitle: '手机登录', + mobileNumber: '手机号码', + mobileNumberPlaceholder: '请输入手机号码', + backLogin: '返回', + getSmsCode: '获取验证码', + btnMobile: '手机登录', + btnQRCode: '二维码登录', + qrcode: '扫描二维码登录', + btnRegister: '注册', + SmsSendMsg: '验证码已发送' + }, + captcha: { + verification: '请完成安全验证', + slide: '向右滑动完成验证', + point: '请依次点击', + success: '验证成功', + fail: '验证失败' + }, + router: { + login: '登录', + socialLogin: '社交登录', + home: '首页', + analysis: '分析页', + workplace: '工作台' + }, + analysis: { + newUser: '新增用户', + unreadInformation: '未读消息', + transactionAmount: '成交金额', + totalShopping: '购物总量', + monthlySales: '每月销售额', + userAccessSource: '用户访问来源', + january: '一月', + february: '二月', + march: '三月', + april: '四月', + may: '五月', + june: '六月', + july: '七月', + august: '八月', + september: '九月', + october: '十月', + november: '十一月', + december: '十二月', + estimate: '预计', + actual: '实际', + directAccess: '直接访问', + mailMarketing: '邮件营销', + allianceAdvertising: '联盟广告', + videoAdvertising: '视频广告', + searchEngines: '搜索引擎', + weeklyUserActivity: '每周用户活跃量', + activeQuantity: '活跃量', + monday: '周一', + tuesday: '周二', + wednesday: '周三', + thursday: '周四', + friday: '周五', + saturday: '周六', + sunday: '周日' + }, + workplace: { + welcome: '你好', + happyDay: '祝你开心每一天!', + toady: '今日晴', + notice: '通知公告', + project: '项目数', + access: '项目访问', + toDo: '待办', + introduction: '一个正经的简介', + shortcutOperation: '快捷入口', + operation: '操作', + index: '指数', + personal: '个人', + team: '团队', + quote: '引用', + contribution: '贡献', + hot: '热度', + yield: '产量', + dynamic: '动态', + push: '推送', + follow: '关注' + }, + form: { + input: '输入框', + inputNumber: '数字输入框', + default: '默认', + icon: '图标', + mixed: '复合型', + textarea: '多行文本', + slot: '插槽', + position: '位置', + autocomplete: '自动补全', + select: '选择器', + selectGroup: '选项分组', + selectV2: '虚拟列表选择器', + cascader: '级联选择器', + switch: '开关', + rate: '评分', + colorPicker: '颜色选择器', + transfer: '穿梭框', + render: '渲染器', + radio: '单选框', + button: '按钮', + checkbox: '多选框', + slider: '滑块', + datePicker: '日期选择器', + shortcuts: '快捷选项', + today: '今天', + yesterday: '昨天', + aWeekAgo: '一周前', + week: '周', + year: '年', + month: '月', + dates: '日期', + daterange: '日期范围', + monthrange: '月份范围', + dateTimePicker: '日期时间选择器', + dateTimerange: '日期时间范围', + timePicker: '时间选择器', + timeSelect: '时间选择', + inputPassword: '密码输入框', + passwordStrength: '密码强度', + operate: '操作', + change: '更改', + restore: '还原', + disabled: '禁用', + disablement: '解除禁用', + delete: '删除', + add: '添加', + setValue: '设置值', + resetValue: '重置值', + set: '设置', + subitem: '子项', + formValidation: '表单验证', + verifyReset: '验证重置', + remark: '备注' + }, + watermark: { + watermark: '水印' + }, + table: { + table: '表格', + index: '序号', + title: '标题', + author: '作者', + createTime: '创建时间', + action: '操作', + pagination: '分页', + reserveIndex: '叠加序号', + restoreIndex: '还原序号', + showSelections: '显示多选', + hiddenSelections: '隐藏多选', + showExpandedRows: '显示展开行', + hiddenExpandedRows: '隐藏展开行', + header: '头部' + }, + action: { + create: '新增', + add: '新增', + del: '删除', + delete: '删除', + edit: '编辑', + update: '编辑', + preview: '预览', + more: '更多', + sync: '同步', + save: '保存', + detail: '详情', + export: '导出', + import: '导入', + generate: '生成', + logout: '强制退出', + test: '测试', + typeCreate: '字典类型新增', + typeUpdate: '字典类型编辑', + dataCreate: '字典数据新增', + dataUpdate: '字典数据编辑' + }, + dialog: { + dialog: '弹窗', + open: '打开', + close: '关闭' + }, + sys: { + api: { + operationFailed: '操作失败', + errorTip: '错误提示', + errorMessage: '操作失败,系统异常!', + timeoutMessage: '登录超时,请重新登录!', + apiTimeoutMessage: '接口请求超时,请刷新页面重试!', + apiRequestFailed: '请求出错,请稍候重试', + networkException: '网络异常', + networkExceptionMsg: '网络异常,请检查您的网络连接是否正常!', + errMsg401: '用户没有权限(令牌、用户名、密码错误)!', + errMsg403: '用户得到授权,但是访问是被禁止的。!', + errMsg404: '网络请求错误,未找到该资源!', + errMsg405: '网络请求错误,请求方法未允许!', + errMsg408: '网络请求超时!', + errMsg500: '服务器错误,请联系管理员!', + errMsg501: '网络未实现!', + errMsg502: '网络错误!', + errMsg503: '服务不可用,服务器暂时过载或维护!', + errMsg504: '网络超时!', + errMsg505: 'http版本不支持该请求!', + errMsg901: '演示模式,无法进行写操作!' + }, + app: { + logoutTip: '温馨提醒', + logoutMessage: '是否确认退出系统?', + menuLoading: '菜单加载中...' + }, + exception: { + backLogin: '返回登录', + backHome: '返回首页', + subTitle403: '抱歉,您无权访问此页面。', + subTitle404: '抱歉,您访问的页面不存在。', + subTitle500: '抱歉,服务器报告错误。', + noDataTitle: '当前页无数据', + networkErrorTitle: '网络错误', + networkErrorSubTitle: '抱歉,您的网络连接已断开,请检查您的网络!' + }, + lock: { + unlock: '点击解锁', + alert: '锁屏密码错误', + backToLogin: '返回登录', + entry: '进入系统', + placeholder: '请输入锁屏密码或者用户密码' + }, + login: { + backSignIn: '返回', + signInFormTitle: '登录', + ssoFormTitle: '三方授权', + mobileSignInFormTitle: '手机登录', + qrSignInFormTitle: '二维码登录', + signUpFormTitle: '注册', + forgetFormTitle: '重置密码', + signInTitle: '开箱即用的中后台管理系统', + signInDesc: '输入您的个人详细信息开始使用!', + policy: '我同意xxx隐私政策', + scanSign: `扫码后点击"确认",即可完成登录`, + loginButton: '登录', + registerButton: '注册', + rememberMe: '记住我', + forgetPassword: '忘记密码?', + otherSignIn: '其他登录方式', + // notify + loginSuccessTitle: '登录成功', + loginSuccessDesc: '欢迎回来', + // placeholder + accountPlaceholder: '请输入账号', + passwordPlaceholder: '请输入密码', + smsPlaceholder: '请输入验证码', + mobilePlaceholder: '请输入手机号码', + policyPlaceholder: '勾选后才能注册', + diffPwd: '两次输入密码不一致', + userName: '账号', + password: '密码', + confirmPassword: '确认密码', + email: '邮箱', + smsCode: '短信验证码', + mobile: '手机号码' + } + }, + profile: { + user: { + title: '个人信息', + username: '用户名称', + nickname: '用户昵称', + mobile: '手机号码', + email: '用户邮箱', + dept: '所属部门', + posts: '所属岗位', + roles: '所属角色', + sex: '性别', + man: '男', + woman: '女', + createTime: '创建日期' + }, + info: { + title: '基本信息', + basicInfo: '基本资料', + resetPwd: '修改密码', + userSocial: '社交信息' + }, + rules: { + nickname: '请输入用户昵称', + mail: '请输入邮箱地址', + truemail: '请输入正确的邮箱地址', + phone: '请输入正确的手机号码', + truephone: '请输入正确的手机号码' + }, + password: { + oldPassword: '旧密码', + newPassword: '新密码', + confirmPassword: '确认密码', + oldPwdMsg: '请输入旧密码', + newPwdMsg: '请输入新密码', + cfPwdMsg: '请输入确认密码', + pwdRules: '长度在 6 到 20 个字符', + diffPwd: '两次输入密码不一致' + } + }, + cropper: { + selectImage: '选择图片', + uploadSuccess: '上传成功', + modalTitle: '头像上传', + okText: '确认并上传', + btn_reset: '重置', + btn_rotate_left: '逆时针旋转', + btn_rotate_right: '顺时针旋转', + btn_scale_x: '水平翻转', + btn_scale_y: '垂直翻转', + btn_zoom_in: '放大', + btn_zoom_out: '缩小', + preview: '预览' + }, + 'OAuth 2.0': 'OAuth 2.0' // 避免菜单名是 OAuth 2.0 时,一直 warn 报错 +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..76c7247 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,72 @@ +// 引入unocss css +import '@/plugins/unocss' + +// 导入全局的svg图标 +import '@/plugins/svgIcon' + +// 初始化多语言 +import { setupI18n } from '@/plugins/vueI18n' + +// 引入状态管理 +import { setupStore } from '@/store' + +// 全局组件 +import { setupGlobCom } from '@/components' + +// 引入 element-plus +import { setupElementPlus } from '@/plugins/elementPlus' + +// 引入 form-create +import { setupFormCreate } from '@/plugins/formCreate' + +// 引入全局样式 +import '@/styles/index.scss' + +// 引入动画 +import '@/plugins/animate.css' + +// 路由 +import router, { setupRouter } from '@/router' + +// 权限 +import { setupAuth } from '@/directives' + +import { createApp } from 'vue' + +import App from './App.vue' + +import './permission' + +import '@/plugins/tongji' // 百度统计 +import Logger from '@/utils/Logger' + +import VueDOMPurifyHTML from 'vue-dompurify-html' // 解决v-html 的安全隐患 + +// 创建实例 +const setupAll = async () => { + const app = createApp(App) + + await setupI18n(app) + + setupStore(app) + + setupGlobCom(app) + + setupElementPlus(app) + + setupFormCreate(app) + + setupRouter(app) + + setupAuth(app) + + await router.isReady() + + app.use(VueDOMPurifyHTML) + + app.mount('#app') +} + +setupAll() + +Logger.prettyPrimary(`欢迎使用`, import.meta.env.VITE_APP_TITLE) diff --git a/src/permission.ts b/src/permission.ts new file mode 100644 index 0000000..b04bc3c --- /dev/null +++ b/src/permission.ts @@ -0,0 +1,106 @@ +import router from './router' +import type { RouteRecordRaw } from 'vue-router' +import { isRelogin } from '@/config/axios/service' +import { getAccessToken } from '@/utils/auth' +import { useTitle } from '@/hooks/web/useTitle' +import { useNProgress } from '@/hooks/web/useNProgress' +import { usePageLoading } from '@/hooks/web/usePageLoading' +import { useDictStoreWithOut } from '@/store/modules/dict' +import { useUserStoreWithOut } from '@/store/modules/user' +import { usePermissionStoreWithOut } from '@/store/modules/permission' + +const { start, done } = useNProgress() + +const { loadStart, loadDone } = usePageLoading() + +const parseURL = ( + url: string | null | undefined +): { basePath: string; paramsObject: { [key: string]: string } } => { + // 如果输入为 null 或 undefined,返回空字符串和空对象 + if (url == null) { + return { basePath: '', paramsObject: {} } + } + + // 找到问号 (?) 的位置,它之前是基础路径,之后是查询参数 + const questionMarkIndex = url.indexOf('?') + let basePath = url + const paramsObject: { [key: string]: string } = {} + + // 如果找到了问号,说明有查询参数 + if (questionMarkIndex !== -1) { + // 获取 basePath + basePath = url.substring(0, questionMarkIndex) + + // 从 URL 中获取查询字符串部分 + const queryString = url.substring(questionMarkIndex + 1) + + // 使用 URLSearchParams 遍历参数 + const searchParams = new URLSearchParams(queryString) + searchParams.forEach((value, key) => { + // 封装进 paramsObject 对象 + paramsObject[key] = value + }) + } + + // 返回 basePath 和 paramsObject + return { basePath, paramsObject } +} + +// 路由不重定向白名单 +const whiteList = [ + '/login', + '/social-login', + '/auth-redirect', + '/bind', + '/register', + '/oauthLogin/gitee' +] + +// 路由加载前 +router.beforeEach(async (to, from, next) => { + start() + loadStart() + if (getAccessToken()) { + if (to.path === '/login') { + next({ path: '/' }) + } else { + // 获取所有字典 + const dictStore = useDictStoreWithOut() + const userStore = useUserStoreWithOut() + const permissionStore = usePermissionStoreWithOut() + if (!dictStore.getIsSetDict) { + await dictStore.setDictMap() + } + if (!userStore.getIsSetUser) { + isRelogin.show = true + await userStore.setUserInfoAction() + isRelogin.show = false + // 后端过滤菜单 + await permissionStore.generateRoutes() + permissionStore.getAddRouters.forEach((route) => { + router.addRoute(route as unknown as RouteRecordRaw) // 动态添加可访问路由表 + }) + const redirectPath = from.query.redirect || to.path + // 修复跳转时不带参数的问题 + const redirect = decodeURIComponent(redirectPath as string) + const { paramsObject: query } = parseURL(redirect) + const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect, query } + next(nextData) + } else { + next() + } + } + } else { + if (whiteList.indexOf(to.path) !== -1) { + next() + } else { + next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页 + } + } +}) + +router.afterEach((to) => { + useTitle(to?.meta?.title as string) + done() // 结束Progress + loadDone() +}) diff --git a/src/plugins/animate.css/index.ts b/src/plugins/animate.css/index.ts new file mode 100644 index 0000000..3e93451 --- /dev/null +++ b/src/plugins/animate.css/index.ts @@ -0,0 +1 @@ +import 'animate.css' diff --git a/src/plugins/echarts/index.ts b/src/plugins/echarts/index.ts new file mode 100644 index 0000000..18d05aa --- /dev/null +++ b/src/plugins/echarts/index.ts @@ -0,0 +1,49 @@ +import * as echarts from 'echarts/core' + +import { + BarChart, + FunnelChart, + GaugeChart, + LineChart, + MapChart, + PictorialBarChart, + PieChart, + RadarChart +} from 'echarts/charts' + +import { + AriaComponent, + GridComponent, + LegendComponent, + ParallelComponent, + PolarComponent, + TitleComponent, + ToolboxComponent, + TooltipComponent, + VisualMapComponent +} from 'echarts/components' + +import { CanvasRenderer } from 'echarts/renderers' + +echarts.use([ + LegendComponent, + TitleComponent, + TooltipComponent, + ToolboxComponent, + GridComponent, + PolarComponent, + AriaComponent, + ParallelComponent, + VisualMapComponent, + BarChart, + LineChart, + PieChart, + MapChart, + CanvasRenderer, + PictorialBarChart, + RadarChart, + GaugeChart, + FunnelChart +]) + +export default echarts diff --git a/src/plugins/elementPlus/index.ts b/src/plugins/elementPlus/index.ts new file mode 100644 index 0000000..0ae2a8b --- /dev/null +++ b/src/plugins/elementPlus/index.ts @@ -0,0 +1,17 @@ +import type { App } from 'vue' +// 需要全局引入一些组件,如ElScrollbar,不然一些下拉项样式有问题 +import { ElLoading, ElScrollbar, ElButton } from 'element-plus' + +const plugins = [ElLoading] + +const components = [ElScrollbar, ElButton] + +export const setupElementPlus = (app: App<Element>) => { + plugins.forEach((plugin) => { + app.use(plugin) + }) + + components.forEach((component) => { + app.component(component.name, component) + }) +} diff --git a/src/plugins/formCreate/index.ts b/src/plugins/formCreate/index.ts new file mode 100644 index 0000000..44556de --- /dev/null +++ b/src/plugins/formCreate/index.ts @@ -0,0 +1,74 @@ +import type { App } from 'vue' +// 👇使用 form-create 需额外全局引入 element plus 组件 +import { + ElAlert, + ElAside, + ElContainer, + ElDivider, + ElHeader, + ElMain, + ElPopconfirm, + ElTable, + ElTableColumn, + ElTabPane, + ElTabs, + ElTransfer +} from 'element-plus' +import FcDesigner from '@form-create/designer' +import formCreate from '@form-create/element-ui' +import install from '@form-create/element-ui/auto-import' + +//======================= 自定义组件 ======================= +import { UploadFile, UploadImg, UploadImgs } from '@/components/UploadFile' +import { useApiSelect } from '@/components/FormCreate' +import { Editor } from '@/components/Editor' +import DictSelect from '@/components/FormCreate/src/components/DictSelect.vue' + +const UserSelect = useApiSelect({ + name: 'UserSelect', + labelField: 'nickname', + valueField: 'id', + url: '/system/user/simple-list' +}) +const DeptSelect = useApiSelect({ + name: 'DeptSelect', + labelField: 'name', + valueField: 'id', + url: '/system/dept/simple-list' +}) +const ApiSelect = useApiSelect({ + name: 'ApiSelect' +}) + +const components = [ + ElAside, + ElPopconfirm, + ElHeader, + ElMain, + ElContainer, + ElDivider, + ElTransfer, + ElAlert, + ElTabs, + ElTable, + ElTableColumn, + ElTabPane, + UploadImg, + UploadImgs, + UploadFile, + DictSelect, + UserSelect, + DeptSelect, + ApiSelect, + Editor +] + +// 参考 http://www.form-create.com/v3/element-ui/auto-import.html 文档 +export const setupFormCreate = (app: App<Element>) => { + components.forEach((component) => { + app.component(component.name, component) + }) + formCreate.use(install) + app.use(formCreate) + app.use(FcDesigner) +} diff --git a/src/plugins/svgIcon/index.ts b/src/plugins/svgIcon/index.ts new file mode 100644 index 0000000..b5b7f70 --- /dev/null +++ b/src/plugins/svgIcon/index.ts @@ -0,0 +1,3 @@ +import 'virtual:svg-icons-register' + +import '@purge-icons/generated' diff --git a/src/plugins/tongji/index.ts b/src/plugins/tongji/index.ts new file mode 100644 index 0000000..ec261a1 --- /dev/null +++ b/src/plugins/tongji/index.ts @@ -0,0 +1,23 @@ +import router from '@/router' + +// 用于 router push +window._hmt = window._hmt || [] +// HM_ID +const HM_ID = import.meta.env.VITE_APP_BAIDU_CODE +;(function () { + // 有值的时候,才开启 + if (!HM_ID) { + return + } + const hm = document.createElement('script') + hm.src = 'https://hm.baidu.com/hm.js?' + HM_ID + const s = document.getElementsByTagName('script')[0] + s.parentNode.insertBefore(hm, s) +})() + +router.afterEach(function (to) { + if (!HM_ID) { + return + } + _hmt.push(['_trackPageview', to.fullPath]) +}) diff --git a/src/plugins/unocss/index.ts b/src/plugins/unocss/index.ts new file mode 100644 index 0000000..d366b5a --- /dev/null +++ b/src/plugins/unocss/index.ts @@ -0,0 +1 @@ +import 'virtual:uno.css' diff --git a/src/plugins/vueI18n/helper.ts b/src/plugins/vueI18n/helper.ts new file mode 100644 index 0000000..da6bc8c --- /dev/null +++ b/src/plugins/vueI18n/helper.ts @@ -0,0 +1,3 @@ +export const setHtmlPageLang = (locale: LocaleType) => { + document.querySelector('html')?.setAttribute('lang', locale) +} diff --git a/src/plugins/vueI18n/index.ts b/src/plugins/vueI18n/index.ts new file mode 100644 index 0000000..f845b13 --- /dev/null +++ b/src/plugins/vueI18n/index.ts @@ -0,0 +1,42 @@ +import type { App } from 'vue' +import { createI18n } from 'vue-i18n' +import { useLocaleStoreWithOut } from '@/store/modules/locale' +import type { I18n, I18nOptions } from 'vue-i18n' +import { setHtmlPageLang } from './helper' + +export let i18n: ReturnType<typeof createI18n> + +const createI18nOptions = async (): Promise<I18nOptions> => { + const localeStore = useLocaleStoreWithOut() + const locale = localeStore.getCurrentLocale + const localeMap = localeStore.getLocaleMap + const defaultLocal = await import(`../../locales/${locale.lang}.ts`) + const message = defaultLocal.default ?? {} + + setHtmlPageLang(locale.lang) + + localeStore.setCurrentLocale({ + lang: locale.lang + // elLocale: elLocal + }) + + return { + legacy: false, + locale: locale.lang, + fallbackLocale: locale.lang, + messages: { + [locale.lang]: message + }, + availableLocales: localeMap.map((v) => v.lang), + sync: true, + silentTranslationWarn: true, + missingWarn: false, + silentFallbackWarn: true + } +} + +export const setupI18n = async (app: App<Element>) => { + const options = await createI18nOptions() + i18n = createI18n(options) as I18n + app.use(i18n) +} diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..8f66ca3 --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,28 @@ +import type { App } from 'vue' +import type { RouteRecordRaw } from 'vue-router' +import { createRouter, createWebHistory } from 'vue-router' +import remainingRouter from './modules/remaining' + +// 创建路由实例 +const router = createRouter({ + history: createWebHistory(), // createWebHashHistory URL带#,createWebHistory URL不带# + strict: true, + routes: remainingRouter as RouteRecordRaw[], + scrollBehavior: () => ({ left: 0, top: 0 }) +}) + +export const resetRouter = (): void => { + const resetWhiteNameList = ['Redirect', 'Login', 'NoFind', 'Root'] + router.getRoutes().forEach((route) => { + const { name } = route + if (name && !resetWhiteNameList.includes(name as string)) { + router.hasRoute(name) && router.removeRoute(name) + } + }) +} + +export const setupRouter = (app: App<Element>) => { + app.use(router) +} + +export default router diff --git a/src/router/modules/remaining.ts b/src/router/modules/remaining.ts new file mode 100644 index 0000000..bf2ba2b --- /dev/null +++ b/src/router/modules/remaining.ts @@ -0,0 +1,609 @@ +import { Layout } from '@/utils/routerHelper' + +const { t } = useI18n() +/** + * redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击 + * name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题 + * meta : { + hidden: true 当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false) + + alwaysShow: true 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式, + 只有一个时,会将那个子路由当做根路由显示在侧边栏, + 若你想不管路由下面的 children 声明的个数都显示你的根路由, + 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则, + 一直显示根路由(默认 false) + + title: 'title' 设置该路由在侧边栏和面包屑中展示的名字 + + icon: 'svg-name' 设置该路由的图标 + + noCache: true 如果设置为true,则不会被 <keep-alive> 缓存(默认 false) + + breadcrumb: false 如果设置为false,则不会在breadcrumb面包屑中显示(默认 true) + + affix: true 如果设置为true,则会一直固定在tag项中(默认 false) + + noTagsView: true 如果设置为true,则不会出现在tag中(默认 false) + + activeMenu: '/dashboard' 显示高亮的路由路径 + + followAuth: '/dashboard' 跟随哪个路由进行权限过滤 + + canTo: true 设置为true即使hidden为true,也依然可以进行路由跳转(默认 false) + } + **/ +const remainingRouter: AppRouteRecordRaw[] = [ + { + path: '/redirect', + component: Layout, + name: 'Redirect', + children: [ + { + path: '/redirect/:path(.*)', + name: 'Redirect', + component: () => import('@/views/Redirect/Redirect.vue'), + meta: {} + } + ], + meta: { + hidden: true, + noTagsView: true + } + }, + { + path: '/', + component: Layout, + redirect: '/index', + name: 'Home', + meta: {}, + children: [ + { + path: 'index', + component: () => import('@/views/Home/Index.vue'), + name: 'Index', + meta: { + title: t('router.home'), + icon: 'ep:home-filled', + noCache: false, + affix: true + } + } + ] + }, + { + path: '/user', + component: Layout, + name: 'UserInfo', + meta: { + hidden: true + }, + children: [ + { + path: 'profile', + component: () => import('@/views/Profile/Index.vue'), + name: 'Profile', + meta: { + canTo: true, + hidden: true, + noTagsView: false, + icon: 'ep:user', + title: t('common.profile') + } + }, + { + path: 'notify-message', + component: () => import('@/views/system/notify/my/index.vue'), + name: 'MyNotifyMessage', + meta: { + canTo: true, + hidden: true, + noTagsView: false, + icon: 'ep:message', + title: '我的站内信' + } + } + ] + }, + { + path: '/dict', + component: Layout, + name: 'dict', + meta: { + hidden: true + }, + children: [ + { + path: 'type/data/:dictType', + component: () => import('@/views/system/dict/data/index.vue'), + name: 'SystemDictData', + meta: { + title: '字典数据', + noCache: true, + hidden: true, + canTo: true, + icon: '', + activeMenu: '/system/dict' + } + } + ] + }, + + { + path: '/codegen', + component: Layout, + name: 'CodegenEdit', + meta: { + hidden: true + }, + children: [ + { + path: 'edit', + component: () => import('@/views/infra/codegen/EditTable.vue'), + name: 'InfraCodegenEditTable', + meta: { + noCache: true, + hidden: true, + canTo: true, + icon: 'ep:edit', + title: '修改生成配置', + activeMenu: 'infra/codegen/index' + } + } + ] + }, + { + path: '/job', + component: Layout, + name: 'JobL', + meta: { + hidden: true + }, + children: [ + { + path: 'job-log', + component: () => import('@/views/infra/job/logger/index.vue'), + name: 'InfraJobLog', + meta: { + noCache: true, + hidden: true, + canTo: true, + icon: 'ep:edit', + title: '调度日志', + activeMenu: 'infra/job/index' + } + } + ] + }, + { + path: '/login', + component: () => import('@/views/Login/Login.vue'), + name: 'Login', + meta: { + hidden: true, + title: t('router.login'), + noTagsView: true + } + }, + { + path: '/sso', + component: () => import('@/views/Login/Login.vue'), + name: 'SSOLogin', + meta: { + hidden: true, + title: t('router.login'), + noTagsView: true + } + }, + { + path: '/social-login', + component: () => import('@/views/Login/SocialLogin.vue'), + name: 'SocialLogin', + meta: { + hidden: true, + title: t('router.socialLogin'), + noTagsView: true + } + }, + { + path: '/403', + component: () => import('@/views/Error/403.vue'), + name: 'NoAccess', + meta: { + hidden: true, + title: '403', + noTagsView: true + } + }, + { + path: '/404', + component: () => import('@/views/Error/404.vue'), + name: 'NoFound', + meta: { + hidden: true, + title: '404', + noTagsView: true + } + }, + { + path: '/500', + component: () => import('@/views/Error/500.vue'), + name: 'Error', + meta: { + hidden: true, + title: '500', + noTagsView: true + } + }, + { + path: '/bpm', + component: Layout, + name: 'bpm', + meta: { + hidden: true + }, + children: [ + { + path: 'manager/form/edit', + component: () => import('@/views/bpm/form/editor/index.vue'), + name: 'BpmFormEditor', + meta: { + noCache: true, + hidden: true, + canTo: true, + title: '设计流程表单', + activeMenu: '/bpm/manager/form' + } + }, + { + path: 'manager/model/edit', + component: () => import('@/views/bpm/model/editor/index.vue'), + name: 'BpmModelEditor', + meta: { + noCache: true, + hidden: true, + canTo: true, + title: '设计流程', + activeMenu: '/bpm/manager/model' + } + }, + { + path: 'manager/simple/workflow/model/edit', + component: () => import('@/views/bpm/simpleWorkflow/index.vue'), + name: 'SimpleWorkflowDesignEditor', + meta: { + noCache: true, + hidden: true, + canTo: true, + title: '仿钉钉设计流程', + activeMenu: '/bpm/manager/model' + } + }, + { + path: 'manager/definition', + component: () => import('@/views/bpm/definition/index.vue'), + name: 'BpmProcessDefinition', + meta: { + noCache: true, + hidden: true, + canTo: true, + title: '流程定义', + activeMenu: '/bpm/manager/model' + } + }, + { + path: 'process-instance/detail', + component: () => import('@/views/bpm/processInstance/detail/index.vue'), + name: 'BpmProcessInstanceDetail', + meta: { + noCache: true, + hidden: true, + canTo: true, + title: '流程详情', + activeMenu: '/bpm/task/my' + } + }, + { + path: 'oa/leave/create', + component: () => import('@/views/bpm/oa/leave/create.vue'), + name: 'OALeaveCreate', + meta: { + noCache: true, + hidden: true, + canTo: true, + title: '发起 OA 请假', + activeMenu: '/bpm/oa/leave' + } + }, + { + path: 'oa/leave/detail', + component: () => import('@/views/bpm/oa/leave/detail.vue'), + name: 'OALeaveDetail', + meta: { + noCache: true, + hidden: true, + canTo: true, + title: '查看 OA 请假', + activeMenu: '/bpm/oa/leave' + } + } + ] + }, + { + path: '/mall/product', // 商品中心 + component: Layout, + name: 'ProductCenter', + meta: { + hidden: true + }, + children: [ + { + path: 'spu/add', + component: () => import('@/views/mall/product/spu/form/index.vue'), + name: 'ProductSpuAdd', + meta: { + noCache: false, // 需要缓存 + hidden: true, + canTo: true, + icon: 'ep:edit', + title: '商品添加', + activeMenu: '/mall/product/spu' + } + }, + { + path: 'spu/edit/:id(\\d+)', + component: () => import('@/views/mall/product/spu/form/index.vue'), + name: 'ProductSpuEdit', + meta: { + noCache: true, + hidden: true, + canTo: true, + icon: 'ep:edit', + title: '商品编辑', + activeMenu: '/mall/product/spu' + } + }, + { + path: 'spu/detail/:id(\\d+)', + component: () => import('@/views/mall/product/spu/form/index.vue'), + name: 'ProductSpuDetail', + meta: { + noCache: true, + hidden: true, + canTo: true, + icon: 'ep:view', + title: '商品详情', + activeMenu: '/mall/product/spu' + } + }, + { + path: 'property/value/:propertyId(\\d+)', + component: () => import('@/views/mall/product/property/value/index.vue'), + name: 'ProductPropertyValue', + meta: { + noCache: true, + hidden: true, + canTo: true, + icon: 'ep:view', + title: '商品属性值', + activeMenu: '/product/property' + } + } + ] + }, + { + path: '/mall/trade', // 交易中心 + component: Layout, + name: 'TradeCenter', + meta: { + hidden: true + }, + children: [ + { + path: 'order/detail/:id(\\d+)', + component: () => import('@/views/mall/trade/order/detail/index.vue'), + name: 'TradeOrderDetail', + meta: { title: '订单详情', icon: 'ep:view', activeMenu: '/mall/trade/order' } + }, + { + path: 'after-sale/detail/:id(\\d+)', + component: () => import('@/views/mall/trade/afterSale/detail/index.vue'), + name: 'TradeAfterSaleDetail', + meta: { title: '退款详情', icon: 'ep:view', activeMenu: '/mall/trade/after-sale' } + } + ] + }, + { + path: '/member', + component: Layout, + name: 'MemberCenter', + meta: { hidden: true }, + children: [ + { + path: 'user/detail/:id', + name: 'MemberUserDetail', + meta: { + title: '会员详情', + noCache: true, + hidden: true + }, + component: () => import('@/views/member/user/detail/index.vue') + } + ] + }, + { + path: '/pay', + component: Layout, + name: 'pay', + meta: { hidden: true }, + children: [ + { + path: 'cashier', + name: 'PayCashier', + meta: { + title: '收银台', + noCache: true, + hidden: true + }, + component: () => import('@/views/pay/cashier/index.vue') + } + ] + }, + { + path: '/diy', + name: 'DiyCenter', + meta: { hidden: true }, + component: Layout, + children: [ + { + path: 'template/decorate/:id', + name: 'DiyTemplateDecorate', + meta: { + title: '模板装修', + noCache: true, + hidden: true, + activeMenu: '/mall/promotion/diy/template' + }, + component: () => import('@/views/mall/promotion/diy/template/decorate.vue') + }, + { + path: 'page/decorate/:id', + name: 'DiyPageDecorate', + meta: { + title: '页面装修', + noCache: true, + hidden: true, + activeMenu: '/mall/promotion/diy/page' + }, + component: () => import('@/views/mall/promotion/diy/page/decorate.vue') + } + ] + }, + { + path: '/crm', + component: Layout, + name: 'CrmCenter', + meta: { hidden: true }, + children: [ + { + path: 'clue/detail/:id', + name: 'CrmClueDetail', + meta: { + title: '线索详情', + noCache: true, + hidden: true, + activeMenu: '/crm/clue' + }, + component: () => import('@/views/crm/clue/detail/index.vue') + }, + { + path: 'customer/detail/:id', + name: 'CrmCustomerDetail', + meta: { + title: '客户详情', + noCache: true, + hidden: true, + activeMenu: '/crm/customer' + }, + component: () => import('@/views/crm/customer/detail/index.vue') + }, + { + path: 'business/detail/:id', + name: 'CrmBusinessDetail', + meta: { + title: '商机详情', + noCache: true, + hidden: true, + activeMenu: '/crm/business' + }, + component: () => import('@/views/crm/business/detail/index.vue') + }, + { + path: 'contract/detail/:id', + name: 'CrmContractDetail', + meta: { + title: '合同详情', + noCache: true, + hidden: true, + activeMenu: '/crm/contract' + }, + component: () => import('@/views/crm/contract/detail/index.vue') + }, + { + path: 'receivable-plan/detail/:id', + name: 'CrmReceivablePlanDetail', + meta: { + title: '回款计划详情', + noCache: true, + hidden: true, + activeMenu: '/crm/receivable-plan' + }, + component: () => import('@/views/crm/receivable/plan/detail/index.vue') + }, + { + path: 'receivable/detail/:id', + name: 'CrmReceivableDetail', + meta: { + title: '回款详情', + noCache: true, + hidden: true, + activeMenu: '/crm/receivable' + }, + component: () => import('@/views/crm/receivable/detail/index.vue') + }, + { + path: 'contact/detail/:id', + name: 'CrmContactDetail', + meta: { + title: '联系人详情', + noCache: true, + hidden: true, + activeMenu: '/crm/contact' + }, + component: () => import('@/views/crm/contact/detail/index.vue') + }, + { + path: 'product/detail/:id', + name: 'CrmProductDetail', + meta: { + title: '产品详情', + noCache: true, + hidden: true, + activeMenu: '/crm/product' + }, + component: () => import('@/views/crm/product/detail/index.vue') + } + ] + }, + { + path: '/ai', + component: Layout, + name: 'Ai', + meta: { + hidden: true + }, + children: [ + { + path: 'image/square', + component: () => import('@/views/ai/image/square/index.vue'), + name: 'AiImageSquare', + meta: { + title: '绘图作品', + icon: 'ep:home-filled', + noCache: false + } + } + ] + }, + { + path: '/:pathMatch(.*)*', + component: () => import('@/views/Error/404.vue'), + name: '', + meta: { + title: '404', + hidden: true, + breadcrumb: false + } + } +] + +export default remainingRouter diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..63f0045 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,12 @@ +import type { App } from 'vue' +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' + +const store = createPinia() +store.use(piniaPluginPersistedstate) + +export const setupStore = (app: App<Element>) => { + app.use(store) +} + +export { store } diff --git a/src/store/modules/app.ts b/src/store/modules/app.ts new file mode 100644 index 0000000..8733618 --- /dev/null +++ b/src/store/modules/app.ts @@ -0,0 +1,277 @@ +import { defineStore } from 'pinia' +import { store } from '../index' +import { setCssVar, humpToUnderline } from '@/utils' +import { ElMessage } from 'element-plus' +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' +import { ElementPlusSize } from '@/types/elementPlus' +import { LayoutType } from '@/types/layout' +import { ThemeTypes } from '@/types/theme' + +const { wsCache } = useCache() + +interface AppState { + breadcrumb: boolean + breadcrumbIcon: boolean + collapse: boolean + uniqueOpened: boolean + hamburger: boolean + screenfull: boolean + search: boolean + size: boolean + locale: boolean + message: boolean + tagsView: boolean + tagsViewIcon: boolean + logo: boolean + fixedHeader: boolean + greyMode: boolean + pageLoading: boolean + layout: LayoutType + title: string + userInfo: string + isDark: boolean + currentSize: ElementPlusSize + sizeMap: ElementPlusSize[] + mobile: boolean + footer: boolean + theme: ThemeTypes + fixedMenu: boolean +} + +export const useAppStore = defineStore('app', { + state: (): AppState => { + return { + userInfo: 'userInfo', // 登录信息存储字段-建议每个项目换一个字段,避免与其他项目冲突 + sizeMap: ['default', 'large', 'small'], + mobile: false, // 是否是移动端 + title: import.meta.env.VITE_APP_TITLE, // 标题 + pageLoading: false, // 路由跳转loading + + breadcrumb: true, // 面包屑 + breadcrumbIcon: true, // 面包屑图标 + collapse: false, // 折叠菜单 + uniqueOpened: true, // 是否只保持一个子菜单的展开 + hamburger: true, // 折叠图标 + screenfull: true, // 全屏图标 + search: true, // 搜索图标 + size: true, // 尺寸图标 + locale: true, // 多语言图标 + message: true, // 消息图标 + tagsView: true, // 标签页 + tagsViewIcon: true, // 是否显示标签图标 + logo: true, // logo + fixedHeader: true, // 固定toolheader + footer: true, // 显示页脚 + greyMode: false, // 是否开始灰色模式,用于特殊悼念日 + fixedMenu: wsCache.get('fixedMenu') || false, // 是否固定菜单 + + layout: wsCache.get(CACHE_KEY.LAYOUT) || 'classic', // layout布局 + isDark: wsCache.get(CACHE_KEY.IS_DARK) || false, // 是否是暗黑模式 + currentSize: wsCache.get('default') || 'default', // 组件尺寸 + theme: wsCache.get(CACHE_KEY.THEME) || { + // 主题色 + elColorPrimary: '#409eff', + // 左侧菜单边框颜色 + leftMenuBorderColor: 'inherit', + // 左侧菜单背景颜色 + leftMenuBgColor: '#001529', + // 左侧菜单浅色背景颜色 + leftMenuBgLightColor: '#0f2438', + // 左侧菜单选中背景颜色 + leftMenuBgActiveColor: 'var(--el-color-primary)', + // 左侧菜单收起选中背景颜色 + leftMenuCollapseBgActiveColor: 'var(--el-color-primary)', + // 左侧菜单字体颜色 + leftMenuTextColor: '#bfcbd9', + // 左侧菜单选中字体颜色 + leftMenuTextActiveColor: '#fff', + // logo字体颜色 + logoTitleTextColor: '#fff', + // logo边框颜色 + logoBorderColor: 'inherit', + // 头部背景颜色 + topHeaderBgColor: '#fff', + // 头部字体颜色 + topHeaderTextColor: 'inherit', + // 头部悬停颜色 + topHeaderHoverColor: '#f6f6f6', + // 头部边框颜色 + topToolBorderColor: '#eee' + } + } + }, + getters: { + getBreadcrumb(): boolean { + return this.breadcrumb + }, + getBreadcrumbIcon(): boolean { + return this.breadcrumbIcon + }, + getCollapse(): boolean { + return this.collapse + }, + getUniqueOpened(): boolean { + return this.uniqueOpened + }, + getHamburger(): boolean { + return this.hamburger + }, + getScreenfull(): boolean { + return this.screenfull + }, + getSize(): boolean { + return this.size + }, + getLocale(): boolean { + return this.locale + }, + getMessage(): boolean { + return this.message + }, + getTagsView(): boolean { + return this.tagsView + }, + getTagsViewIcon(): boolean { + return this.tagsViewIcon + }, + getLogo(): boolean { + return this.logo + }, + getFixedHeader(): boolean { + return this.fixedHeader + }, + getGreyMode(): boolean { + return this.greyMode + }, + getFixedMenu(): boolean { + return this.fixedMenu + }, + getPageLoading(): boolean { + return this.pageLoading + }, + getLayout(): LayoutType { + return this.layout + }, + getTitle(): string { + return this.title + }, + getUserInfo(): string { + return this.userInfo + }, + getIsDark(): boolean { + return this.isDark + }, + getCurrentSize(): ElementPlusSize { + return this.currentSize + }, + getSizeMap(): ElementPlusSize[] { + return this.sizeMap + }, + getMobile(): boolean { + return this.mobile + }, + getTheme(): ThemeTypes { + return this.theme + }, + getFooter(): boolean { + return this.footer + } + }, + actions: { + setBreadcrumb(breadcrumb: boolean) { + this.breadcrumb = breadcrumb + }, + setBreadcrumbIcon(breadcrumbIcon: boolean) { + this.breadcrumbIcon = breadcrumbIcon + }, + setCollapse(collapse: boolean) { + this.collapse = collapse + }, + setUniqueOpened(uniqueOpened: boolean) { + this.uniqueOpened = uniqueOpened + }, + setHamburger(hamburger: boolean) { + this.hamburger = hamburger + }, + setScreenfull(screenfull: boolean) { + this.screenfull = screenfull + }, + setSize(size: boolean) { + this.size = size + }, + setLocale(locale: boolean) { + this.locale = locale + }, + setMessage(message: boolean) { + this.message = message + }, + setTagsView(tagsView: boolean) { + this.tagsView = tagsView + }, + setTagsViewIcon(tagsViewIcon: boolean) { + this.tagsViewIcon = tagsViewIcon + }, + setLogo(logo: boolean) { + this.logo = logo + }, + setFixedHeader(fixedHeader: boolean) { + this.fixedHeader = fixedHeader + }, + setGreyMode(greyMode: boolean) { + this.greyMode = greyMode + }, + setFixedMenu(fixedMenu: boolean) { + wsCache.set('fixedMenu', fixedMenu) + this.fixedMenu = fixedMenu + }, + setPageLoading(pageLoading: boolean) { + this.pageLoading = pageLoading + }, + setLayout(layout: LayoutType) { + if (this.mobile && layout !== 'classic') { + ElMessage.warning('移动端模式下不支持切换其他布局') + return + } + this.layout = layout + wsCache.set(CACHE_KEY.LAYOUT, this.layout) + }, + setTitle(title: string) { + this.title = title + }, + setIsDark(isDark: boolean) { + this.isDark = isDark + if (this.isDark) { + document.documentElement.classList.add('dark') + document.documentElement.classList.remove('light') + } else { + document.documentElement.classList.add('light') + document.documentElement.classList.remove('dark') + } + wsCache.set(CACHE_KEY.IS_DARK, this.isDark) + }, + setCurrentSize(currentSize: ElementPlusSize) { + this.currentSize = currentSize + wsCache.set('currentSize', this.currentSize) + }, + setMobile(mobile: boolean) { + this.mobile = mobile + }, + setTheme(theme: ThemeTypes) { + this.theme = Object.assign(this.theme, theme) + wsCache.set(CACHE_KEY.THEME, this.theme) + }, + setCssVarTheme() { + for (const key in this.theme) { + setCssVar(`--${humpToUnderline(key)}`, this.theme[key]) + } + }, + setFooter(footer: boolean) { + this.footer = footer + } + }, + persist: false +}) + +export const useAppStoreWithOut = () => { + return useAppStore(store) +} diff --git a/src/store/modules/dict.ts b/src/store/modules/dict.ts new file mode 100644 index 0000000..e239fb0 --- /dev/null +++ b/src/store/modules/dict.ts @@ -0,0 +1,104 @@ +import { defineStore } from 'pinia' +import { store } from '../index' +// @ts-ignore +import { DictDataVO } from '@/api/system/dict/types' +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' +const { wsCache } = useCache('sessionStorage') +import { getSimpleDictDataList } from '@/api/system/dict/dict.data' + +export interface DictValueType { + value: any + label: string + clorType?: string + cssClass?: string +} +export interface DictTypeType { + dictType: string + dictValue: DictValueType[] +} +export interface DictState { + dictMap: Map<string, any> + isSetDict: boolean +} + +export const useDictStore = defineStore('dict', { + state: (): DictState => ({ + dictMap: new Map<string, any>(), + isSetDict: false + }), + getters: { + getDictMap(): Recordable { + const dictMap = wsCache.get(CACHE_KEY.DICT_CACHE) + if (dictMap) { + this.dictMap = dictMap + } + return this.dictMap + }, + getIsSetDict(): boolean { + return this.isSetDict + } + }, + actions: { + async setDictMap() { + const dictMap = wsCache.get(CACHE_KEY.DICT_CACHE) + if (dictMap) { + this.dictMap = dictMap + this.isSetDict = true + } else { + const res = await getSimpleDictDataList() + // 设置数据 + const dictDataMap = new Map<string, any>() + res.forEach((dictData: DictDataVO) => { + // 获得 dictType 层级 + const enumValueObj = dictDataMap[dictData.dictType] + if (!enumValueObj) { + dictDataMap[dictData.dictType] = [] + } + // 处理 dictValue 层级 + dictDataMap[dictData.dictType].push({ + value: dictData.value, + label: dictData.label, + colorType: dictData.colorType, + cssClass: dictData.cssClass + }) + }) + this.dictMap = dictDataMap + this.isSetDict = true + wsCache.set(CACHE_KEY.DICT_CACHE, dictDataMap, { exp: 60 }) // 60 秒 过期 + } + }, + getDictByType(type: string) { + if (!this.isSetDict) { + this.setDictMap() + } + return this.dictMap[type] + }, + async resetDict() { + wsCache.delete(CACHE_KEY.DICT_CACHE) + const res = await getSimpleDictDataList() + // 设置数据 + const dictDataMap = new Map<string, any>() + res.forEach((dictData: DictDataVO) => { + // 获得 dictType 层级 + const enumValueObj = dictDataMap[dictData.dictType] + if (!enumValueObj) { + dictDataMap[dictData.dictType] = [] + } + // 处理 dictValue 层级 + dictDataMap[dictData.dictType].push({ + value: dictData.value, + label: dictData.label, + colorType: dictData.colorType, + cssClass: dictData.cssClass + }) + }) + this.dictMap = dictDataMap + this.isSetDict = true + wsCache.set(CACHE_KEY.DICT_CACHE, dictDataMap, { exp: 60 }) // 60 秒 过期 + } + } +}) + +export const useDictStoreWithOut = () => { + return useDictStore(store) +} diff --git a/src/store/modules/locale.ts b/src/store/modules/locale.ts new file mode 100644 index 0000000..1fc772a --- /dev/null +++ b/src/store/modules/locale.ts @@ -0,0 +1,59 @@ +import { defineStore } from 'pinia' +import { store } from '../index' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import en from 'element-plus/es/locale/lang/en' +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' +import { LocaleDropdownType } from '@/types/localeDropdown' + +const { wsCache } = useCache() + +const elLocaleMap = { + 'zh-CN': zhCn, + en: en +} +interface LocaleState { + currentLocale: LocaleDropdownType + localeMap: LocaleDropdownType[] +} + +export const useLocaleStore = defineStore('locales', { + state: (): LocaleState => { + return { + currentLocale: { + lang: wsCache.get(CACHE_KEY.LANG) || 'zh-CN', + elLocale: elLocaleMap[wsCache.get(CACHE_KEY.LANG) || 'zh-CN'] + }, + // 多语言 + localeMap: [ + { + lang: 'zh-CN', + name: '简体中文' + }, + { + lang: 'en', + name: 'English' + } + ] + } + }, + getters: { + getCurrentLocale(): LocaleDropdownType { + return this.currentLocale + }, + getLocaleMap(): LocaleDropdownType[] { + return this.localeMap + } + }, + actions: { + setCurrentLocale(localeMap: LocaleDropdownType) { + // this.locale = Object.assign(this.locale, localeMap) + this.currentLocale.lang = localeMap?.lang + this.currentLocale.elLocale = elLocaleMap[localeMap?.lang] + wsCache.set(CACHE_KEY.LANG, localeMap?.lang) + } + } +}) + +export const useLocaleStoreWithOut = () => { + return useLocaleStore(store) +} diff --git a/src/store/modules/lock.ts b/src/store/modules/lock.ts new file mode 100644 index 0000000..68ae1d7 --- /dev/null +++ b/src/store/modules/lock.ts @@ -0,0 +1,48 @@ +import { defineStore } from 'pinia' +import { store } from '@/store' + +interface lockInfo { + isLock?: boolean + password?: string +} + +interface LockState { + lockInfo: lockInfo +} + +export const useLockStore = defineStore('lock', { + state: (): LockState => { + return { + lockInfo: { + // isLock: false, // 是否锁定屏幕 + // password: '' // 锁屏密码 + } + } + }, + getters: { + getLockInfo(): lockInfo { + return this.lockInfo + } + }, + actions: { + setLockInfo(lockInfo: lockInfo) { + this.lockInfo = lockInfo + }, + resetLockInfo() { + this.lockInfo = {} + }, + unLock(password: string) { + if (this.lockInfo?.password === password) { + this.resetLockInfo() + return true + } else { + return false + } + } + }, + persist: true +}) + +export const useLockStoreWithOut = () => { + return useLockStore(store) +} diff --git a/src/store/modules/permission.ts b/src/store/modules/permission.ts new file mode 100644 index 0000000..fc927f4 --- /dev/null +++ b/src/store/modules/permission.ts @@ -0,0 +1,70 @@ +import { defineStore } from 'pinia' +import { store } from '@/store' +import { cloneDeep } from 'lodash-es' +import remainingRouter from '@/router/modules/remaining' +import { flatMultiLevelRoutes, generateRoute } from '@/utils/routerHelper' +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' + +const { wsCache } = useCache() + +export interface PermissionState { + routers: AppRouteRecordRaw[] + addRouters: AppRouteRecordRaw[] + menuTabRouters: AppRouteRecordRaw[] +} + +export const usePermissionStore = defineStore('permission', { + state: (): PermissionState => ({ + routers: [], + addRouters: [], + menuTabRouters: [] + }), + getters: { + getRouters(): AppRouteRecordRaw[] { + return this.routers + }, + getAddRouters(): AppRouteRecordRaw[] { + return flatMultiLevelRoutes(cloneDeep(this.addRouters)) + }, + getMenuTabRouters(): AppRouteRecordRaw[] { + return this.menuTabRouters + } + }, + actions: { + async generateRoutes(): Promise<unknown> { + return new Promise<void>(async (resolve) => { + // 获得菜单列表,它在登录的时候,setUserInfoAction 方法中已经进行获取 + let res: AppCustomRouteRecordRaw[] = [] + if (wsCache.get(CACHE_KEY.ROLE_ROUTERS)) { + res = wsCache.get(CACHE_KEY.ROLE_ROUTERS) as AppCustomRouteRecordRaw[] + } + const routerMap: AppRouteRecordRaw[] = generateRoute(res) + // 动态路由,404一定要放到最后面 + // preschooler:vue-router@4以后已支持静态404路由,此处可不再追加 + this.addRouters = routerMap.concat([ + { + path: '/:path(.*)*', + // redirect: '/404', + component: () => import('@/views/Error/404.vue'), + name: '404Page', + meta: { + hidden: true, + breadcrumb: false + } + } + ]) + // 渲染菜单的所有路由 + this.routers = cloneDeep(remainingRouter).concat(routerMap) + resolve() + }) + }, + setMenuTabRouters(routers: AppRouteRecordRaw[]): void { + this.menuTabRouters = routers + } + }, + persist: false +}) + +export const usePermissionStoreWithOut = () => { + return usePermissionStore(store) +} diff --git a/src/store/modules/simpleWorkflow.ts b/src/store/modules/simpleWorkflow.ts new file mode 100644 index 0000000..cf98538 --- /dev/null +++ b/src/store/modules/simpleWorkflow.ts @@ -0,0 +1,55 @@ +import { store } from '../index' +import { defineStore } from 'pinia' + +export const useWorkFlowStore = defineStore('simpleWorkflow', { + state: () => ({ + tableId: '', + isTried: false, + promoterDrawer: false, + flowPermission1: {}, + approverDrawer: false, + approverConfig1: {}, + copyerDrawer: false, + copyerConfig1: {}, + conditionDrawer: false, + conditionsConfig1: { + conditionNodes: [] + } + }), + actions: { + setTableId(payload) { + this.tableId = payload + }, + setIsTried(payload) { + this.isTried = payload + }, + setPromoter(payload) { + this.promoterDrawer = payload + }, + setFlowPermission(payload) { + this.flowPermission1 = payload + }, + setApprover(payload) { + this.approverDrawer = payload + }, + setApproverConfig(payload) { + this.approverConfig1 = payload + }, + setCopyer(payload) { + this.copyerDrawer = payload + }, + setCopyerConfig(payload) { + this.copyerConfig1 = payload + }, + setCondition(payload) { + this.conditionDrawer = payload + }, + setConditionsConfig(payload) { + this.conditionsConfig1 = payload + } + } +}) + +export const useWorkFlowStoreWithOut = () => { + return useWorkFlowStore(store) +} diff --git a/src/store/modules/tagsView.ts b/src/store/modules/tagsView.ts new file mode 100644 index 0000000..25a3a1f --- /dev/null +++ b/src/store/modules/tagsView.ts @@ -0,0 +1,141 @@ +import router from '@/router' +import type { RouteLocationNormalizedLoaded } from 'vue-router' +import { getRawRoute } from '@/utils/routerHelper' +import { defineStore } from 'pinia' +import { store } from '../index' +import { findIndex } from '@/utils' + +export interface TagsViewState { + visitedViews: RouteLocationNormalizedLoaded[] + cachedViews: Set<string> +} + +export const useTagsViewStore = defineStore('tagsView', { + state: (): TagsViewState => ({ + visitedViews: [], + cachedViews: new Set() + }), + getters: { + getVisitedViews(): RouteLocationNormalizedLoaded[] { + return this.visitedViews + }, + getCachedViews(): string[] { + return Array.from(this.cachedViews) + } + }, + actions: { + // 新增缓存和tag + addView(view: RouteLocationNormalizedLoaded): void { + this.addVisitedView(view) + this.addCachedView() + }, + // 新增tag + addVisitedView(view: RouteLocationNormalizedLoaded) { + if (this.visitedViews.some((v) => v.path === view.path)) return + if (view.meta?.noTagsView) return + this.visitedViews.push( + Object.assign({}, view, { + title: view.meta?.title || 'no-name' + }) + ) + }, + // 新增缓存 + addCachedView() { + const cacheMap: Set<string> = new Set() + for (const v of this.visitedViews) { + const item = getRawRoute(v) + const needCache = !item.meta?.noCache + if (!needCache) { + continue + } + const name = item.name as string + cacheMap.add(name) + } + if (Array.from(this.cachedViews).sort().toString() === Array.from(cacheMap).sort().toString()) + return + this.cachedViews = cacheMap + }, + // 删除某个 + delView(view: RouteLocationNormalizedLoaded) { + this.delVisitedView(view) + this.delCachedView() + }, + // 删除tag + delVisitedView(view: RouteLocationNormalizedLoaded) { + for (const [i, v] of this.visitedViews.entries()) { + if (v.path === view.path) { + this.visitedViews.splice(i, 1) + break + } + } + }, + // 删除缓存 + delCachedView() { + const route = router.currentRoute.value + const index = findIndex<string>(this.getCachedViews, (v) => v === route.name) + if (index > -1) { + this.cachedViews.delete(this.getCachedViews[index]) + } + }, + // 删除所有缓存和tag + delAllViews() { + this.delAllVisitedViews() + this.delCachedView() + }, + // 删除所有tag + delAllVisitedViews() { + // const affixTags = this.visitedViews.filter((tag) => tag.meta.affix) + this.visitedViews = [] + }, + // 删除其他 + delOthersViews(view: RouteLocationNormalizedLoaded) { + this.delOthersVisitedViews(view) + this.addCachedView() + }, + // 删除其他tag + delOthersVisitedViews(view: RouteLocationNormalizedLoaded) { + this.visitedViews = this.visitedViews.filter((v) => { + return v?.meta?.affix || v.path === view.path + }) + }, + // 删除左侧 + delLeftViews(view: RouteLocationNormalizedLoaded) { + const index = findIndex<RouteLocationNormalizedLoaded>( + this.visitedViews, + (v) => v.path === view.path + ) + if (index > -1) { + this.visitedViews = this.visitedViews.filter((v, i) => { + return v?.meta?.affix || v.path === view.path || i > index + }) + this.addCachedView() + } + }, + // 删除右侧 + delRightViews(view: RouteLocationNormalizedLoaded) { + const index = findIndex<RouteLocationNormalizedLoaded>( + this.visitedViews, + (v) => v.path === view.path + ) + if (index > -1) { + this.visitedViews = this.visitedViews.filter((v, i) => { + return v?.meta?.affix || v.path === view.path || i < index + }) + this.addCachedView() + } + }, + updateVisitedView(view: RouteLocationNormalizedLoaded) { + for (let v of this.visitedViews) { + if (v.path === view.path) { + v = Object.assign(v, view) + break + } + } + } + }, + persist: false +}) + +export const useTagsViewStoreWithOut = () => { + return useTagsViewStore(store) +} diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts new file mode 100644 index 0000000..b386180 --- /dev/null +++ b/src/store/modules/user.ts @@ -0,0 +1,103 @@ +import { store } from '@/store' +import { defineStore } from 'pinia' +import { getAccessToken, removeToken } from '@/utils/auth' +import { CACHE_KEY, useCache, deleteUserCache } from '@/hooks/web/useCache' +import { getInfo, loginOut } from '@/api/login' + +const { wsCache } = useCache() + +interface UserVO { + id: number + avatar: string + nickname: string + deptId: number +} + +interface UserInfoVO { + // USER 缓存 + permissions: string[] + roles: string[] + isSetUser: boolean + user: UserVO +} + +export const useUserStore = defineStore('admin-user', { + state: (): UserInfoVO => ({ + permissions: [], + roles: [], + isSetUser: false, + user: { + id: 0, + avatar: '', + nickname: '', + deptId: 0 + } + }), + getters: { + getPermissions(): string[] { + return this.permissions + }, + getRoles(): string[] { + return this.roles + }, + getIsSetUser(): boolean { + return this.isSetUser + }, + getUser(): UserVO { + return this.user + } + }, + actions: { + async setUserInfoAction() { + if (!getAccessToken()) { + this.resetState() + return null + } + let userInfo = wsCache.get(CACHE_KEY.USER) + if (!userInfo) { + userInfo = await getInfo() + } + this.permissions = userInfo.permissions + this.roles = userInfo.roles + this.user = userInfo.user + this.isSetUser = true + wsCache.set(CACHE_KEY.USER, userInfo) + wsCache.set(CACHE_KEY.ROLE_ROUTERS, userInfo.menus) + }, + async setUserAvatarAction(avatar: string) { + const userInfo = wsCache.get(CACHE_KEY.USER) + // NOTE: 是否需要像`setUserInfoAction`一样判断`userInfo != null` + this.user.avatar = avatar + userInfo.user.avatar = avatar + wsCache.set(CACHE_KEY.USER, userInfo) + }, + async setUserNicknameAction(nickname: string) { + const userInfo = wsCache.get(CACHE_KEY.USER) + // NOTE: 是否需要像`setUserInfoAction`一样判断`userInfo != null` + this.user.nickname = nickname + userInfo.user.nickname = nickname + wsCache.set(CACHE_KEY.USER, userInfo) + }, + async loginOut() { + await loginOut() + removeToken() + deleteUserCache() // 删除用户缓存 + this.resetState() + }, + resetState() { + this.permissions = [] + this.roles = [] + this.isSetUser = false + this.user = { + id: 0, + avatar: '', + nickname: '', + deptId: 0 + } + } + } +}) + +export const useUserStoreWithOut = () => { + return useUserStore(store) +} diff --git a/src/styles/FormCreate/fonts/fontello.woff b/src/styles/FormCreate/fonts/fontello.woff new file mode 100644 index 0000000..1e00f49 Binary files /dev/null and b/src/styles/FormCreate/fonts/fontello.woff differ diff --git a/src/styles/FormCreate/index.scss b/src/styles/FormCreate/index.scss new file mode 100644 index 0000000..bb62000 --- /dev/null +++ b/src/styles/FormCreate/index.scss @@ -0,0 +1,22 @@ +// 使用字体图标来源 https://fontello.com/ + +@font-face { + font-family: 'fc-icon'; + src: url('@/styles/FormCreate/fonts/fontello.woff') format('woff'); +} + +.icon-doc-text:before { + content: '\f0f6'; +} + +.icon-server:before { + content: '\f233'; +} + +.icon-address-card-o:before { + content: '\f2bc'; +} + +.icon-user-o:before { + content: '\f2c0'; +} diff --git a/src/styles/global.module.scss b/src/styles/global.module.scss new file mode 100644 index 0000000..8448a92 --- /dev/null +++ b/src/styles/global.module.scss @@ -0,0 +1,6 @@ +@import './variables.scss'; +// 导出变量 +:export { + namespace: $namespace; + elNamespace: $elNamespace; +} diff --git a/src/styles/index.scss b/src/styles/index.scss new file mode 100644 index 0000000..766f983 --- /dev/null +++ b/src/styles/index.scss @@ -0,0 +1,37 @@ +@import './var.css'; +@import './FormCreate/index.scss'; +@import './theme.scss'; +@import 'element-plus/theme-chalk/dark/css-vars.css'; + +.reset-margin [class*='el-icon'] + span { + margin-left: 2px !important; +} + +// 解决抽屉弹出时,body宽度变化的问题 +.el-popup-parent--hidden { + width: 100% !important; +} + +// 解决表格内容超过表格总宽度后,横向滚动条前端顶不到表格边缘的问题 +.el-scrollbar__bar { + display: flex; + justify-content: flex-start; +} + +/* nprogress 适配 element-plus 的主题色 */ +#nprogress { + & .bar { + background-color: var(--el-color-primary) !important; + } + + & .peg { + box-shadow: + 0 0 10px var(--el-color-primary), + 0 0 5px var(--el-color-primary) !important; + } + + & .spinner-icon { + border-top-color: var(--el-color-primary); + border-left-color: var(--el-color-primary); + } +} diff --git a/src/styles/theme.scss b/src/styles/theme.scss new file mode 100644 index 0000000..149002c --- /dev/null +++ b/src/styles/theme.scss @@ -0,0 +1,17 @@ +// .text-color { +// color: var(--el-text-color-regular); +// } +// .dark .dark\:text-color { +// color: rgba(255, 255, 255, var(--dark-text-color)); +// } + +// 登录页 +.dark .login-form { + .el-divider__text { + background-color: var(--login-bg-color); + } + + .el-card { + background-color: var(--login-bg-color); + } +} diff --git a/src/styles/var.css b/src/styles/var.css new file mode 100644 index 0000000..63459ba --- /dev/null +++ b/src/styles/var.css @@ -0,0 +1,66 @@ +:root { + --login-bg-color: #293146; + + --left-menu-max-width: 200px; + + --left-menu-min-width: 64px; + + --left-menu-bg-color: #001529; + + --left-menu-bg-light-color: #0f2438; + + --left-menu-bg-active-color: var(--el-color-primary); + + --left-menu-text-color: #bfcbd9; + + --left-menu-text-active-color: #fff; + + --left-menu-collapse-bg-active-color: var(--el-color-primary); + /* left menu end */ + + /* logo start */ + --logo-height: 50px; + + --logo-title-text-color: #fff; + /* logo end */ + + /* header start */ + --top-header-bg-color: '#fff'; + + --top-header-text-color: 'inherit'; + + --top-header-hover-color: #f6f6f6; + + --top-tool-height: var(--logo-height); + + --top-tool-p-x: 0; + + --tags-view-height: 35px; + /* header start */ + + /* tab menu start */ + --tab-menu-max-width: 80px; + + --tab-menu-min-width: 30px; + + --tab-menu-collapse-height: 36px; + /* tab menu end */ + + --app-content-padding: 20px; + + --app-content-bg-color: #f5f7f9; + + --app-footer-height: 50px; + + --transition-time-02: 0.2s; +} + +.dark { + --app-content-bg-color: var(--el-bg-color); +} + +html, +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/src/styles/variables.scss b/src/styles/variables.scss new file mode 100644 index 0000000..00b66f1 --- /dev/null +++ b/src/styles/variables.scss @@ -0,0 +1,4 @@ +// 命名空间 +$namespace: v; +// el命名空间 +$elNamespace: el; diff --git a/src/types/components.d.ts b/src/types/components.d.ts new file mode 100644 index 0000000..8de1f33 --- /dev/null +++ b/src/types/components.d.ts @@ -0,0 +1,56 @@ +export type ComponentName = + | 'Radio' + | 'RadioButton' + | 'Checkbox' + | 'CheckboxButton' + | 'Input' + | 'Autocomplete' + | 'InputNumber' + | 'Select' + | 'Cascader' + | 'Switch' + | 'Slider' + | 'TimePicker' + | 'DatePicker' + | 'Rate' + | 'ColorPicker' + | 'Transfer' + | 'Divider' + | 'TimeSelect' + | 'SelectV2' + | 'TreeSelect' + | 'InputPassword' + | 'Editor' + | 'UploadImg' + | 'UploadImgs' + | 'UploadFile' + +export type ColProps = { + span?: number + xs?: number + sm?: number + md?: number + lg?: number + xl?: number + tag?: string +} + +export type ComponentOptions = { + label?: string + value?: FormValueType + disabled?: boolean + key?: string | number + children?: ComponentOptions[] + options?: ComponentOptions[] +} & Recordable + +export type ComponentOptionsAlias = { + labelField?: string + valueField?: string +} + +export type ComponentProps = { + optionsAlias?: ComponentOptionsAlias + options?: ComponentOptions[] + optionsSlot?: boolean +} & Recordable diff --git a/src/types/configGlobal.d.ts b/src/types/configGlobal.d.ts new file mode 100644 index 0000000..f6d7b3c --- /dev/null +++ b/src/types/configGlobal.d.ts @@ -0,0 +1,4 @@ +import { ElementPlusSize } from './elementPlus' +export interface ConfigGlobalTypes { + size?: ElementPlusSize +} diff --git a/src/types/contextMenu.d.ts b/src/types/contextMenu.d.ts new file mode 100644 index 0000000..0738d0e --- /dev/null +++ b/src/types/contextMenu.d.ts @@ -0,0 +1,7 @@ +export type contextMenuSchema = { + disabled?: boolean + divided?: boolean + icon?: string + label: string + command?: (item: contextMenuSchema) => void +} diff --git a/src/types/descriptions.d.ts b/src/types/descriptions.d.ts new file mode 100644 index 0000000..af6d68c --- /dev/null +++ b/src/types/descriptions.d.ts @@ -0,0 +1,14 @@ +export interface DescriptionsSchema { + span?: number // 占多少分 + field: string // 字段名 + label?: string // label名 + mappedField?: string // 字段映射 + width?: string | number + minWidth?: string | number + align?: 'left' | 'center' | 'right' + labelAlign?: 'left' | 'center' | 'right' + className?: string + labelClassName?: string + dateFormat?: string // add by 星语:支持时间的格式化 + dictType?: string // add by 星语:支持 dict 字典数据 +} diff --git a/src/types/elementPlus.d.ts b/src/types/elementPlus.d.ts new file mode 100644 index 0000000..2c6b76e --- /dev/null +++ b/src/types/elementPlus.d.ts @@ -0,0 +1,3 @@ +export type ElementPlusSize = 'default' | 'small' | 'large' + +export type ElementPlusInfoType = 'success' | 'info' | 'warning' | 'danger' diff --git a/src/types/form.d.ts b/src/types/form.d.ts new file mode 100644 index 0000000..980c8cc --- /dev/null +++ b/src/types/form.d.ts @@ -0,0 +1,44 @@ +import type { CSSProperties } from 'vue' +import { ColProps, ComponentProps, ComponentName } from '@/types/components' +import type { AxiosPromise } from 'axios' + +export type FormSetPropsType = { + field: string + path: string + value: any +} + +export type FormValueType = string | number | string[] | number[] | boolean | undefined | null + +export type FormItemProps = { + labelWidth?: string | number + required?: boolean + rules?: Recordable + error?: string + showMessage?: boolean + inlineMessage?: boolean + style?: CSSProperties +} + +export type FormSchema = { + // 唯一值 + field: string + // 标题 + label?: string + // 提示 + labelMessage?: string + // col组件属性 + colProps?: ColProps + // 表单组件属性,slots对应的是表单组件的插槽,规则:${field}-xxx,具体可以查看element-plus文档 + componentProps?: { slots?: Recordable } & ComponentProps + // formItem组件属性 + formItemProps?: FormItemProps + // 渲染的组件 + component?: ComponentName + // 初始值 + value?: FormValueType + // 是否隐藏 + hidden?: boolean + // 远程加载下拉项 + api?: <T = any>() => AxiosPromise<T> +} diff --git a/src/types/icon.d.ts b/src/types/icon.d.ts new file mode 100644 index 0000000..d1ffcdb --- /dev/null +++ b/src/types/icon.d.ts @@ -0,0 +1,5 @@ +export interface IconTypes { + size?: number + color?: string + icon: string +} diff --git a/src/types/infoTip.d.ts b/src/types/infoTip.d.ts new file mode 100644 index 0000000..6eff083 --- /dev/null +++ b/src/types/infoTip.d.ts @@ -0,0 +1,4 @@ +export interface TipSchema { + label: string + keys?: string[] +} diff --git a/src/types/layout.d.ts b/src/types/layout.d.ts new file mode 100644 index 0000000..cad3e2a --- /dev/null +++ b/src/types/layout.d.ts @@ -0,0 +1 @@ +export type LayoutType = 'classic' | 'topLeft' | 'top' | 'cutMenu' diff --git a/src/types/localeDropdown.d.ts b/src/types/localeDropdown.d.ts new file mode 100644 index 0000000..c749dce --- /dev/null +++ b/src/types/localeDropdown.d.ts @@ -0,0 +1,10 @@ +export interface Language { + el: Recordable + name: string +} + +export interface LocaleDropdownType { + lang: LocaleType + name?: string + elLocale?: Language +} diff --git a/src/types/qrcode.d.ts b/src/types/qrcode.d.ts new file mode 100644 index 0000000..86cdf0b --- /dev/null +++ b/src/types/qrcode.d.ts @@ -0,0 +1,9 @@ +export interface QrcodeLogo { + src?: string + logoSize?: number + bgColor?: string + borderSize?: number + crossOrigin?: string + borderRadius?: number + logoRadius?: number +} diff --git a/src/types/table.d.ts b/src/types/table.d.ts new file mode 100644 index 0000000..9cb4205 --- /dev/null +++ b/src/types/table.d.ts @@ -0,0 +1,44 @@ +export type TableColumn = { + field: string + label?: string + width?: number | string + fixed?: 'left' | 'right' + children?: TableColumn[] +} & Recordable + +export type VxeTableColumn = { + field: string + title?: string + children?: TableColumn[] +} & Recordable + +export type TableSlotDefault = { + row: Recordable + column: TableColumn + $index: number +} & Recordable + +export interface Pagination { + small?: boolean + background?: boolean + pageSize?: number + defaultPageSize?: number + total?: number + pageCount?: number + pagerCount?: number + currentPage?: number + defaultCurrentPage?: number + layout?: string + pageSizes?: number[] + popperClass?: string + prevText?: string + nextText?: string + disabled?: boolean + hideOnSinglePage?: boolean +} + +export interface TableSetPropsType { + field: string + path: string + value: any +} diff --git a/src/types/theme.d.ts b/src/types/theme.d.ts new file mode 100644 index 0000000..ad649b0 --- /dev/null +++ b/src/types/theme.d.ts @@ -0,0 +1,16 @@ +export type ThemeTypes = { + elColorPrimary?: string + leftMenuBorderColor?: string + leftMenuBgColor?: string + leftMenuBgLightColor?: string + leftMenuBgActiveColor?: string + leftMenuCollapseBgActiveColor?: string + leftMenuTextColor?: string + leftMenuTextActiveColor?: string + logoTitleTextColor?: string + logoBorderColor?: string + topHeaderBgColor?: string + topHeaderTextColor?: string + topHeaderHoverColor?: string + topToolBorderColor?: string +} diff --git a/src/utils/Logger.ts b/src/utils/Logger.ts new file mode 100644 index 0000000..ca58df2 --- /dev/null +++ b/src/utils/Logger.ts @@ -0,0 +1,100 @@ +const isArray = function (obj: any): boolean { + return Object.prototype.toString.call(obj) === '[object Array]' +} + +const Logger = () => {} + +Logger.typeColor = function (type: string) { + let color = '' + switch (type) { + case 'primary': + color = '#2d8cf0' + break + case 'success': + color = '#19be6b' + break + case 'info': + color = '#909399' + break + case 'warn': + color = '#ff9900' + break + case 'error': + color = '#f03f14' + break + default: + color = '#35495E' + break + } + return color +} + +Logger.print = function (type = 'default', text: any, back = false) { + if (typeof text === 'object') { + // 如果是對象則調用打印對象方式 + isArray(text) ? console.table(text) : console.dir(text) + return + } + if (back) { + // 如果是打印帶背景圖的 + console.log( + `%c ${text} `, + `background:${Logger.typeColor(type)}; padding: 2px; border-radius: 4px; color: #fff;` + ) + } else { + console.log( + `%c ${text} `, + `border: 1px solid ${Logger.typeColor(type)}; + padding: 2px; border-radius: 4px; + color: ${Logger.typeColor(type)};` + ) + } +} + +Logger.printBack = function (type = 'primary', text) { + this.print(type, text, true) +} + +Logger.pretty = function (type = 'primary', title, text) { + if (typeof text === 'object') { + console.group('Console Group', title) + console.log( + `%c ${title}`, + `background:${Logger.typeColor(type)};border:1px solid ${Logger.typeColor(type)}; + padding: 1px; border-radius: 4px; color: #fff;` + ) + isArray(text) ? console.table(text) : console.dir(text) + console.groupEnd() + return + } + console.log( + `%c ${title} %c ${text} %c`, + `background:${Logger.typeColor(type)};border:1px solid ${Logger.typeColor(type)}; + padding: 1px; border-radius: 4px 0 0 4px; color: #fff;`, + `border:1px solid ${Logger.typeColor(type)}; + padding: 1px; border-radius: 0 4px 4px 0; color: ${Logger.typeColor(type)};`, + 'background:transparent' + ) +} + +Logger.prettyPrimary = function (title, ...text) { + text.forEach((t) => this.pretty('primary', title, t)) +} + +Logger.prettySuccess = function (title, ...text) { + text.forEach((t) => this.pretty('success', title, t)) +} + +Logger.prettyWarn = function (title, ...text) { + text.forEach((t) => this.pretty('warn', title, t)) +} + +Logger.prettyError = function (title, ...text) { + text.forEach((t) => this.pretty('error', title, t)) +} + +Logger.prettyInfo = function (title, ...text) { + text.forEach((t) => this.pretty('info', title, t)) +} + +export default Logger diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..c68a67a --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,71 @@ +import { useCache, CACHE_KEY } from '@/hooks/web/useCache' +import { TokenType } from '@/api/login/types' +import { decrypt, encrypt } from '@/utils/jsencrypt' + +const { wsCache } = useCache() + +const AccessTokenKey = 'ACCESS_TOKEN' +const RefreshTokenKey = 'REFRESH_TOKEN' + +// 获取token +export const getAccessToken = () => { + // 此处与TokenKey相同,此写法解决初始化时Cookies中不存在TokenKey报错 + return wsCache.get(AccessTokenKey) ? wsCache.get(AccessTokenKey) : wsCache.get('ACCESS_TOKEN') +} + +// 刷新token +export const getRefreshToken = () => { + return wsCache.get(RefreshTokenKey) +} + +// 设置token +export const setToken = (token: TokenType) => { + wsCache.set(RefreshTokenKey, token.refreshToken) + wsCache.set(AccessTokenKey, token.accessToken) +} + +// 删除token +export const removeToken = () => { + wsCache.delete(AccessTokenKey) + wsCache.delete(RefreshTokenKey) +} + +/** 格式化token(jwt格式) */ +export const formatToken = (token: string): string => { + return 'Bearer ' + token +} +// ========== 账号相关 ========== + +export type LoginFormType = { + tenantName: string + username: string + password: string + rememberMe: boolean +} + +export const getLoginForm = () => { + const loginForm: LoginFormType = wsCache.get(CACHE_KEY.LoginForm) + if (loginForm) { + loginForm.password = decrypt(loginForm.password) as string + } + return loginForm +} + +export const setLoginForm = (loginForm: LoginFormType) => { + loginForm.password = encrypt(loginForm.password) as string + wsCache.set(CACHE_KEY.LoginForm, loginForm, { exp: 30 * 24 * 60 * 60 }) +} + +export const removeLoginForm = () => { + wsCache.delete(CACHE_KEY.LoginForm) +} + +// ========== 租户相关 ========== + +export const getTenantId = () => { + return wsCache.get(CACHE_KEY.TenantId) +} + +export const setTenantId = (username: string) => { + wsCache.set(CACHE_KEY.TenantId, username) +} diff --git a/src/utils/color.ts b/src/utils/color.ts new file mode 100644 index 0000000..13424e5 --- /dev/null +++ b/src/utils/color.ts @@ -0,0 +1,174 @@ +/** + * 判断是否 十六进制颜色值. + * 输入形式可为 #fff000 #f00 + * + * @param String color 十六进制颜色值 + * @return Boolean + */ +export const isHexColor = (color: string) => { + const reg = /^#([0-9a-fA-F]{3}|[0-9a-fA-f]{6})$/ + return reg.test(color) +} + +/** + * RGB 颜色值转换为 十六进制颜色值. + * r, g, 和 b 需要在 [0, 255] 范围内 + * + * @return String 类似#ff00ff + * @param r + * @param g + * @param b + */ +export const rgbToHex = (r: number, g: number, b: number) => { + // tslint:disable-next-line:no-bitwise + const hex = ((r << 16) | (g << 8) | b).toString(16) + return '#' + new Array(Math.abs(hex.length - 7)).join('0') + hex +} + +/** + * Transform a HEX color to its RGB representation + * @param {string} hex The color to transform + * @returns The RGB representation of the passed color + */ +export const hexToRGB = (hex: string, opacity?: number) => { + let sHex = hex.toLowerCase() + if (isHexColor(hex)) { + if (sHex.length === 4) { + let sColorNew = '#' + for (let i = 1; i < 4; i += 1) { + sColorNew += sHex.slice(i, i + 1).concat(sHex.slice(i, i + 1)) + } + sHex = sColorNew + } + const sColorChange: number[] = [] + for (let i = 1; i < 7; i += 2) { + sColorChange.push(parseInt('0x' + sHex.slice(i, i + 2))) + } + return opacity + ? 'RGBA(' + sColorChange.join(',') + ',' + opacity + ')' + : 'RGB(' + sColorChange.join(',') + ')' + } + return sHex +} + +export const colorIsDark = (color: string) => { + if (!isHexColor(color)) return + const [r, g, b] = hexToRGB(color) + .replace(/(?:\(|\)|rgb|RGB)*/g, '') + .split(',') + .map((item) => Number(item)) + return r * 0.299 + g * 0.578 + b * 0.114 < 192 +} + +/** + * Darkens a HEX color given the passed percentage + * @param {string} color The color to process + * @param {number} amount The amount to change the color by + * @returns {string} The HEX representation of the processed color + */ +export const darken = (color: string, amount: number) => { + color = color.indexOf('#') >= 0 ? color.substring(1, color.length) : color + amount = Math.trunc((255 * amount) / 100) + return `#${subtractLight(color.substring(0, 2), amount)}${subtractLight( + color.substring(2, 4), + amount + )}${subtractLight(color.substring(4, 6), amount)}` +} + +/** + * Lightens a 6 char HEX color according to the passed percentage + * @param {string} color The color to change + * @param {number} amount The amount to change the color by + * @returns {string} The processed color represented as HEX + */ +export const lighten = (color: string, amount: number) => { + color = color.indexOf('#') >= 0 ? color.substring(1, color.length) : color + amount = Math.trunc((255 * amount) / 100) + return `#${addLight(color.substring(0, 2), amount)}${addLight( + color.substring(2, 4), + amount + )}${addLight(color.substring(4, 6), amount)}` +} + +/* Suma el porcentaje indicado a un color (RR, GG o BB) hexadecimal para aclararlo */ +/** + * Sums the passed percentage to the R, G or B of a HEX color + * @param {string} color The color to change + * @param {number} amount The amount to change the color by + * @returns {string} The processed part of the color + */ +const addLight = (color: string, amount: number) => { + const cc = parseInt(color, 16) + amount + const c = cc > 255 ? 255 : cc + return c.toString(16).length > 1 ? c.toString(16) : `0${c.toString(16)}` +} + +/** + * Calculates luminance of an rgb color + * @param {number} r red + * @param {number} g green + * @param {number} b blue + */ +const luminanace = (r: number, g: number, b: number) => { + const a = [r, g, b].map((v) => { + v /= 255 + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) + }) + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722 +} + +/** + * Calculates contrast between two rgb colors + * @param {string} rgb1 rgb color 1 + * @param {string} rgb2 rgb color 2 + */ +const contrast = (rgb1: string[], rgb2: number[]) => { + return ( + (luminanace(~~rgb1[0], ~~rgb1[1], ~~rgb1[2]) + 0.05) / + (luminanace(rgb2[0], rgb2[1], rgb2[2]) + 0.05) + ) +} + +/** + * Determines what the best text color is (black or white) based con the contrast with the background + * @param hexColor - Last selected color by the user + */ +export const calculateBestTextColor = (hexColor: string) => { + const rgbColor = hexToRGB(hexColor.substring(1)) + const contrastWithBlack = contrast(rgbColor.split(','), [0, 0, 0]) + + return contrastWithBlack >= 12 ? '#000000' : '#FFFFFF' +} + +/** + * Subtracts the indicated percentage to the R, G or B of a HEX color + * @param {string} color The color to change + * @param {number} amount The amount to change the color by + * @returns {string} The processed part of the color + */ +const subtractLight = (color: string, amount: number) => { + const cc = parseInt(color, 16) - amount + const c = cc < 0 ? 0 : cc + return c.toString(16).length > 1 ? c.toString(16) : `0${c.toString(16)}` +} + +// 预设颜色 +export const PREDEFINE_COLORS = [ + '#ff4500', + '#ff8c00', + '#ffd700', + '#90ee90', + '#00ced1', + '#1e90ff', + '#c71585', + '#409EFF', + '#909399', + '#C0C4CC', + '#b7390b', + '#ff7800', + '#fad400', + '#5b8c5f', + '#00babd', + '#1f73c3', + '#711f57' +] diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..cfa785b --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,439 @@ +/** + * Created by 芋道源码 + * + * 枚举类 + */ + +// ========== COMMON 模块 ========== +// 全局通用状态枚举 +export const CommonStatusEnum = { + ENABLE: 0, // 开启 + DISABLE: 1 // 禁用 +} + +// 全局用户类型枚举 +export const UserTypeEnum = { + MEMBER: 1, // 会员 + ADMIN: 2 // 管理员 +} + +// ========== SYSTEM 模块 ========== +/** + * 菜单的类型枚举 + */ +export const SystemMenuTypeEnum = { + DIR: 1, // 目录 + MENU: 2, // 菜单 + BUTTON: 3 // 按钮 +} + +/** + * 角色的类型枚举 + */ +export const SystemRoleTypeEnum = { + SYSTEM: 1, // 内置角色 + CUSTOM: 2 // 自定义角色 +} + +/** + * 数据权限的范围枚举 + */ +export const SystemDataScopeEnum = { + ALL: 1, // 全部数据权限 + DEPT_CUSTOM: 2, // 指定部门数据权限 + DEPT_ONLY: 3, // 部门数据权限 + DEPT_AND_CHILD: 4, // 部门及以下数据权限 + DEPT_SELF: 5 // 仅本人数据权限 +} + +/** + * 用户的社交平台的类型枚举 + */ +export const SystemUserSocialTypeEnum = { + DINGTALK: { + title: '钉钉', + type: 20, + source: 'dingtalk', + img: 'https://s1.ax1x.com/2022/05/22/OzMDRs.png' + }, + WECHAT_ENTERPRISE: { + title: '企业微信', + type: 30, + source: 'wechat_enterprise', + img: 'https://s1.ax1x.com/2022/05/22/OzMrzn.png' + } +} + +// ========== INFRA 模块 ========== +/** + * 代码生成模板类型 + */ +export const InfraCodegenTemplateTypeEnum = { + CRUD: 1, // 基础 CRUD + TREE: 2, // 树形 CRUD + SUB: 3 // 主子表 CRUD +} + +/** + * 任务状态的枚举 + */ +export const InfraJobStatusEnum = { + INIT: 0, // 初始化中 + NORMAL: 1, // 运行中 + STOP: 2 // 暂停运行 +} + +/** + * API 异常数据的处理状态 + */ +export const InfraApiErrorLogProcessStatusEnum = { + INIT: 0, // 未处理 + DONE: 1, // 已处理 + IGNORE: 2 // 已忽略 +} + +// ========== PAY 模块 ========== +/** + * 支付渠道枚举 + */ +export const PayChannelEnum = { + WX_PUB: { + code: 'wx_pub', + name: '微信 JSAPI 支付' + }, + WX_LITE: { + code: 'wx_lite', + name: '微信小程序支付' + }, + WX_APP: { + code: 'wx_app', + name: '微信 APP 支付' + }, + WX_NATIVE: { + code: 'wx_native', + name: '微信 Native 支付' + }, + WX_WAP: { + code: 'wx_wap', + name: '微信 WAP 网站支付' + }, + WX_BAR: { + code: 'wx_bar', + name: '微信条码支付' + }, + ALIPAY_PC: { + code: 'alipay_pc', + name: '支付宝 PC 网站支付' + }, + ALIPAY_WAP: { + code: 'alipay_wap', + name: '支付宝 WAP 网站支付' + }, + ALIPAY_APP: { + code: 'alipay_app', + name: '支付宝 APP 支付' + }, + ALIPAY_QR: { + code: 'alipay_qr', + name: '支付宝扫码支付' + }, + ALIPAY_BAR: { + code: 'alipay_bar', + name: '支付宝条码支付' + }, + WALLET: { + code: 'wallet', + name: '钱包支付' + }, + MOCK: { + code: 'mock', + name: '模拟支付' + } +} + +/** + * 支付的展示模式每局 + */ +export const PayDisplayModeEnum = { + URL: { + mode: 'url' + }, + IFRAME: { + mode: 'iframe' + }, + FORM: { + mode: 'form' + }, + QR_CODE: { + mode: 'qr_code' + }, + APP: { + mode: 'app' + } +} + +/** + * 支付类型枚举 + */ +export const PayType = { + WECHAT: 'WECHAT', + ALIPAY: 'ALIPAY', + MOCK: 'MOCK' +} + +/** + * 支付订单状态枚举 + */ +export const PayOrderStatusEnum = { + WAITING: { + status: 0, + name: '未支付' + }, + SUCCESS: { + status: 10, + name: '已支付' + }, + CLOSED: { + status: 20, + name: '未支付' + } +} + +// ========== MALL - 商品模块 ========== +/** + * 商品 SPU 状态 + */ +export const ProductSpuStatusEnum = { + RECYCLE: { + status: -1, + name: '回收站' + }, + DISABLE: { + status: 0, + name: '下架' + }, + ENABLE: { + status: 1, + name: '上架' + } +} + +// ========== MALL - 营销模块 ========== +/** + * 优惠劵模板的有限期类型的枚举 + */ +export const CouponTemplateValidityTypeEnum = { + DATE: { + type: 1, + name: '固定日期可用' + }, + TERM: { + type: 2, + name: '领取之后可用' + } +} + +/** + * 优惠劵模板的领取方式的枚举 + */ +export const CouponTemplateTakeTypeEnum = { + USER: { + type: 1, + name: '直接领取' + }, + ADMIN: { + type: 2, + name: '指定发放' + }, + REGISTER: { + type: 3, + name: '新人券' + } +} + +/** + * 营销的商品范围枚举 + */ +export const PromotionProductScopeEnum = { + ALL: { + scope: 1, + name: '通用劵' + }, + SPU: { + scope: 2, + name: '商品劵' + }, + CATEGORY: { + scope: 3, + name: '品类劵' + } +} + +/** + * 营销的条件类型枚举 + */ +export const PromotionConditionTypeEnum = { + PRICE: { + type: 10, + name: '满 N 元' + }, + COUNT: { + type: 20, + name: '满 N 件' + } +} + +/** + * 优惠类型枚举 + */ +export const PromotionDiscountTypeEnum = { + PRICE: { + type: 1, + name: '满减' + }, + PERCENT: { + type: 2, + name: '折扣' + } +} + +// ========== MALL - 交易模块 ========== +/** + * 分销关系绑定模式枚举 + */ +export const BrokerageBindModeEnum = { + ANYTIME: { + mode: 1, + name: '首次绑定' + }, + REGISTER: { + mode: 2, + name: '注册绑定' + }, + OVERRIDE: { + mode: 3, + name: '覆盖绑定' + } +} +/** + * 分佣模式枚举 + */ +export const BrokerageEnabledConditionEnum = { + ALL: { + condition: 1, + name: '人人分销' + }, + ADMIN: { + condition: 2, + name: '指定分销' + } +} +/** + * 佣金记录业务类型枚举 + */ +export const BrokerageRecordBizTypeEnum = { + ORDER: { + type: 1, + name: '获得推广佣金' + }, + WITHDRAW: { + type: 2, + name: '提现申请' + } +} +/** + * 佣金提现状态枚举 + */ +export const BrokerageWithdrawStatusEnum = { + AUDITING: { + status: 0, + name: '审核中' + }, + AUDIT_SUCCESS: { + status: 10, + name: '审核通过' + }, + AUDIT_FAIL: { + status: 20, + name: '审核不通过' + }, + WITHDRAW_SUCCESS: { + status: 11, + name: '提现成功' + }, + WITHDRAW_FAIL: { + status: 21, + name: '提现失败' + } +} +/** + * 佣金提现类型枚举 + */ +export const BrokerageWithdrawTypeEnum = { + WALLET: { + type: 1, + name: '钱包' + }, + BANK: { + type: 2, + name: '银行卡' + }, + WECHAT: { + type: 3, + name: '微信' + }, + ALIPAY: { + type: 4, + name: '支付宝' + } +} + +/** + * 配送方式枚举 + */ +export const DeliveryTypeEnum = { + EXPRESS: { + type: 1, + name: '快递发货' + }, + PICK_UP: { + type: 2, + name: '到店自提' + } +} +/** + * 交易订单 - 状态 + */ +export const TradeOrderStatusEnum = { + UNPAID: { + status: 0, + name: '待支付' + }, + UNDELIVERED: { + status: 10, + name: '待发货' + }, + DELIVERED: { + status: 20, + name: '已发货' + }, + COMPLETED: { + status: 30, + name: '已完成' + }, + CANCELED: { + status: 40, + name: '已取消' + } +} + +// ========== ERP - 企业资源计划 ========== + +export const ErpBizType = { + PURCHASE_ORDER: 10, + PURCHASE_IN: 11, + PURCHASE_RETURN: 12, + SALE_ORDER: 20, + SALE_OUT: 21, + SALE_RETURN: 22 +} diff --git a/src/utils/dateUtil.ts b/src/utils/dateUtil.ts new file mode 100644 index 0000000..316b870 --- /dev/null +++ b/src/utils/dateUtil.ts @@ -0,0 +1,18 @@ +/** + * Independent time operation tool to facilitate subsequent switch to dayjs + */ +// TODO 芋艿:【锁屏】可能后面删除掉 +import dayjs from 'dayjs' + +const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss' +const DATE_FORMAT = 'YYYY-MM-DD' + +export function formatToDateTime(date?: dayjs.ConfigType, format = DATE_TIME_FORMAT): string { + return dayjs(date).format(format) +} + +export function formatToDate(date?: dayjs.ConfigType, format = DATE_FORMAT): string { + return dayjs(date).format(format) +} + +export const dateUtil = dayjs diff --git a/src/utils/dict.ts b/src/utils/dict.ts new file mode 100644 index 0000000..7fbcfeb --- /dev/null +++ b/src/utils/dict.ts @@ -0,0 +1,230 @@ +/** + * 数据字典工具类 + */ +import { useDictStoreWithOut } from '@/store/modules/dict' +import { ElementPlusInfoType } from '@/types/elementPlus' + +const dictStore = useDictStoreWithOut() + +/** + * 获取 dictType 对应的数据字典数组 + * + * @param dictType 数据类型 + * @returns {*|Array} 数据字典数组 + */ +export interface DictDataType { + dictType: string + label: string + value: string | number | boolean + colorType: ElementPlusInfoType | '' + cssClass: string +} + +export interface NumberDictDataType extends DictDataType { + value: number +} + +export interface StringDictDataType extends DictDataType { + value: string +} + +export const getDictOptions = (dictType: string) => { + return dictStore.getDictByType(dictType) || [] +} + +export const getIntDictOptions = (dictType: string): NumberDictDataType[] => { + // 获得通用的 DictDataType 列表 + const dictOptions: DictDataType[] = getDictOptions(dictType) + // 转换成 number 类型的 NumberDictDataType 类型 + // why 需要特殊转换:避免 IDEA 在 v-for="dict in getIntDictOptions(...)" 时,el-option 的 key 会告警 + const dictOption: NumberDictDataType[] = [] + dictOptions.forEach((dict: DictDataType) => { + dictOption.push({ + ...dict, + value: parseInt(dict.value + '') + }) + }) + return dictOption +} + +export const getStrDictOptions = (dictType: string) => { + // 获得通用的 DictDataType 列表 + const dictOptions: DictDataType[] = getDictOptions(dictType) + // 转换成 string 类型的 StringDictDataType 类型 + // why 需要特殊转换:避免 IDEA 在 v-for="dict in getStrDictOptions(...)" 时,el-option 的 key 会告警 + const dictOption: StringDictDataType[] = [] + dictOptions.forEach((dict: DictDataType) => { + dictOption.push({ + ...dict, + value: dict.value + '' + }) + }) + return dictOption +} + +export const getBoolDictOptions = (dictType: string) => { + const dictOption: DictDataType[] = [] + const dictOptions: DictDataType[] = getDictOptions(dictType) + dictOptions.forEach((dict: DictDataType) => { + dictOption.push({ + ...dict, + value: dict.value + '' === 'true' + }) + }) + return dictOption +} + +/** + * 获取指定字典类型的指定值对应的字典对象 + * @param dictType 字典类型 + * @param value 字典值 + * @return DictDataType 字典对象 + */ +export const getDictObj = (dictType: string, value: any): DictDataType | undefined => { + const dictOptions: DictDataType[] = getDictOptions(dictType) + for (const dict of dictOptions) { + if (dict.value === value + '') { + return dict + } + } +} + +/** + * 获得字典数据的文本展示 + * + * @param dictType 字典类型 + * @param value 字典数据的值 + * @return 字典名称 + */ +export const getDictLabel = (dictType: string, value: any): string => { + const dictOptions: DictDataType[] = getDictOptions(dictType) + const dictLabel = ref('') + dictOptions.forEach((dict: DictDataType) => { + if (dict.value === value + '') { + dictLabel.value = dict.label + } + }) + return dictLabel.value +} + +export enum DICT_TYPE { + USER_TYPE = 'user_type', + COMMON_STATUS = 'common_status', + TERMINAL = 'terminal', // 终端 + DATE_INTERVAL = 'date_interval', // 数据间隔 + + // ========== SYSTEM 模块 ========== + SYSTEM_USER_SEX = 'system_user_sex', + SYSTEM_MENU_TYPE = 'system_menu_type', + SYSTEM_ROLE_TYPE = 'system_role_type', + SYSTEM_DATA_SCOPE = 'system_data_scope', + SYSTEM_NOTICE_TYPE = 'system_notice_type', + SYSTEM_LOGIN_TYPE = 'system_login_type', + SYSTEM_LOGIN_RESULT = 'system_login_result', + SYSTEM_SMS_CHANNEL_CODE = 'system_sms_channel_code', + SYSTEM_SMS_TEMPLATE_TYPE = 'system_sms_template_type', + SYSTEM_SMS_SEND_STATUS = 'system_sms_send_status', + SYSTEM_SMS_RECEIVE_STATUS = 'system_sms_receive_status', + SYSTEM_OAUTH2_GRANT_TYPE = 'system_oauth2_grant_type', + SYSTEM_MAIL_SEND_STATUS = 'system_mail_send_status', + SYSTEM_NOTIFY_TEMPLATE_TYPE = 'system_notify_template_type', + SYSTEM_SOCIAL_TYPE = 'system_social_type', + + // ========== INFRA 模块 ========== + INFRA_BOOLEAN_STRING = 'infra_boolean_string', + INFRA_JOB_STATUS = 'infra_job_status', + INFRA_JOB_LOG_STATUS = 'infra_job_log_status', + INFRA_API_ERROR_LOG_PROCESS_STATUS = 'infra_api_error_log_process_status', + INFRA_CONFIG_TYPE = 'infra_config_type', + INFRA_CODEGEN_TEMPLATE_TYPE = 'infra_codegen_template_type', + INFRA_CODEGEN_FRONT_TYPE = 'infra_codegen_front_type', + INFRA_CODEGEN_SCENE = 'infra_codegen_scene', + INFRA_FILE_STORAGE = 'infra_file_storage', + INFRA_OPERATE_TYPE = 'infra_operate_type', + + // ========== BPM 模块 ========== + BPM_MODEL_FORM_TYPE = 'bpm_model_form_type', + BPM_TASK_CANDIDATE_STRATEGY = 'bpm_task_candidate_strategy', + BPM_PROCESS_INSTANCE_STATUS = 'bpm_process_instance_status', + BPM_TASK_STATUS = 'bpm_task_status', + BPM_OA_LEAVE_TYPE = 'bpm_oa_leave_type', + BPM_PROCESS_LISTENER_TYPE = 'bpm_process_listener_type', + BPM_PROCESS_LISTENER_VALUE_TYPE = 'bpm_process_listener_value_type', + + // ========== PAY 模块 ========== + PAY_CHANNEL_CODE = 'pay_channel_code', // 支付渠道编码类型 + PAY_ORDER_STATUS = 'pay_order_status', // 商户支付订单状态 + PAY_REFUND_STATUS = 'pay_refund_status', // 退款订单状态 + PAY_NOTIFY_STATUS = 'pay_notify_status', // 商户支付回调状态 + PAY_NOTIFY_TYPE = 'pay_notify_type', // 商户支付回调状态 + PAY_TRANSFER_STATUS = 'pay_transfer_status', // 转账订单状态 + PAY_TRANSFER_TYPE = 'pay_transfer_type', // 转账订单状态 + + // ========== MP 模块 ========== + MP_AUTO_REPLY_REQUEST_MATCH = 'mp_auto_reply_request_match', // 自动回复请求匹配类型 + MP_MESSAGE_TYPE = 'mp_message_type', // 消息类型 + + // ========== Member 会员模块 ========== + MEMBER_POINT_BIZ_TYPE = 'member_point_biz_type', // 积分的业务类型 + MEMBER_EXPERIENCE_BIZ_TYPE = 'member_experience_biz_type', // 会员经验业务类型 + + // ========== MALL - 商品模块 ========== + PRODUCT_SPU_STATUS = 'product_spu_status', //商品状态 + + // ========== MALL - 交易模块 ========== + EXPRESS_CHARGE_MODE = 'trade_delivery_express_charge_mode', //快递的计费方式 + TRADE_AFTER_SALE_STATUS = 'trade_after_sale_status', // 售后 - 状态 + TRADE_AFTER_SALE_WAY = 'trade_after_sale_way', // 售后 - 方式 + TRADE_AFTER_SALE_TYPE = 'trade_after_sale_type', // 售后 - 类型 + TRADE_ORDER_TYPE = 'trade_order_type', // 订单 - 类型 + TRADE_ORDER_STATUS = 'trade_order_status', // 订单 - 状态 + TRADE_ORDER_ITEM_AFTER_SALE_STATUS = 'trade_order_item_after_sale_status', // 订单项 - 售后状态 + TRADE_DELIVERY_TYPE = 'trade_delivery_type', // 配送方式 + BROKERAGE_ENABLED_CONDITION = 'brokerage_enabled_condition', // 分佣模式 + BROKERAGE_BIND_MODE = 'brokerage_bind_mode', // 分销关系绑定模式 + BROKERAGE_BANK_NAME = 'brokerage_bank_name', // 佣金提现银行 + BROKERAGE_WITHDRAW_TYPE = 'brokerage_withdraw_type', // 佣金提现类型 + BROKERAGE_RECORD_BIZ_TYPE = 'brokerage_record_biz_type', // 佣金业务类型 + BROKERAGE_RECORD_STATUS = 'brokerage_record_status', // 佣金状态 + BROKERAGE_WITHDRAW_STATUS = 'brokerage_withdraw_status', // 佣金提现状态 + + // ========== MALL - 营销模块 ========== + PROMOTION_DISCOUNT_TYPE = 'promotion_discount_type', // 优惠类型 + PROMOTION_PRODUCT_SCOPE = 'promotion_product_scope', // 营销的商品范围 + PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE = 'promotion_coupon_template_validity_type', // 优惠劵模板的有限期类型 + PROMOTION_COUPON_STATUS = 'promotion_coupon_status', // 优惠劵的状态 + PROMOTION_COUPON_TAKE_TYPE = 'promotion_coupon_take_type', // 优惠劵的领取方式 + PROMOTION_ACTIVITY_STATUS = 'promotion_activity_status', // 优惠活动的状态 + PROMOTION_CONDITION_TYPE = 'promotion_condition_type', // 营销的条件类型枚举 + PROMOTION_BARGAIN_RECORD_STATUS = 'promotion_bargain_record_status', // 砍价记录的状态 + PROMOTION_COMBINATION_RECORD_STATUS = 'promotion_combination_record_status', // 拼团记录的状态 + PROMOTION_BANNER_POSITION = 'promotion_banner_position', // banner 定位 + + // ========== CRM - 客户管理模块 ========== + CRM_AUDIT_STATUS = 'crm_audit_status', // CRM 审批状态 + CRM_BIZ_TYPE = 'crm_biz_type', // CRM 业务类型 + CRM_BUSINESS_END_STATUS_TYPE = 'crm_business_end_status_type', // CRM 商机结束状态类型 + CRM_RECEIVABLE_RETURN_TYPE = 'crm_receivable_return_type', // CRM 回款的还款方式 + CRM_CUSTOMER_INDUSTRY = 'crm_customer_industry', // CRM 客户所属行业 + CRM_CUSTOMER_LEVEL = 'crm_customer_level', // CRM 客户级别 + CRM_CUSTOMER_SOURCE = 'crm_customer_source', // CRM 客户来源 + CRM_PRODUCT_STATUS = 'crm_product_status', // CRM 商品状态 + CRM_PERMISSION_LEVEL = 'crm_permission_level', // CRM 数据权限的级别 + CRM_PRODUCT_UNIT = 'crm_product_unit', // CRM 产品单位 + CRM_FOLLOW_UP_TYPE = 'crm_follow_up_type', // CRM 跟进方式 + + // ========== ERP - 企业资源计划模块 ========== + ERP_AUDIT_STATUS = 'erp_audit_status', // ERP 审批状态 + ERP_STOCK_RECORD_BIZ_TYPE = 'erp_stock_record_biz_type', // 库存明细的业务类型 + + // ========== AI - 人工智能模块 ========== + AI_PLATFORM = 'ai_platform', // AI 平台 + AI_IMAGE_STATUS = 'ai_image_status', // AI 图片状态 + AI_MUSIC_STATUS = 'ai_music_status', // AI 音乐状态 + AI_GENERATE_MODE = 'ai_generate_mode', // AI 生成模式 + AI_WRITE_TYPE = 'ai_write_type', // AI 写作类型 + AI_WRITE_LENGTH = 'ai_write_length', // AI 写作长度 + AI_WRITE_FORMAT = 'ai_write_format', // AI 写作格式 + AI_WRITE_TONE = 'ai_write_tone', // AI 写作语气 + AI_WRITE_LANGUAGE = 'ai_write_language' // AI 写作语言 +} diff --git a/src/utils/domUtils.ts b/src/utils/domUtils.ts new file mode 100644 index 0000000..dbc1989 --- /dev/null +++ b/src/utils/domUtils.ts @@ -0,0 +1,289 @@ +import { isServer } from './is' +const ieVersion = isServer ? 0 : Number((document as any).documentMode) +const SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g +const MOZ_HACK_REGEXP = /^moz([A-Z])/ + +export interface ViewportOffsetResult { + left: number + top: number + right: number + bottom: number + rightIncludeBody: number + bottomIncludeBody: number +} + +/* istanbul ignore next */ +const trim = function (string: string) { + return (string || '').replace(/^[\s\uFEFF]+|[\s\uFEFF]+$/g, '') +} + +/* istanbul ignore next */ +const camelCase = function (name: string) { + return name + .replace(SPECIAL_CHARS_REGEXP, function (_, __, letter, offset) { + return offset ? letter.toUpperCase() : letter + }) + .replace(MOZ_HACK_REGEXP, 'Moz$1') +} + +/* istanbul ignore next */ +export function hasClass(el: Element, cls: string) { + if (!el || !cls) return false + if (cls.indexOf(' ') !== -1) { + throw new Error('className should not contain space.') + } + if (el.classList) { + return el.classList.contains(cls) + } else { + return (' ' + el.className + ' ').indexOf(' ' + cls + ' ') > -1 + } +} + +/* istanbul ignore next */ +export function addClass(el: Element, cls: string) { + if (!el) return + let curClass = el.className + const classes = (cls || '').split(' ') + + for (let i = 0, j = classes.length; i < j; i++) { + const clsName = classes[i] + if (!clsName) continue + + if (el.classList) { + el.classList.add(clsName) + } else if (!hasClass(el, clsName)) { + curClass += ' ' + clsName + } + } + if (!el.classList) { + el.className = curClass + } +} + +/* istanbul ignore next */ +export function removeClass(el: Element, cls: string) { + if (!el || !cls) return + const classes = cls.split(' ') + let curClass = ' ' + el.className + ' ' + + for (let i = 0, j = classes.length; i < j; i++) { + const clsName = classes[i] + if (!clsName) continue + + if (el.classList) { + el.classList.remove(clsName) + } else if (hasClass(el, clsName)) { + curClass = curClass.replace(' ' + clsName + ' ', ' ') + } + } + if (!el.classList) { + el.className = trim(curClass) + } +} + +export function getBoundingClientRect(element: Element): DOMRect | number { + if (!element || !element.getBoundingClientRect) { + return 0 + } + return element.getBoundingClientRect() +} + +/** + * 获取当前元素的left、top偏移 + * left:元素最左侧距离文档左侧的距离 + * top:元素最顶端距离文档顶端的距离 + * right:元素最右侧距离文档右侧的距离 + * bottom:元素最底端距离文档底端的距离 + * rightIncludeBody:元素最左侧距离文档右侧的距离 + * bottomIncludeBody:元素最底端距离文档最底部的距离 + * + * @description: + */ +export function getViewportOffset(element: Element): ViewportOffsetResult { + const doc = document.documentElement + + const docScrollLeft = doc.scrollLeft + const docScrollTop = doc.scrollTop + const docClientLeft = doc.clientLeft + const docClientTop = doc.clientTop + + const pageXOffset = window.pageXOffset + const pageYOffset = window.pageYOffset + + const box = getBoundingClientRect(element) + + const { left: retLeft, top: rectTop, width: rectWidth, height: rectHeight } = box as DOMRect + + const scrollLeft = (pageXOffset || docScrollLeft) - (docClientLeft || 0) + const scrollTop = (pageYOffset || docScrollTop) - (docClientTop || 0) + const offsetLeft = retLeft + pageXOffset + const offsetTop = rectTop + pageYOffset + + const left = offsetLeft - scrollLeft + const top = offsetTop - scrollTop + + const clientWidth = window.document.documentElement.clientWidth + const clientHeight = window.document.documentElement.clientHeight + return { + left: left, + top: top, + right: clientWidth - rectWidth - left, + bottom: clientHeight - rectHeight - top, + rightIncludeBody: clientWidth - left, + bottomIncludeBody: clientHeight - top + } +} + +/* istanbul ignore next */ +export const on = function ( + element: HTMLElement | Document | Window, + event: string, + handler: EventListenerOrEventListenerObject +): void { + if (element && event && handler) { + element.addEventListener(event, handler, false) + } +} + +/* istanbul ignore next */ +export const off = function ( + element: HTMLElement | Document | Window, + event: string, + handler: any +): void { + if (element && event && handler) { + element.removeEventListener(event, handler, false) + } +} + +/* istanbul ignore next */ +export const once = function (el: HTMLElement, event: string, fn: EventListener): void { + const listener = function (this: any, ...args: unknown[]) { + if (fn) { + // @ts-ignore + fn.apply(this, args) + } + off(el, event, listener) + } + on(el, event, listener) +} + +/* istanbul ignore next */ +export const getStyle = + ieVersion < 9 + ? function (element: Element | any, styleName: string) { + if (isServer) return + if (!element || !styleName) return null + styleName = camelCase(styleName) + if (styleName === 'float') { + styleName = 'styleFloat' + } + try { + switch (styleName) { + case 'opacity': + try { + return element.filters.item('alpha').opacity / 100 + } catch (e) { + return 1.0 + } + default: + return element.style[styleName] || element.currentStyle + ? element.currentStyle[styleName] + : null + } + } catch (e) { + return element.style[styleName] + } + } + : function (element: Element | any, styleName: string) { + if (isServer) return + if (!element || !styleName) return null + styleName = camelCase(styleName) + if (styleName === 'float') { + styleName = 'cssFloat' + } + try { + const computed = (document as any).defaultView.getComputedStyle(element, '') + return element.style[styleName] || computed ? computed[styleName] : null + } catch (e) { + return element.style[styleName] + } + } + +/* istanbul ignore next */ +export function setStyle(element: Element | any, styleName: any, value: any) { + if (!element || !styleName) return + + if (typeof styleName === 'object') { + for (const prop in styleName) { + if (Object.prototype.hasOwnProperty.call(styleName, prop)) { + setStyle(element, prop, styleName[prop]) + } + } + } else { + styleName = camelCase(styleName) + if (styleName === 'opacity' && ieVersion < 9) { + element.style.filter = isNaN(value) ? '' : 'alpha(opacity=' + value * 100 + ')' + } else { + element.style[styleName] = value + } + } +} + +/* istanbul ignore next */ +export const isScroll = (el: Element, vertical: any) => { + if (isServer) return + + const determinedDirection = vertical !== null || vertical !== undefined + const overflow = determinedDirection + ? vertical + ? getStyle(el, 'overflow-y') + : getStyle(el, 'overflow-x') + : getStyle(el, 'overflow') + + return overflow.match(/(scroll|auto)/) +} + +/* istanbul ignore next */ +export const getScrollContainer = (el: Element, vertical?: any) => { + if (isServer) return + + let parent: any = el + while (parent) { + if ([window, document, document.documentElement].includes(parent)) { + return window + } + if (isScroll(parent, vertical)) { + return parent + } + parent = parent.parentNode + } + + return parent +} + +/* istanbul ignore next */ +export const isInContainer = (el: Element, container: any) => { + if (isServer || !el || !container) return false + + const elRect = el.getBoundingClientRect() + let containerRect + + if ([window, document, document.documentElement, null, undefined].includes(container)) { + containerRect = { + top: 0, + right: window.innerWidth, + bottom: window.innerHeight, + left: 0 + } + } else { + containerRect = container.getBoundingClientRect() + } + + return ( + elRect.top < containerRect.bottom && + elRect.bottom > containerRect.top && + elRect.right > containerRect.left && + elRect.left < containerRect.right + ) +} diff --git a/src/utils/download.ts b/src/utils/download.ts new file mode 100644 index 0000000..5bbfb9f --- /dev/null +++ b/src/utils/download.ts @@ -0,0 +1,71 @@ +const download0 = (data: Blob, fileName: string, mineType: string) => { + // 创建 blob + const blob = new Blob([data], { type: mineType }) + // 创建 href 超链接,点击进行下载 + window.URL = window.URL || window.webkitURL + const href = URL.createObjectURL(blob) + const downA = document.createElement('a') + downA.href = href + downA.download = fileName + downA.click() + // 销毁超连接 + window.URL.revokeObjectURL(href) +} + +const download = { + // 下载 Excel 方法 + excel: (data: Blob, fileName: string) => { + download0(data, fileName, 'application/vnd.ms-excel') + }, + // 下载 Word 方法 + word: (data: Blob, fileName: string) => { + download0(data, fileName, 'application/msword') + }, + // 下载 Zip 方法 + zip: (data: Blob, fileName: string) => { + download0(data, fileName, 'application/zip') + }, + // 下载 Html 方法 + html: (data: Blob, fileName: string) => { + download0(data, fileName, 'text/html') + }, + // 下载 Markdown 方法 + markdown: (data: Blob, fileName: string) => { + download0(data, fileName, 'text/markdown') + }, + // 下载图片(允许跨域) + image: ({ + url, + canvasWidth, + canvasHeight, + drawWithImageSize = true + }: { + url: string + canvasWidth?: number // 指定画布宽度 + canvasHeight?: number // 指定画布高度 + drawWithImageSize?: boolean // 将图片绘制在画布上时带上图片的宽高值, 默认是要带上的 + }) => { + const image = new Image() + // image.setAttribute('crossOrigin', 'anonymous') + image.src = url + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = canvasWidth || image.width + canvas.height = canvasHeight || image.height + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D + ctx?.clearRect(0, 0, canvas.width, canvas.height) + if (drawWithImageSize) { + ctx.drawImage(image, 0, 0, image.width, image.height) + } else { + ctx.drawImage(image, 0, 0) + } + const url = canvas.toDataURL('image/png') + const a = document.createElement('a') + a.href = url + a.download = 'image.png' + a.click() + } + } +} + +export default download diff --git a/src/utils/filt.ts b/src/utils/filt.ts new file mode 100644 index 0000000..b1a7b2c --- /dev/null +++ b/src/utils/filt.ts @@ -0,0 +1,157 @@ +export const openWindow = ( + url: string, + opt?: { + target?: '_self' | '_blank' | string + noopener?: boolean + noreferrer?: boolean + } +) => { + const { target = '__blank', noopener = true, noreferrer = true } = opt || {} + const feature: string[] = [] + + noopener && feature.push('noopener=yes') + noreferrer && feature.push('noreferrer=yes') + + window.open(url, target, feature.join(',')) +} + +/** + * @description: base64 to blob + */ +export const dataURLtoBlob = (base64Buf: string): Blob => { + const arr = base64Buf.split(',') + const typeItem = arr[0] + const mime = typeItem.match(/:(.*?);/)![1] + const bstr = window.atob(arr[1]) + let n = bstr.length + const u8arr = new Uint8Array(n) + while (n--) { + u8arr[n] = bstr.charCodeAt(n) + } + return new Blob([u8arr], { type: mime }) +} + +/** + * img url to base64 + * @param url + */ +export const urlToBase64 = (url: string, mineType?: string): Promise<string> => { + return new Promise((resolve, reject) => { + let canvas = document.createElement('CANVAS') as Nullable<HTMLCanvasElement> + const ctx = canvas!.getContext('2d') + + const img = new Image() + img.crossOrigin = '' + img.onload = function () { + if (!canvas || !ctx) { + return reject() + } + canvas.height = img.height + canvas.width = img.width + ctx.drawImage(img, 0, 0) + const dataURL = canvas.toDataURL(mineType || 'image/png') + canvas = null + resolve(dataURL) + } + img.src = url + }) +} + +/** + * Download online pictures + * @param url + * @param filename + * @param mime + * @param bom + */ +export const downloadByOnlineUrl = ( + url: string, + filename: string, + mime?: string, + bom?: BlobPart +) => { + urlToBase64(url).then((base64) => { + downloadByBase64(base64, filename, mime, bom) + }) +} + +/** + * Download pictures based on base64 + * @param buf + * @param filename + * @param mime + * @param bom + */ +export const downloadByBase64 = (buf: string, filename: string, mime?: string, bom?: BlobPart) => { + const base64Buf = dataURLtoBlob(buf) + downloadByData(base64Buf, filename, mime, bom) +} + +/** + * Download according to the background interface file stream + * @param {*} data + * @param {*} filename + * @param {*} mime + * @param {*} bom + */ +export const downloadByData = (data: BlobPart, filename: string, mime?: string, bom?: BlobPart) => { + const blobData = typeof bom !== 'undefined' ? [bom, data] : [data] + const blob = new Blob(blobData, { type: mime || 'application/octet-stream' }) + + const blobURL = window.URL.createObjectURL(blob) + const tempLink = document.createElement('a') + tempLink.style.display = 'none' + tempLink.href = blobURL + tempLink.setAttribute('download', filename) + if (typeof tempLink.download === 'undefined') { + tempLink.setAttribute('target', '_blank') + } + document.body.appendChild(tempLink) + tempLink.click() + document.body.removeChild(tempLink) + window.URL.revokeObjectURL(blobURL) +} + +/** + * Download file according to file address + * @param {*} sUrl + */ +export const downloadByUrl = ({ + url, + target = '_blank', + fileName +}: { + url: string + target?: '_self' | '_blank' + fileName?: string +}): boolean => { + const isChrome = window.navigator.userAgent.toLowerCase().indexOf('chrome') > -1 + const isSafari = window.navigator.userAgent.toLowerCase().indexOf('safari') > -1 + + if (/(iP)/g.test(window.navigator.userAgent)) { + console.error('Your browser does not support download!') + return false + } + if (isChrome || isSafari) { + const link = document.createElement('a') + link.href = url + link.target = target + + if (link.download !== undefined) { + link.download = fileName || url.substring(url.lastIndexOf('/') + 1, url.length) + } + + if (document.createEvent) { + const e = document.createEvent('MouseEvents') + e.initEvent('click', true, true) + link.dispatchEvent(e) + return true + } + } + if (url.indexOf('?') === -1) { + url += '?download' + } + + openWindow(url, { target }) + return true +} diff --git a/src/utils/formCreate.ts b/src/utils/formCreate.ts new file mode 100644 index 0000000..850df8c --- /dev/null +++ b/src/utils/formCreate.ts @@ -0,0 +1,57 @@ +/** + * 针对 https://github.com/xaboy/form-create-designer 封装的工具类 + */ + +// 编码表单 Conf +export const encodeConf = (designerRef: object) => { + // @ts-ignore + return JSON.stringify(designerRef.value.getOption()) +} + +// 编码表单 Fields +export const encodeFields = (designerRef: object) => { + // @ts-ignore + const rule = designerRef.value.getRule() + const fields: string[] = [] + rule.forEach((item) => { + fields.push(JSON.stringify(item)) + }) + return fields +} + +// 解码表单 Fields +export const decodeFields = (fields: string[]) => { + const rule: object[] = [] + fields.forEach((item) => { + rule.push(JSON.parse(item)) + }) + return rule +} + +// 设置表单的 Conf 和 Fields,适用 FcDesigner 场景 +export const setConfAndFields = (designerRef: object, conf: string, fields: string) => { + // @ts-ignore + designerRef.value.setOption(JSON.parse(conf)) + // @ts-ignore + designerRef.value.setRule(decodeFields(fields)) +} + +// 设置表单的 Conf 和 Fields,适用 form-create 场景 +export const setConfAndFields2 = ( + detailPreview: object, + conf: string, + fields: string[], + value?: object +) => { + if (isRef(detailPreview)) { + detailPreview = detailPreview.value + } + // @ts-ignore + detailPreview.option = JSON.parse(conf) + // @ts-ignore + detailPreview.rule = decodeFields(fields) + if (value) { + // @ts-ignore + detailPreview.value = value + } +} diff --git a/src/utils/formRules.ts b/src/utils/formRules.ts new file mode 100644 index 0000000..2989867 --- /dev/null +++ b/src/utils/formRules.ts @@ -0,0 +1,7 @@ +const { t } = useI18n() + +// 必填项 +export const required = { + required: true, + message: t('common.required') +} diff --git a/src/utils/formatTime.ts b/src/utils/formatTime.ts new file mode 100644 index 0000000..134a986 --- /dev/null +++ b/src/utils/formatTime.ts @@ -0,0 +1,332 @@ +import dayjs from 'dayjs' +import type { TableColumnCtx } from 'element-plus' + +/** + * 日期快捷选项适用于 el-date-picker + */ +export const defaultShortcuts = [ + { + text: '今天', + value: () => { + return new Date() + } + }, + { + text: '昨天', + value: () => { + const date = new Date() + date.setTime(date.getTime() - 3600 * 1000 * 24) + return [date, date] + } + }, + { + text: '最近七天', + value: () => { + const date = new Date() + date.setTime(date.getTime() - 3600 * 1000 * 24 * 7) + return [date, new Date()] + } + }, + { + text: '最近 30 天', + value: () => { + const date = new Date() + date.setTime(date.getTime() - 3600 * 1000 * 24 * 30) + return [date, new Date()] + } + }, + { + text: '本月', + value: () => { + const date = new Date() + date.setDate(1) // 设置为当前月的第一天 + return [date, new Date()] + } + }, + { + text: '今年', + value: () => { + const date = new Date() + return [new Date(`${date.getFullYear()}-01-01`), date] + } + } +] + +/** + * 时间日期转换 + * @param date 当前时间,new Date() 格式 + * @param format 需要转换的时间格式字符串 + * @description format 字符串随意,如 `YYYY-mm、YYYY-mm-dd` + * @description format 季度:"YYYY-mm-dd HH:MM:SS QQQQ" + * @description format 星期:"YYYY-mm-dd HH:MM:SS WWW" + * @description format 几周:"YYYY-mm-dd HH:MM:SS ZZZ" + * @description format 季度 + 星期 + 几周:"YYYY-mm-dd HH:MM:SS WWW QQQQ ZZZ" + * @returns 返回拼接后的时间字符串 + */ +export function formatDate(date: Date, format?: string): string { + // 日期不存在,则返回空 + if (!date) { + return '' + } + // 日期存在,则进行格式化 + return date ? dayjs(date).format(format ?? 'YYYY-MM-DD HH:mm:ss') : '' +} + +/** + * 获取当前的日期+时间 + */ +export function getNowDateTime() { + return dayjs() +} + +/** + * 获取当前日期是第几周 + * @param dateTime 当前传入的日期值 + * @returns 返回第几周数字值 + */ +export function getWeek(dateTime: Date): number { + const temptTime = new Date(dateTime.getTime()) + // 周几 + const weekday = temptTime.getDay() || 7 + // 周1+5天=周六 + temptTime.setDate(temptTime.getDate() - weekday + 1 + 5) + let firstDay = new Date(temptTime.getFullYear(), 0, 1) + const dayOfWeek = firstDay.getDay() + let spendDay = 1 + if (dayOfWeek != 0) spendDay = 7 - dayOfWeek + 1 + firstDay = new Date(temptTime.getFullYear(), 0, 1 + spendDay) + const d = Math.ceil((temptTime.valueOf() - firstDay.valueOf()) / 86400000) + return Math.ceil(d / 7) +} + +/** + * 将时间转换为 `几秒前`、`几分钟前`、`几小时前`、`几天前` + * @param param 当前时间,new Date() 格式或者字符串时间格式 + * @param format 需要转换的时间格式字符串 + * @description param 10秒: 10 * 1000 + * @description param 1分: 60 * 1000 + * @description param 1小时: 60 * 60 * 1000 + * @description param 24小时:60 * 60 * 24 * 1000 + * @description param 3天: 60 * 60* 24 * 1000 * 3 + * @returns 返回拼接后的时间字符串 + */ +export function formatPast(param: string | Date, format = 'YYYY-mm-dd HH:MM:SS'): string { + // 传入格式处理、存储转换值 + let t: any, s: number + // 获取js 时间戳 + let time: number = new Date().getTime() + // 是否是对象 + typeof param === 'string' || 'object' ? (t = new Date(param).getTime()) : (t = param) + // 当前时间戳 - 传入时间戳 + time = Number.parseInt(`${time - t}`) + if (time < 10000) { + // 10秒内 + return '刚刚' + } else if (time < 60000 && time >= 10000) { + // 超过10秒少于1分钟内 + s = Math.floor(time / 1000) + return `${s}秒前` + } else if (time < 3600000 && time >= 60000) { + // 超过1分钟少于1小时 + s = Math.floor(time / 60000) + return `${s}分钟前` + } else if (time < 86400000 && time >= 3600000) { + // 超过1小时少于24小时 + s = Math.floor(time / 3600000) + return `${s}小时前` + } else if (time < 259200000 && time >= 86400000) { + // 超过1天少于3天内 + s = Math.floor(time / 86400000) + return `${s}天前` + } else { + // 超过3天 + const date = typeof param === 'string' || 'object' ? new Date(param) : param + return formatDate(date, format) + } +} + +/** + * 时间问候语 + * @param param 当前时间,new Date() 格式 + * @description param 调用 `formatAxis(new Date())` 输出 `上午好` + * @returns 返回拼接后的时间字符串 + */ +export function formatAxis(param: Date): string { + const hour: number = new Date(param).getHours() + if (hour < 6) return '凌晨好' + else if (hour < 9) return '早上好' + else if (hour < 12) return '上午好' + else if (hour < 14) return '中午好' + else if (hour < 17) return '下午好' + else if (hour < 19) return '傍晚好' + else if (hour < 22) return '晚上好' + else return '夜里好' +} + +/** + * 将毫秒,转换成时间字符串。例如说,xx 分钟 + * + * @param ms 毫秒 + * @returns {string} 字符串 + */ +export function formatPast2(ms: number): string { + const day = Math.floor(ms / (24 * 60 * 60 * 1000)) + const hour = Math.floor(ms / (60 * 60 * 1000) - day * 24) + const minute = Math.floor(ms / (60 * 1000) - day * 24 * 60 - hour * 60) + const second = Math.floor(ms / 1000 - day * 24 * 60 * 60 - hour * 60 * 60 - minute * 60) + if (day > 0) { + return day + ' 天' + hour + ' 小时 ' + minute + ' 分钟' + } + if (hour > 0) { + return hour + ' 小时 ' + minute + ' 分钟' + } + if (minute > 0) { + return minute + ' 分钟' + } + if (second > 0) { + return second + ' 秒' + } else { + return 0 + ' 秒' + } +} + +/** + * element plus 的时间 Formatter 实现,使用 YYYY-MM-DD HH:mm:ss 格式 + * + * @param row 行数据 + * @param column 字段 + * @param cellValue 字段值 + */ +export function dateFormatter(_row: any, _column: TableColumnCtx<any>, cellValue: any): string { + return cellValue ? formatDate(cellValue) : '' +} + +/** + * element plus 的时间 Formatter 实现,使用 YYYY-MM-DD 格式 + * + * @param row 行数据 + * @param column 字段 + * @param cellValue 字段值 + */ +export function dateFormatter2(_row: any, _column: TableColumnCtx<any>, cellValue: any): string { + return cellValue ? formatDate(cellValue, 'YYYY-MM-DD') : '' +} + +/** + * 设置起始日期,时间为00:00:00 + * @param param 传入日期 + * @returns 带时间00:00:00的日期 + */ +export function beginOfDay(param: Date): Date { + return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 0, 0, 0) +} + +/** + * 设置结束日期,时间为23:59:59 + * @param param 传入日期 + * @returns 带时间23:59:59的日期 + */ +export function endOfDay(param: Date): Date { + return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 23, 59, 59) +} + +/** + * 计算两个日期间隔天数 + * @param param1 日期1 + * @param param2 日期2 + */ +export function betweenDay(param1: Date, param2: Date): number { + param1 = convertDate(param1) + param2 = convertDate(param2) + // 计算差值 + return Math.floor((param2.getTime() - param1.getTime()) / (24 * 3600 * 1000)) +} + +/** + * 日期计算 + * @param param1 日期 + * @param param2 添加的时间 + */ +export function addTime(param1: Date, param2: number): Date { + param1 = convertDate(param1) + return new Date(param1.getTime() + param2) +} + +/** + * 日期转换 + * @param param 日期 + */ +export function convertDate(param: Date | string): Date { + if (typeof param === 'string') { + return new Date(param) + } + return param +} + +/** + * 指定的两个日期, 是否为同一天 + * @param a 日期 A + * @param b 日期 B + */ +export function isSameDay(a: dayjs.ConfigType, b: dayjs.ConfigType): boolean { + if (!a || !b) return false + + const aa = dayjs(a) + const bb = dayjs(b) + return aa.year() == bb.year() && aa.month() == bb.month() && aa.day() == bb.day() +} + +/** + * 获取一天的开始时间、截止时间 + * @param date 日期 + * @param days 天数 + */ +export function getDayRange( + date: dayjs.ConfigType, + days: number +): [dayjs.ConfigType, dayjs.ConfigType] { + const day = dayjs(date).add(days, 'd') + return getDateRange(day, day) +} + +/** + * 获取最近7天的开始时间、截止时间 + */ +export function getLast7Days(): [dayjs.ConfigType, dayjs.ConfigType] { + const lastWeekDay = dayjs().subtract(7, 'd') + const yesterday = dayjs().subtract(1, 'd') + return getDateRange(lastWeekDay, yesterday) +} + +/** + * 获取最近30天的开始时间、截止时间 + */ +export function getLast30Days(): [dayjs.ConfigType, dayjs.ConfigType] { + const lastMonthDay = dayjs().subtract(30, 'd') + const yesterday = dayjs().subtract(1, 'd') + return getDateRange(lastMonthDay, yesterday) +} + +/** + * 获取最近1年的开始时间、截止时间 + */ +export function getLast1Year(): [dayjs.ConfigType, dayjs.ConfigType] { + const lastYearDay = dayjs().subtract(1, 'y') + const yesterday = dayjs().subtract(1, 'd') + return getDateRange(lastYearDay, yesterday) +} + +/** + * 获取指定日期的开始时间、截止时间 + * @param beginDate 开始日期 + * @param endDate 截止日期 + */ +export function getDateRange( + beginDate: dayjs.ConfigType, + endDate: dayjs.ConfigType +): [string, string] { + return [ + dayjs(beginDate).startOf('d').format('YYYY-MM-DD HH:mm:ss'), + dayjs(endDate).endOf('d').format('YYYY-MM-DD HH:mm:ss') + ] +} diff --git a/src/utils/formatter.ts b/src/utils/formatter.ts new file mode 100644 index 0000000..8777f32 --- /dev/null +++ b/src/utils/formatter.ts @@ -0,0 +1,7 @@ +import { floatToFixed2 } from '@/utils' + +// 格式化金额【分转元】 +// @ts-ignore +export const fenToYuanFormat = (_, __, cellValue: any, ___) => { + return `¥${floatToFixed2(cellValue)}` +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..2c2fbbd --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,451 @@ +import { toNumber } from 'lodash-es' + +/** + * + * @param component 需要注册的组件 + * @param alias 组件别名 + * @returns any + */ +export const withInstall = <T>(component: T, alias?: string) => { + const comp = component as any + comp.install = (app: any) => { + app.component(comp.name || comp.displayName, component) + if (alias) { + app.config.globalProperties[alias] = component + } + } + return component as T & Plugin +} + +/** + * @param str 需要转下划线的驼峰字符串 + * @returns 字符串下划线 + */ +export const humpToUnderline = (str: string): string => { + return str.replace(/([A-Z])/g, '-$1').toLowerCase() +} + +/** + * @param str 需要转驼峰的下划线字符串 + * @returns 字符串驼峰 + */ +export const underlineToHump = (str: string): string => { + if (!str) return '' + return str.replace(/\-(\w)/g, (_, letter: string) => { + return letter.toUpperCase() + }) +} + +/** + * 驼峰转横杠 + */ +export const humpToDash = (str: string): string => { + return str.replace(/([A-Z])/g, '-$1').toLowerCase() +} + +export const setCssVar = (prop: string, val: any, dom = document.documentElement) => { + dom.style.setProperty(prop, val) +} + +/** + * 查找数组对象的某个下标 + * @param {Array} ary 查找的数组 + * @param {Functon} fn 判断的方法 + */ +// eslint-disable-next-line +export const findIndex = <T = Recordable>(ary: Array<T>, fn: Fn): number => { + if (ary.findIndex) { + return ary.findIndex(fn) + } + let index = -1 + ary.some((item: T, i: number, ary: Array<T>) => { + const ret: T = fn(item, i, ary) + if (ret) { + index = i + return ret + } + }) + return index +} + +export const trim = (str: string) => { + return str.replace(/(^\s*)|(\s*$)/g, '') +} + +/** + * @param {Date | number | string} time 需要转换的时间 + * @param {String} fmt 需要转换的格式 如 yyyy-MM-dd、yyyy-MM-dd HH:mm:ss + */ +export function formatTime(time: Date | number | string, fmt: string) { + if (!time) return '' + else { + const date = new Date(time) + const o = { + 'M+': date.getMonth() + 1, + 'd+': date.getDate(), + 'H+': date.getHours(), + 'm+': date.getMinutes(), + 's+': date.getSeconds(), + 'q+': Math.floor((date.getMonth() + 3) / 3), + S: date.getMilliseconds() + } + if (/(y+)/.test(fmt)) { + fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length)) + } + for (const k in o) { + if (new RegExp('(' + k + ')').test(fmt)) { + fmt = fmt.replace( + RegExp.$1, + RegExp.$1.length === 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length) + ) + } + } + return fmt + } +} + +/** + * 生成随机字符串 + */ +export function toAnyString() { + const str: string = 'xxxxx-xxxxx-4xxxx-yxxxx-xxxxx'.replace(/[xy]/g, (c: string) => { + const r: number = (Math.random() * 16) | 0 + const v: number = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString() + }) + return str +} + +/** + * 首字母大写 + */ +export function firstUpperCase(str: string) { + return str.toLowerCase().replace(/( |^)[a-z]/g, (L) => L.toUpperCase()) +} + +export const generateUUID = () => { + if (typeof crypto === 'object') { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') { + const callback = (c: any) => { + const num = Number(c) + return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString( + 16 + ) + } + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback) + } + } + let timestamp = new Date().getTime() + let performanceNow = + (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + let random = Math.random() * 16 + if (timestamp > 0) { + random = (timestamp + random) % 16 | 0 + timestamp = Math.floor(timestamp / 16) + } else { + random = (performanceNow + random) % 16 | 0 + performanceNow = Math.floor(performanceNow / 16) + } + return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16) + }) +} + +/** + * element plus 的文件大小 Formatter 实现 + * + * @param row 行数据 + * @param column 字段 + * @param cellValue 字段值 + */ +// @ts-ignore +export const fileSizeFormatter = (row, column, cellValue) => { + const fileSize = cellValue + const unitArr = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const srcSize = parseFloat(fileSize) + const index = Math.floor(Math.log(srcSize) / Math.log(1024)) + const size = srcSize / Math.pow(1024, index) + const sizeStr = size.toFixed(2) //保留的小数位数 + return sizeStr + ' ' + unitArr[index] +} + +/** + * 将值复制到目标对象,且以目标对象属性为准,例:target: {a:1} source:{a:2,b:3} 结果为:{a:2} + * @param target 目标对象 + * @param source 源对象 + */ +export const copyValueToTarget = (target: any, source: any) => { + const newObj = Object.assign({}, target, source) + // 删除多余属性 + Object.keys(newObj).forEach((key) => { + // 如果不是target中的属性则删除 + if (Object.keys(target).indexOf(key) === -1) { + delete newObj[key] + } + }) + // 更新目标对象值 + Object.assign(target, newObj) +} + +/** + * 获取链接的参数值 + * @param key 参数键名 + * @param urlStr 链接地址,默认为当前浏览器的地址 + */ +export const getUrlValue = (key: string, urlStr: string = location.href): string => { + if (!urlStr || !key) return '' + const url = new URL(decodeURIComponent(urlStr)) + return url.searchParams.get(key) ?? '' +} + +/** + * 获取链接的参数值(值类型) + * @param key 参数键名 + * @param urlStr 链接地址,默认为当前浏览器的地址 + */ +export const getUrlNumberValue = (key: string, urlStr: string = location.href): number => { + return toNumber(getUrlValue(key, urlStr)) +} + +/** + * 构建排序字段 + * @param prop 字段名称 + * @param order 顺序 + */ +export const buildSortingField = ({ prop, order }) => { + return { field: prop, order: order === 'ascending' ? 'asc' : 'desc' } +} + +// ========== NumberUtils 数字方法 ========== + +/** + * 数组求和 + * + * @param values 数字数组 + * @return 求和结果,默认为 0 + */ +export const getSumValue = (values: number[]): number => { + return values.reduce((prev, curr) => { + const value = Number(curr) + if (!Number.isNaN(value)) { + return prev + curr + } else { + return prev + } + }, 0) +} + +// ========== 通用金额方法 ========== + +/** + * 将一个整数转换为分数保留两位小数 + * @param num + */ +export const formatToFraction = (num: number | string | undefined): string => { + if (typeof num === 'undefined') return '0.00' + const parsedNumber = typeof num === 'string' ? parseFloat(num) : num + return (parsedNumber / 100.0).toFixed(2) +} + +/** + * 将一个数转换为 1.00 这样 + * 数据呈现的时候使用 + * + * @param num 整数 + */ +// TODO @芋艿:看看怎么融合掉 +export const floatToFixed2 = (num: number | string | undefined): string => { + let str = '0.00' + if (typeof num === 'undefined') { + return str + } + const f = formatToFraction(num) + const decimalPart = f.toString().split('.')[1] + const len = decimalPart ? decimalPart.length : 0 + switch (len) { + case 0: + str = f.toString() + '.00' + break + case 1: + str = f.toString() + '0' + break + case 2: + str = f.toString() + break + } + return str +} + +/** + * 将一个分数转换为整数 + * @param num + */ +// TODO @芋艿:看看怎么融合掉 +export const convertToInteger = (num: number | string | undefined): number => { + if (typeof num === 'undefined') return 0 + const parsedNumber = typeof num === 'string' ? parseFloat(num) : num + // TODO 分转元后还有小数则四舍五入 + return Math.round(parsedNumber * 100) +} + +/** + * 元转分 + */ +export const yuanToFen = (amount: string | number): number => { + return convertToInteger(amount) +} + +/** + * 分转元 + */ +export const fenToYuan = (price: string | number): string => { + return formatToFraction(price) +} + +/** + * 计算环比 + * + * @param value 当前数值 + * @param reference 对比数值 + */ +export const calculateRelativeRate = (value?: number, reference?: number) => { + // 防止除0 + if (!reference || reference == 0) return 0 + + return ((100 * ((value || 0) - reference)) / reference).toFixed(0) +} + +// ========== ERP 专属方法 ========== + +const ERP_COUNT_DIGIT = 3 +const ERP_PRICE_DIGIT = 2 + +/** + * 【ERP】格式化 Input 数字 + * + * 例如说:库存数量 + * + * @param num 数量 + * @package digit 保留的小数位数 + * @return 格式化后的数量 + */ +export const erpNumberFormatter = (num: number | string | undefined, digit: number) => { + if (num == null) { + return '' + } + if (typeof num === 'string') { + num = parseFloat(num) + } + // 如果非 number,则直接返回空串 + if (isNaN(num)) { + return '' + } + return num.toFixed(digit) +} + +/** + * 【ERP】格式化数量,保留三位小数 + * + * 例如说:库存数量 + * + * @param num 数量 + * @return 格式化后的数量 + */ +export const erpCountInputFormatter = (num: number | string | undefined) => { + return erpNumberFormatter(num, ERP_COUNT_DIGIT) +} + +// noinspection JSCommentMatchesSignature +/** + * 【ERP】格式化数量,保留三位小数 + * + * @param cellValue 数量 + * @return 格式化后的数量 + */ +export const erpCountTableColumnFormatter = (_, __, cellValue: any, ___) => { + return erpNumberFormatter(cellValue, ERP_COUNT_DIGIT) +} + +/** + * 【ERP】格式化金额,保留二位小数 + * + * 例如说:库存数量 + * + * @param num 数量 + * @return 格式化后的数量 + */ +export const erpPriceInputFormatter = (num: number | string | undefined) => { + return erpNumberFormatter(num, ERP_PRICE_DIGIT) +} + +// noinspection JSCommentMatchesSignature +/** + * 【ERP】格式化金额,保留二位小数 + * + * @param cellValue 数量 + * @return 格式化后的数量 + */ +export const erpPriceTableColumnFormatter = (_, __, cellValue: any, ___) => { + return erpNumberFormatter(cellValue, ERP_PRICE_DIGIT) +} + +/** + * 【ERP】价格计算,四舍五入保留两位小数 + * + * @param price 价格 + * @param count 数量 + * @return 总价格。如果有任一为空,则返回 undefined + */ +export const erpPriceMultiply = (price: number, count: number) => { + if (price == null || count == null) { + return undefined + } + return parseFloat((price * count).toFixed(ERP_PRICE_DIGIT)) +} + +/** + * 【ERP】百分比计算,四舍五入保留两位小数 + * + * 如果 total 为 0,则返回 0 + * + * @param value 当前值 + * @param total 总值 + */ +export const erpCalculatePercentage = (value: number, total: number) => { + if (total === 0) return 0 + return ((value / total) * 100).toFixed(2) +} + +/** + * 适配 echarts map 的地名 + * + * @param areaName 地区名称 + */ +export const areaReplace = (areaName: string) => { + if (!areaName) { + return areaName + } + return areaName + .replace('维吾尔自治区', '') + .replace('壮族自治区', '') + .replace('回族自治区', '') + .replace('自治区', '') + .replace('省', '') +} + +/** + * 解析 JSON 字符串 + * + * @param str + */ +export function jsonParse(str: string) { + try { + return JSON.parse(str) + } catch (e) { + console.error(`str[${str}] 不是一个 JSON 字符串`) + return '' + } +} diff --git a/src/utils/is.ts b/src/utils/is.ts new file mode 100644 index 0000000..eec86a9 --- /dev/null +++ b/src/utils/is.ts @@ -0,0 +1,117 @@ +// copy to vben-admin + +const toString = Object.prototype.toString + +export const is = (val: unknown, type: string) => { + return toString.call(val) === `[object ${type}]` +} + +export const isDef = <T = unknown>(val?: T): val is T => { + return typeof val !== 'undefined' +} + +export const isUnDef = <T = unknown>(val?: T): val is T => { + return !isDef(val) +} + +export const isObject = (val: any): val is Record<any, any> => { + return val !== null && is(val, 'Object') +} + +export const isEmpty = <T = unknown>(val: T): val is T => { + if (val === null) { + return true + } + if (isArray(val) || isString(val)) { + return val.length === 0 + } + + if (val instanceof Map || val instanceof Set) { + return val.size === 0 + } + + if (isObject(val)) { + return Object.keys(val).length === 0 + } + + return false +} + +export const isDate = (val: unknown): val is Date => { + return is(val, 'Date') +} + +export const isNull = (val: unknown): val is null => { + return val === null +} + +export const isNullAndUnDef = (val: unknown): val is null | undefined => { + return isUnDef(val) && isNull(val) +} + +export const isNullOrUnDef = (val: unknown): val is null | undefined => { + return isUnDef(val) || isNull(val) +} + +export const isNumber = (val: unknown): val is number => { + return is(val, 'Number') +} + +export const isPromise = <T = any>(val: unknown): val is Promise<T> => { + return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch) +} + +export const isString = (val: unknown): val is string => { + return is(val, 'String') +} + +export const isFunction = (val: unknown): val is Function => { + return typeof val === 'function' +} + +export const isBoolean = (val: unknown): val is boolean => { + return is(val, 'Boolean') +} + +export const isRegExp = (val: unknown): val is RegExp => { + return is(val, 'RegExp') +} + +export const isArray = (val: any): val is Array<any> => { + return val && Array.isArray(val) +} + +export const isWindow = (val: any): val is Window => { + return typeof window !== 'undefined' && is(val, 'Window') +} + +export const isElement = (val: unknown): val is Element => { + return isObject(val) && !!val.tagName +} + +export const isMap = (val: unknown): val is Map<any, any> => { + return is(val, 'Map') +} + +export const isServer = typeof window === 'undefined' + +export const isClient = !isServer + +export const isUrl = (path: string): boolean => { + const reg = + /(((^https?:(?:\/\/)?)(?:[-:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&%@.\w_]*)#?(?:[\w]*))?)$/ + return reg.test(path) +} + +export const isDark = (): boolean => { + return window.matchMedia('(prefers-color-scheme: dark)').matches +} + +// 是否是图片链接 +export const isImgPath = (path: string): boolean => { + return /(https?:\/\/|data:image\/).*?\.(png|jpg|jpeg|gif|svg|webp|ico)/gi.test(path) +} + +export const isEmptyVal = (val: any): boolean => { + return val === '' || val === null || val === undefined +} diff --git a/src/utils/jsencrypt.ts b/src/utils/jsencrypt.ts new file mode 100644 index 0000000..374d5f6 --- /dev/null +++ b/src/utils/jsencrypt.ts @@ -0,0 +1,31 @@ +import { JSEncrypt } from 'jsencrypt' + +// 密钥对生成 http://web.chacuo.net/netrsakeypair + +const publicKey = + 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdH\n' + + 'nzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==' + +const privateKey = + 'MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAqhHyZfSsYourNxaY\n' + + '7Nt+PrgrxkiA50efORdI5U5lsW79MmFnusUA355oaSXcLhu5xxB38SMSyP2KvuKN\n' + + 'PuH3owIDAQABAkAfoiLyL+Z4lf4Myxk6xUDgLaWGximj20CUf+5BKKnlrK+Ed8gA\n' + + 'kM0HqoTt2UZwA5E2MzS4EI2gjfQhz5X28uqxAiEA3wNFxfrCZlSZHb0gn2zDpWow\n' + + 'cSxQAgiCstxGUoOqlW8CIQDDOerGKH5OmCJ4Z21v+F25WaHYPxCFMvwxpcw99Ecv\n' + + 'DQIgIdhDTIqD2jfYjPTY8Jj3EDGPbH2HHuffvflECt3Ek60CIQCFRlCkHpi7hthh\n' + + 'YhovyloRYsM+IS9h/0BzlEAuO0ktMQIgSPT3aFAgJYwKpqRYKlLDVcflZFCKY7u3\n' + + 'UP8iWi1Qw0Y=' + +// 加密 +export const encrypt = (txt: string) => { + const encryptor = new JSEncrypt() + encryptor.setPublicKey(publicKey) // 设置公钥 + return encryptor.encrypt(txt) // 对数据进行加密 +} + +// 解密 +export const decrypt = (txt: string) => { + const encryptor = new JSEncrypt() + encryptor.setPrivateKey(privateKey) // 设置私钥 + return encryptor.decrypt(txt) // 对数据进行解密 +} diff --git a/src/utils/permission.ts b/src/utils/permission.ts new file mode 100644 index 0000000..a63ee62 --- /dev/null +++ b/src/utils/permission.ts @@ -0,0 +1,45 @@ +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' + +const { t } = useI18n() // 国际化 + +/** + * 字符权限校验 + * @param {Array} value 校验值 + * @returns {Boolean} + */ +export function checkPermi(value: string[]) { + if (value && value instanceof Array && value.length > 0) { + const { wsCache } = useCache() + const permissionDatas = value + const all_permission = '*:*:*' + const permissions = wsCache.get(CACHE_KEY.USER).permissions + const hasPermission = permissions.some((permission) => { + return all_permission === permission || permissionDatas.includes(permission) + }) + return !!hasPermission + } else { + console.error(t('permission.hasPermission')) + return false + } +} + +/** + * 角色权限校验 + * @param {string[]} value 校验值 + * @returns {Boolean} + */ +export function checkRole(value: string[]) { + if (value && value instanceof Array && value.length > 0) { + const { wsCache } = useCache() + const permissionRoles = value + const super_admin = 'admin' + const roles = wsCache.get(CACHE_KEY.USER).roles + const hasRole = roles.some((role) => { + return super_admin === role || permissionRoles.includes(role) + }) + return !!hasRole + } else { + console.error(t('permission.hasRole')) + return false + } +} diff --git a/src/utils/propTypes.ts b/src/utils/propTypes.ts new file mode 100644 index 0000000..863f55c --- /dev/null +++ b/src/utils/propTypes.ts @@ -0,0 +1,24 @@ +import { VueTypeValidableDef, VueTypesInterface, createTypes, toValidableType } from 'vue-types' +import { CSSProperties } from 'vue' + +type PropTypes = VueTypesInterface & { + readonly style: VueTypeValidableDef<CSSProperties> +} +const newPropTypes = createTypes({ + func: undefined, + bool: undefined, + string: undefined, + number: undefined, + object: undefined, + integer: undefined +}) as PropTypes + +class propTypes extends newPropTypes { + static get style() { + return toValidableType('style', { + type: [String, Object] + }) + } +} + +export { propTypes } diff --git a/src/utils/routerHelper.ts b/src/utils/routerHelper.ts new file mode 100644 index 0000000..f292751 --- /dev/null +++ b/src/utils/routerHelper.ts @@ -0,0 +1,253 @@ +import type { RouteLocationNormalized, Router, RouteRecordNormalized } from 'vue-router' +import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router' +import { isUrl } from '@/utils/is' +import { cloneDeep, omit } from 'lodash-es' +import qs from 'qs' + +const modules = import.meta.glob('../views/**/*.{vue,tsx}') +/** + * 注册一个异步组件 + * @param componentPath 例:/bpm/oa/leave/detail + */ +export const registerComponent = (componentPath: string) => { + for (const item in modules) { + if (item.includes(componentPath)) { + // 使用异步组件的方式来动态加载组件 + // @ts-ignore + return defineAsyncComponent(modules[item]) + } + } +} +/* Layout */ +export const Layout = () => import('@/layout/Layout.vue') + +export const getParentLayout = () => { + return () => + new Promise((resolve) => { + resolve({ + name: 'ParentLayout' + }) + }) +} + +// 按照路由中meta下的rank等级升序来排序路由 +export const ascending = (arr: any[]) => { + arr.forEach((v) => { + if (v?.meta?.rank === null) v.meta.rank = undefined + if (v?.meta?.rank === 0) { + if (v.name !== 'home' && v.path !== '/') { + console.warn('rank only the home page can be 0') + } + } + }) + return arr.sort((a: { meta: { rank: number } }, b: { meta: { rank: number } }) => { + return a?.meta?.rank - b?.meta?.rank + }) +} + +export const getRawRoute = (route: RouteLocationNormalized): RouteLocationNormalized => { + if (!route) return route + const { matched, ...opt } = route + return { + ...opt, + matched: (matched + ? matched.map((item) => ({ + meta: item.meta, + name: item.name, + path: item.path + })) + : undefined) as RouteRecordNormalized[] + } +} + +// 后端控制路由生成 +export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecordRaw[] => { + const res: AppRouteRecordRaw[] = [] + const modulesRoutesKeys = Object.keys(modules) + for (const route of routes) { + // 1. 生成 meta 菜单元数据 + const meta = { + title: route.name, + icon: route.icon, + hidden: !route.visible, + noCache: !route.keepAlive, + alwaysShow: + route.children && + route.children.length === 1 && + (route.alwaysShow !== undefined ? route.alwaysShow : true) + } as any + // 特殊逻辑:如果后端配置的 MenuDO.component 包含 ?,则表示需要传递参数 + // 此时,我们需要解析参数,并且将参数放到 meta.query 中 + // 这样,后续在 Vue 文件中,可以通过 const { currentRoute } = useRouter() 中,通过 meta.query 获取到参数 + if (route.component && route.component.indexOf('?') > -1) { + const query = route.component.split('?')[1] + route.component = route.component.split('?')[0] + meta.query = qs.parse(query) + } + + // 2. 生成 data(AppRouteRecordRaw) + // 路由地址转首字母大写驼峰,作为路由名称,适配keepAlive + let data: AppRouteRecordRaw = { + path: route.path.indexOf('?') > -1 ? route.path.split('?')[0] : route.path, + name: + route.componentName && route.componentName.length > 0 + ? route.componentName + : toCamelCase(route.path, true), + redirect: route.redirect, + meta: meta + } + //处理顶级非目录路由 + if (!route.children && route.parentId == 0 && route.component) { + data.component = Layout + data.meta = {} + data.name = toCamelCase(route.path, true) + 'Parent' + data.redirect = '' + meta.alwaysShow = true + const childrenData: AppRouteRecordRaw = { + path: '', + name: + route.componentName && route.componentName.length > 0 + ? route.componentName + : toCamelCase(route.path, true), + redirect: route.redirect, + meta: meta + } + const index = route?.component + ? modulesRoutesKeys.findIndex((ev) => ev.includes(route.component)) + : modulesRoutesKeys.findIndex((ev) => ev.includes(route.path)) + childrenData.component = modules[modulesRoutesKeys[index]] + data.children = [childrenData] + } else { + // 目录 + if (route.children) { + data.component = Layout + data.redirect = getRedirect(route.path, route.children) + // 外链 + } else if (isUrl(route.path)) { + data = { + path: '/external-link', + component: Layout, + meta: { + name: route.name + }, + children: [data] + } as AppRouteRecordRaw + // 菜单 + } else { + // 对后端传component组件路径和不传做兼容(如果后端传component组件路径,那么path可以随便写,如果不传,component组件路径会根path保持一致) + const index = route?.component + ? modulesRoutesKeys.findIndex((ev) => ev.includes(route.component)) + : modulesRoutesKeys.findIndex((ev) => ev.includes(route.path)) + data.component = modules[modulesRoutesKeys[index]] + } + if (route.children) { + data.children = generateRoute(route.children) + } + } + res.push(data as AppRouteRecordRaw) + } + return res +} +export const getRedirect = (parentPath: string, children: AppCustomRouteRecordRaw[]) => { + if (!children || children.length == 0) { + return parentPath + } + const path = generateRoutePath(parentPath, children[0].path) + // 递归子节点 + if (children[0].children) return getRedirect(path, children[0].children) +} +const generateRoutePath = (parentPath: string, path: string) => { + if (parentPath.endsWith('/')) { + parentPath = parentPath.slice(0, -1) // 移除默认的 / + } + if (!path.startsWith('/')) { + path = '/' + path + } + return parentPath + path +} +export const pathResolve = (parentPath: string, path: string) => { + if (isUrl(path)) return path + const childPath = path.startsWith('/') || !path ? path : `/${path}` + return `${parentPath}${childPath}`.replace(/\/\//g, '/') +} + +// 路由降级 +export const flatMultiLevelRoutes = (routes: AppRouteRecordRaw[]) => { + const modules: AppRouteRecordRaw[] = cloneDeep(routes) + for (let index = 0; index < modules.length; index++) { + const route = modules[index] + if (!isMultipleRoute(route)) { + continue + } + promoteRouteLevel(route) + } + return modules +} + +// 层级是否大于2 +const isMultipleRoute = (route: AppRouteRecordRaw) => { + if (!route || !Reflect.has(route, 'children') || !route.children?.length) { + return false + } + + const children = route.children + + let flag = false + for (let index = 0; index < children.length; index++) { + const child = children[index] + if (child.children?.length) { + flag = true + break + } + } + return flag +} + +// 生成二级路由 +const promoteRouteLevel = (route: AppRouteRecordRaw) => { + let router: Router | null = createRouter({ + routes: [route as RouteRecordRaw], + history: createWebHashHistory() + }) + + const routes = router.getRoutes() + addToChildren(routes, route.children || [], route) + router = null + + route.children = route.children?.map((item) => omit(item, 'children')) +} + +// 添加所有子菜单 +const addToChildren = ( + routes: RouteRecordNormalized[], + children: AppRouteRecordRaw[], + routeModule: AppRouteRecordRaw +) => { + for (let index = 0; index < children.length; index++) { + const child = children[index] + const route = routes.find((item) => item.name === child.name) + if (!route) { + continue + } + routeModule.children = routeModule.children || [] + if (!routeModule.children.find((item) => item.name === route.name)) { + routeModule.children?.push(route as unknown as AppRouteRecordRaw) + } + if (child.children?.length) { + addToChildren(routes, child.children, routeModule) + } + } +} +const toCamelCase = (str: string, upperCaseFirst: boolean) => { + str = (str || '') + .replace(/-(.)/g, function (group1: string) { + return group1.toUpperCase() + }) + .replaceAll('-', '') + + if (upperCaseFirst && str) { + str = str.charAt(0).toUpperCase() + str.slice(1) + } + + return str +} diff --git a/src/utils/tree.ts b/src/utils/tree.ts new file mode 100644 index 0000000..91059ef --- /dev/null +++ b/src/utils/tree.ts @@ -0,0 +1,400 @@ +interface TreeHelperConfig { + id: string + children: string + pid: string +} + +const DEFAULT_CONFIG: TreeHelperConfig = { + id: 'id', + children: 'children', + pid: 'pid' +} +export const defaultProps = { + children: 'children', + label: 'name', + value: 'id', + isLeaf: 'leaf', + emitPath: false // 用于 cascader 组件:在选中节点改变时,是否返回由该节点所在的各级菜单的值所组成的数组,若设置 false,则只返回该节点的值 +} + +const getConfig = (config: Partial<TreeHelperConfig>) => Object.assign({}, DEFAULT_CONFIG, config) + +// tree from list +export const listToTree = <T = any>(list: any[], config: Partial<TreeHelperConfig> = {}): T[] => { + const conf = getConfig(config) as TreeHelperConfig + const nodeMap = new Map() + const result: T[] = [] + const { id, children, pid } = conf + + for (const node of list) { + node[children] = node[children] || [] + nodeMap.set(node[id], node) + } + for (const node of list) { + const parent = nodeMap.get(node[pid]) + ;(parent ? parent.children : result).push(node) + } + return result +} + +export const treeToList = <T = any>(tree: any, config: Partial<TreeHelperConfig> = {}): T => { + config = getConfig(config) + const { children } = config + const result: any = [...tree] + for (let i = 0; i < result.length; i++) { + if (!result[i][children!]) continue + result.splice(i + 1, 0, ...result[i][children!]) + } + return result +} + +export const findNode = <T = any>( + tree: any, + func: Fn, + config: Partial<TreeHelperConfig> = {} +): T | null => { + config = getConfig(config) + const { children } = config + const list = [...tree] + for (const node of list) { + if (func(node)) return node + node[children!] && list.push(...node[children!]) + } + return null +} + +export const findNodeAll = <T = any>( + tree: any, + func: Fn, + config: Partial<TreeHelperConfig> = {} +): T[] => { + config = getConfig(config) + const { children } = config + const list = [...tree] + const result: T[] = [] + for (const node of list) { + func(node) && result.push(node) + node[children!] && list.push(...node[children!]) + } + return result +} + +export const findPath = <T = any>( + tree: any, + func: Fn, + config: Partial<TreeHelperConfig> = {} +): T | T[] | null => { + config = getConfig(config) + const path: T[] = [] + const list = [...tree] + const visitedSet = new Set() + const { children } = config + while (list.length) { + const node = list[0] + if (visitedSet.has(node)) { + path.pop() + list.shift() + } else { + visitedSet.add(node) + node[children!] && list.unshift(...node[children!]) + path.push(node) + if (func(node)) { + return path + } + } + } + return null +} + +export const findPathAll = (tree: any, func: Fn, config: Partial<TreeHelperConfig> = {}) => { + config = getConfig(config) + const path: any[] = [] + const list = [...tree] + const result: any[] = [] + const visitedSet = new Set(), + { children } = config + while (list.length) { + const node = list[0] + if (visitedSet.has(node)) { + path.pop() + list.shift() + } else { + visitedSet.add(node) + node[children!] && list.unshift(...node[children!]) + path.push(node) + func(node) && result.push([...path]) + } + } + return result +} + +export const filter = <T = any>( + tree: T[], + func: (n: T) => boolean, + config: Partial<TreeHelperConfig> = {} +): T[] => { + config = getConfig(config) + const children = config.children as string + + function listFilter(list: T[]) { + return list + .map((node: any) => ({ ...node })) + .filter((node) => { + node[children] = node[children] && listFilter(node[children]) + return func(node) || (node[children] && node[children].length) + }) + } + + return listFilter(tree) +} + +export const forEach = <T = any>( + tree: T[], + func: (n: T) => any, + config: Partial<TreeHelperConfig> = {} +): void => { + config = getConfig(config) + const list: any[] = [...tree] + const { children } = config + for (let i = 0; i < list.length; i++) { + // func 返回true就终止遍历,避免大量节点场景下无意义循环,引起浏览器卡顿 + if (func(list[i])) { + return + } + children && list[i][children] && list.splice(i + 1, 0, ...list[i][children]) + } +} + +/** + * @description: Extract tree specified structure + */ +export const treeMap = <T = any>( + treeData: T[], + opt: { children?: string; conversion: Fn } +): T[] => { + return treeData.map((item) => treeMapEach(item, opt)) +} + +/** + * @description: Extract tree specified structure + */ +export const treeMapEach = ( + data: any, + { children = 'children', conversion }: { children?: string; conversion: Fn } +) => { + const haveChildren = Array.isArray(data[children]) && data[children].length > 0 + const conversionData = conversion(data) || {} + if (haveChildren) { + return { + ...conversionData, + [children]: data[children].map((i: number) => + treeMapEach(i, { + children, + conversion + }) + ) + } + } else { + return { + ...conversionData + } + } +} + +/** + * 递归遍历树结构 + * @param treeDatas 树 + * @param callBack 回调 + * @param parentNode 父节点 + */ +export const eachTree = (treeDatas: any[], callBack: Fn, parentNode = {}) => { + treeDatas.forEach((element) => { + const newNode = callBack(element, parentNode) || element + if (element.children) { + eachTree(element.children, callBack, newNode) + } + }) +} + +/** + * 构造树型结构数据 + * @param {*} data 数据源 + * @param {*} id id字段 默认 'id' + * @param {*} parentId 父节点字段 默认 'parentId' + * @param {*} children 孩子节点字段 默认 'children' + */ +export const handleTree = (data: any[], id?: string, parentId?: string, children?: string) => { + if (!Array.isArray(data)) { + console.warn('data must be an array') + return [] + } + const config = { + id: id || 'id', + parentId: parentId || 'parentId', + childrenList: children || 'children' + } + + const childrenListMap = {} + const nodeIds = {} + const tree: any[] = [] + + for (const d of data) { + const parentId = d[config.parentId] + if (childrenListMap[parentId] == null) { + childrenListMap[parentId] = [] + } + nodeIds[d[config.id]] = d + childrenListMap[parentId].push(d) + } + + for (const d of data) { + const parentId = d[config.parentId] + if (nodeIds[parentId] == null) { + tree.push(d) + } + } + + for (const t of tree) { + adaptToChildrenList(t) + } + + function adaptToChildrenList(o) { + if (childrenListMap[o[config.id]] !== null) { + o[config.childrenList] = childrenListMap[o[config.id]] + } + if (o[config.childrenList]) { + for (const c of o[config.childrenList]) { + adaptToChildrenList(c) + } + } + } + + return tree +} + +/** + * 构造树型结构数据 + * @param {*} data 数据源 + * @param {*} id id字段 默认 'id' + * @param {*} parentId 父节点字段 默认 'parentId' + * @param {*} children 孩子节点字段 默认 'children' + * @param {*} rootId 根Id 默认 0 + */ +// @ts-ignore +export const handleTree2 = (data, id, parentId, children, rootId) => { + id = id || 'id' + parentId = parentId || 'parentId' + // children = children || 'children' + rootId = + rootId || + Math.min( + ...data.map((item) => { + return item[parentId] + }) + ) || + 0 + // 对源数据深度克隆 + const cloneData = JSON.parse(JSON.stringify(data)) + // 循环所有项 + const treeData = cloneData.filter((father) => { + const branchArr = cloneData.filter((child) => { + // 返回每一项的子级数组 + return father[id] === child[parentId] + }) + branchArr.length > 0 ? (father.children = branchArr) : '' + // 返回第一层 + return father[parentId] === rootId + }) + return treeData !== '' ? treeData : data +} + +/** + * 校验选中的节点,是否为指定 level + * + * @param tree 要操作的树结构数据 + * @param nodeId 需要判断在什么层级的数据 + * @param level 检查的级别, 默认检查到二级 + * @return true 是;false 否 + */ +export const checkSelectedNode = (tree: any[], nodeId: any, level = 2): boolean => { + if (typeof tree === 'undefined' || !Array.isArray(tree) || tree.length === 0) { + console.warn('tree must be an array') + return false + } + + // 校验是否是一级节点 + if (tree.some((item) => item.id === nodeId)) { + return false + } + + // 递归计数 + let count = 1 + + // 深层次校验 + function performAThoroughValidation(arr: any[]): boolean { + count += 1 + for (const item of arr) { + if (item.id === nodeId) { + return true + } else if (typeof item.children !== 'undefined' && item.children.length !== 0) { + if (performAThoroughValidation(item.children)) { + return true + } + } + } + return false + } + + for (const item of tree) { + count = 1 + if (performAThoroughValidation(item.children)) { + // 找到后对比是否是期望的层级 + if (count >= level) { + return true + } + } + } + + return false +} + +/** + * 获取节点的完整结构 + * @param tree 树数据 + * @param nodeId 节点 id + */ +export const treeToString = (tree: any[], nodeId) => { + if (typeof tree === 'undefined' || !Array.isArray(tree) || tree.length === 0) { + console.warn('tree must be an array') + return '' + } + // 校验是否是一级节点 + const node = tree.find((item) => item.id === nodeId) + if (typeof node !== 'undefined') { + return node.name + } + let str = '' + + function performAThoroughValidation(arr) { + for (const item of arr) { + if (item.id === nodeId) { + str += ` / ${item.name}` + return true + } else if (typeof item.children !== 'undefined' && item.children.length !== 0) { + str += ` / ${item.name}` + if (performAThoroughValidation(item.children)) { + return true + } + } + } + return false + } + + for (const item of tree) { + str = `${item.name}` + if (performAThoroughValidation(item.children)) { + break + } + } + return str +} diff --git a/src/utils/tsxHelper.ts b/src/utils/tsxHelper.ts new file mode 100644 index 0000000..6087fa3 --- /dev/null +++ b/src/utils/tsxHelper.ts @@ -0,0 +1,16 @@ +import { Slots } from 'vue' +import { isFunction } from '@/utils/is' + +export const getSlot = (slots: Slots, slot = 'default', data?: Recordable) => { + // Reflect.has 判断一个对象是否存在某个属性 + if (!slots || !Reflect.has(slots, slot)) { + return null + } + if (!isFunction(slots[slot])) { + console.error(`${slot} is not a function!`) + return null + } + const slotFn = slots[slot] + if (!slotFn) return null + return slotFn(data) +} diff --git a/src/views/Error/403.vue b/src/views/Error/403.vue new file mode 100644 index 0000000..a3ec487 --- /dev/null +++ b/src/views/Error/403.vue @@ -0,0 +1,8 @@ +<template> + <Error type="403" @error-click="push('/')" /> +</template> +<script lang="ts" setup> +defineOptions({ name: 'Error403' }) + +const { push } = useRouter() +</script> diff --git a/src/views/Error/404.vue b/src/views/Error/404.vue new file mode 100644 index 0000000..f6a08de --- /dev/null +++ b/src/views/Error/404.vue @@ -0,0 +1,7 @@ +<template> + <Error @error-click="push('/')" /> +</template> +<script lang="ts" setup> +defineOptions({ name: 'Error404' }) +const { push } = useRouter() +</script> diff --git a/src/views/Error/500.vue b/src/views/Error/500.vue new file mode 100644 index 0000000..998487d --- /dev/null +++ b/src/views/Error/500.vue @@ -0,0 +1,7 @@ +<template> + <Error type="500" @error-click="push('/')" /> +</template> +<script lang="ts" setup> +defineOptions({ name: 'Error500' }) +const { push } = useRouter() +</script> diff --git a/src/views/Home/Index.vue b/src/views/Home/Index.vue new file mode 100644 index 0000000..c59b9d2 --- /dev/null +++ b/src/views/Home/Index.vue @@ -0,0 +1,391 @@ +<template> + <div> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <el-row :gutter="16" justify="space-between"> + <el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24"> + <div class="flex items-center"> + <el-avatar :src="avatar" :size="70" class="mr-16px"> + <img src="@/assets/imgs/avatar.gif" alt="" /> + </el-avatar> + <div> + <div class="text-20px"> + {{ t('workplace.welcome') }} {{ username }} {{ t('workplace.happyDay') }} + </div> + <div class="mt-10px text-14px text-gray-500"> + {{ t('workplace.toady') }},20℃ - 32℃! + </div> + </div> + </div> + </el-col> + <el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24"> + <div class="h-70px flex items-center justify-end lt-sm:mt-10px"> + <div class="px-8px text-right"> + <div class="mb-16px text-14px text-gray-400">{{ t('workplace.project') }}</div> + <CountTo + class="text-20px" + :start-val="0" + :end-val="totalSate.project" + :duration="2600" + /> + </div> + <el-divider direction="vertical" /> + <div class="px-8px text-right"> + <div class="mb-16px text-14px text-gray-400">{{ t('workplace.toDo') }}</div> + <CountTo + class="text-20px" + :start-val="0" + :end-val="totalSate.todo" + :duration="2600" + /> + </div> + <el-divider direction="vertical" border-style="dashed" /> + <div class="px-8px text-right"> + <div class="mb-16px text-14px text-gray-400">{{ t('workplace.access') }}</div> + <CountTo + class="text-20px" + :start-val="0" + :end-val="totalSate.access" + :duration="2600" + /> + </div> + </div> + </el-col> + </el-row> + </el-skeleton> + </el-card> + </div> + + <el-row class="mt-8px" :gutter="8" justify="space-between"> + <el-col :xl="16" :lg="16" :md="24" :sm="24" :xs="24" class="mb-8px"> + <el-card shadow="never"> + <template #header> + <div class="h-3 flex justify-between"> + <span>{{ t('workplace.project') }}</span> + <el-link + type="primary" + :underline="false" + href="https://github.com/yudaocode" + target="_blank" + > + {{ t('action.more') }} + </el-link> + </div> + </template> + <el-skeleton :loading="loading" animated> + <el-row> + <el-col + v-for="(item, index) in projects" + :key="`card-${index}`" + :xl="8" + :lg="8" + :md="8" + :sm="24" + :xs="24" + > + <el-card shadow="hover" class="mr-5px mt-5px"> + <div class="flex items-center"> + <Icon :icon="item.icon" :size="25" class="mr-8px" /> + <span class="text-16px">{{ item.name }}</span> + </div> + <div class="mt-12px text-9px text-gray-400">{{ t(item.message) }}</div> + <div class="mt-12px flex justify-between text-12px text-gray-400"> + <span>{{ item.personal }}</span> + <span>{{ formatTime(item.time, 'yyyy-MM-dd') }}</span> + </div> + </el-card> + </el-col> + </el-row> + </el-skeleton> + </el-card> + + <el-card shadow="never" class="mt-8px"> + <el-skeleton :loading="loading" animated> + <el-row :gutter="20" justify="space-between"> + <el-col :xl="10" :lg="10" :md="24" :sm="24" :xs="24"> + <el-card shadow="hover" class="mb-8px"> + <el-skeleton :loading="loading" animated> + <Echart :options="pieOptionsData" :height="280" /> + </el-skeleton> + </el-card> + </el-col> + <el-col :xl="14" :lg="14" :md="24" :sm="24" :xs="24"> + <el-card shadow="hover" class="mb-8px"> + <el-skeleton :loading="loading" animated> + <Echart :options="barOptionsData" :height="280" /> + </el-skeleton> + </el-card> + </el-col> + </el-row> + </el-skeleton> + </el-card> + </el-col> + <el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24" class="mb-8px"> + <el-card shadow="never"> + <template #header> + <div class="h-3 flex justify-between"> + <span>{{ t('workplace.shortcutOperation') }}</span> + </div> + </template> + <el-skeleton :loading="loading" animated> + <el-row> + <el-col v-for="item in shortcut" :key="`team-${item.name}`" :span="8" class="mb-8px"> + <div class="flex items-center"> + <Icon :icon="item.icon" class="mr-8px" /> + <el-link type="default" :underline="false" @click="setWatermark(item.name)"> + {{ item.name }} + </el-link> + </div> + </el-col> + </el-row> + </el-skeleton> + </el-card> + <el-card shadow="never" class="mt-8px"> + <template #header> + <div class="h-3 flex justify-between"> + <span>{{ t('workplace.notice') }}</span> + <el-link type="primary" :underline="false">{{ t('action.more') }}</el-link> + </div> + </template> + <el-skeleton :loading="loading" animated> + <div v-for="(item, index) in notice" :key="`dynamics-${index}`"> + <div class="flex items-center"> + <el-avatar :src="avatar" :size="35" class="mr-16px"> + <img src="@/assets/imgs/avatar.gif" alt="" /> + </el-avatar> + <div> + <div class="text-14px"> + <Highlight :keys="item.keys.map((v) => t(v))"> + {{ item.type }} : {{ item.title }} + </Highlight> + </div> + <div class="mt-16px text-12px text-gray-400"> + {{ formatTime(item.date, 'yyyy-MM-dd') }} + </div> + </div> + </div> + <el-divider /> + </div> + </el-skeleton> + </el-card> + </el-col> + </el-row> +</template> +<script lang="ts" setup> +import { set } from 'lodash-es' +import { EChartsOption } from 'echarts' +import { formatTime } from '@/utils' + +import { useUserStore } from '@/store/modules/user' +import { useWatermark } from '@/hooks/web/useWatermark' +import type { WorkplaceTotal, Project, Notice, Shortcut } from './types' +import { pieOptions, barOptions } from './echarts-data' + +defineOptions({ name: 'Home' }) + +const { t } = useI18n() +const userStore = useUserStore() +const { setWatermark } = useWatermark() +const loading = ref(true) +const avatar = userStore.getUser.avatar +const username = userStore.getUser.nickname +const pieOptionsData = reactive<EChartsOption>(pieOptions) as EChartsOption +// 获取统计数 +let totalSate = reactive<WorkplaceTotal>({ + project: 0, + access: 0, + todo: 0 +}) + +const getCount = async () => { + const data = { + project: 40, + access: 2340, + todo: 10 + } + totalSate = Object.assign(totalSate, data) +} + +// 获取项目数 +let projects = reactive<Project[]>([]) +const getProject = async () => { + const data = [ + { + name: 'ruoyi-vue-pro', + icon: 'akar-icons:github-fill', + message: 'https://github.com/YunaiV/ruoyi-vue-pro', + personal: 'Spring Boot 单体架构', + time: new Date() + }, + { + name: 'yudao-ui-admin-vue3', + icon: 'logos:vue', + message: 'https://github.com/yudaocode/yudao-ui-admin-vue3', + personal: 'Vue3 + element-plus', + time: new Date() + }, + { + name: 'yudao-ui-admin-vben', + icon: 'logos:vue', + message: 'https://github.com/yudaocode/yudao-ui-admin-vben', + personal: 'Vue3 + vben(antd)', + time: new Date() + }, + { + name: 'yudao-cloud', + icon: 'akar-icons:github', + message: 'https://github.com/YunaiV/yudao-cloud', + personal: 'Spring Cloud 微服务架构', + time: new Date() + }, + { + name: 'yudao-ui-mall-uniapp', + icon: 'logos:vue', + message: 'https://github.com/yudaocode/yudao-ui-admin-uniapp', + personal: 'Vue3 + uniapp', + time: new Date() + }, + { + name: 'yudao-ui-admin-vue2', + icon: 'logos:vue', + message: 'https://github.com/yudaocode/yudao-ui-admin-vue2', + personal: 'Vue2 + element-ui', + time: new Date() + } + ] + projects = Object.assign(projects, data) +} + +// 获取通知公告 +let notice = reactive<Notice[]>([]) +const getNotice = async () => { + const data = [ + { + title: '系统支持 JDK 8/17/21,Vue 2/3', + type: '通知', + keys: ['通知', '8', '17', '21', '2', '3'], + date: new Date() + }, + { + title: '后端提供 Spring Boot 2.7/3.2 + Cloud 双架构', + type: '公告', + keys: ['公告', 'Boot', 'Cloud'], + date: new Date() + }, + { + title: '全部开源,个人与企业可 100% 直接使用,无需授权', + type: '通知', + keys: ['通知', '无需授权'], + date: new Date() + }, + { + title: '国内使用最广泛的快速开发平台,超 300+ 人贡献', + type: '公告', + keys: ['公告', '最广泛'], + date: new Date() + } + ] + notice = Object.assign(notice, data) +} + +// 获取快捷入口 +let shortcut = reactive<Shortcut[]>([]) + +const getShortcut = async () => { + const data = [ + { + name: 'Github', + icon: 'akar-icons:github-fill', + url: 'github.io' + }, + { + name: 'Vue', + icon: 'logos:vue', + url: 'vuejs.org' + }, + { + name: 'Vite', + icon: 'vscode-icons:file-type-vite', + url: 'https://vitejs.dev/' + }, + { + name: 'Angular', + icon: 'logos:angular-icon', + url: 'github.io' + }, + { + name: 'React', + icon: 'logos:react', + url: 'github.io' + }, + { + name: 'Webpack', + icon: 'logos:webpack', + url: 'github.io' + } + ] + shortcut = Object.assign(shortcut, data) +} + +// 用户来源 +const getUserAccessSource = async () => { + const data = [ + { value: 335, name: 'analysis.directAccess' }, + { value: 310, name: 'analysis.mailMarketing' }, + { value: 234, name: 'analysis.allianceAdvertising' }, + { value: 135, name: 'analysis.videoAdvertising' }, + { value: 1548, name: 'analysis.searchEngines' } + ] + set( + pieOptionsData, + 'legend.data', + data.map((v) => t(v.name)) + ) + pieOptionsData!.series![0].data = data.map((v) => { + return { + name: t(v.name), + value: v.value + } + }) +} +const barOptionsData = reactive<EChartsOption>(barOptions) as EChartsOption + +// 周活跃量 +const getWeeklyUserActivity = async () => { + const data = [ + { value: 13253, name: 'analysis.monday' }, + { value: 34235, name: 'analysis.tuesday' }, + { value: 26321, name: 'analysis.wednesday' }, + { value: 12340, name: 'analysis.thursday' }, + { value: 24643, name: 'analysis.friday' }, + { value: 1322, name: 'analysis.saturday' }, + { value: 1324, name: 'analysis.sunday' } + ] + set( + barOptionsData, + 'xAxis.data', + data.map((v) => t(v.name)) + ) + set(barOptionsData, 'series', [ + { + name: t('analysis.activeQuantity'), + data: data.map((v) => v.value), + type: 'bar' + } + ]) +} + +const getAllApi = async () => { + await Promise.all([ + getCount(), + getProject(), + getNotice(), + getShortcut(), + getUserAccessSource(), + getWeeklyUserActivity() + ]) + loading.value = false +} + +getAllApi() +</script> diff --git a/src/views/Home/Index2.vue b/src/views/Home/Index2.vue new file mode 100644 index 0000000..c9429ab --- /dev/null +++ b/src/views/Home/Index2.vue @@ -0,0 +1,319 @@ +<template> + <el-row :class="prefixCls" :gutter="20" justify="space-between"> + <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" :rows="2" animated> + <template #default> + <div :class="`${prefixCls}__item flex justify-between`"> + <div> + <div + :class="`${prefixCls}__item--icon ${prefixCls}__item--peoples p-16px inline-block rounded-6px`" + > + <Icon :size="40" icon="svg-icon:peoples" /> + </div> + </div> + <div class="flex flex-col justify-between"> + <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`" + >{{ t('analysis.newUser') }} + </div> + <CountTo + :duration="2600" + :end-val="102400" + :start-val="0" + class="text-right text-20px font-700" + /> + </div> + </div> + </template> + </el-skeleton> + </el-card> + </el-col> + + <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" :rows="2" animated> + <template #default> + <div :class="`${prefixCls}__item flex justify-between`"> + <div> + <div + :class="`${prefixCls}__item--icon ${prefixCls}__item--message p-16px inline-block rounded-6px`" + > + <Icon :size="40" icon="svg-icon:message" /> + </div> + </div> + <div class="flex flex-col justify-between"> + <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`" + >{{ t('analysis.unreadInformation') }} + </div> + <CountTo + :duration="2600" + :end-val="81212" + :start-val="0" + class="text-right text-20px font-700" + /> + </div> + </div> + </template> + </el-skeleton> + </el-card> + </el-col> + + <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" :rows="2" animated> + <template #default> + <div :class="`${prefixCls}__item flex justify-between`"> + <div> + <div + :class="`${prefixCls}__item--icon ${prefixCls}__item--money p-16px inline-block rounded-6px`" + > + <Icon :size="40" icon="svg-icon:money" /> + </div> + </div> + <div class="flex flex-col justify-between"> + <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`" + >{{ t('analysis.transactionAmount') }} + </div> + <CountTo + :duration="2600" + :end-val="9280" + :start-val="0" + class="text-right text-20px font-700" + /> + </div> + </div> + </template> + </el-skeleton> + </el-card> + </el-col> + + <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" :rows="2" animated> + <template #default> + <div :class="`${prefixCls}__item flex justify-between`"> + <div> + <div + :class="`${prefixCls}__item--icon ${prefixCls}__item--shopping p-16px inline-block rounded-6px`" + > + <Icon :size="40" icon="svg-icon:shopping" /> + </div> + </div> + <div class="flex flex-col justify-between"> + <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`" + >{{ t('analysis.totalShopping') }} + </div> + <CountTo + :duration="2600" + :end-val="13600" + :start-val="0" + class="text-right text-20px font-700" + /> + </div> + </div> + </template> + </el-skeleton> + </el-card> + </el-col> + </el-row> + <el-row :gutter="20" justify="space-between"> + <el-col :lg="10" :md="24" :sm="24" :xl="10" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" animated> + <Echart :height="300" :options="pieOptionsData" /> + </el-skeleton> + </el-card> + </el-col> + <el-col :lg="14" :md="24" :sm="24" :xl="14" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" animated> + <Echart :height="300" :options="barOptionsData" /> + </el-skeleton> + </el-card> + </el-col> + <el-col :span="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" :rows="4" animated> + <Echart :height="350" :options="lineOptionsData" /> + </el-skeleton> + </el-card> + </el-col> + </el-row> +</template> +<script lang="ts" setup> +import { set } from 'lodash-es' +import { EChartsOption } from 'echarts' + +import { useDesign } from '@/hooks/web/useDesign' +import type { AnalysisTotalTypes } from './types' +import { barOptions, lineOptions, pieOptions } from './echarts-data' + +defineOptions({ name: 'Home2' }) + +const { t } = useI18n() +const loading = ref(true) +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('panel') +const pieOptionsData = reactive<EChartsOption>(pieOptions) as EChartsOption + +let totalState = reactive<AnalysisTotalTypes>({ + users: 0, + messages: 0, + moneys: 0, + shoppings: 0 +}) + +const getCount = async () => { + const data = { + users: 102400, + messages: 81212, + moneys: 9280, + shoppings: 13600 + } + totalState = Object.assign(totalState, data) +} + +// 用户来源 +const getUserAccessSource = async () => { + const data = [ + { value: 335, name: 'analysis.directAccess' }, + { value: 310, name: 'analysis.mailMarketing' }, + { value: 234, name: 'analysis.allianceAdvertising' }, + { value: 135, name: 'analysis.videoAdvertising' }, + { value: 1548, name: 'analysis.searchEngines' } + ] + set( + pieOptionsData, + 'legend.data', + data.map((v) => t(v.name)) + ) + set(pieOptionsData, 'series.data', data) +} +const barOptionsData = reactive<EChartsOption>(barOptions) as EChartsOption + +// 周活跃量 +const getWeeklyUserActivity = async () => { + const data = [ + { value: 13253, name: 'analysis.monday' }, + { value: 34235, name: 'analysis.tuesday' }, + { value: 26321, name: 'analysis.wednesday' }, + { value: 12340, name: 'analysis.thursday' }, + { value: 24643, name: 'analysis.friday' }, + { value: 1322, name: 'analysis.saturday' }, + { value: 1324, name: 'analysis.sunday' } + ] + set( + barOptionsData, + 'xAxis.data', + data.map((v) => t(v.name)) + ) + set(barOptionsData, 'series', [ + { + name: t('analysis.activeQuantity'), + data: data.map((v) => v.value), + type: 'bar' + } + ]) +} + +const lineOptionsData = reactive<EChartsOption>(lineOptions) as EChartsOption + +// 每月销售总额 +const getMonthlySales = async () => { + const data = [ + { estimate: 100, actual: 120, name: 'analysis.january' }, + { estimate: 120, actual: 82, name: 'analysis.february' }, + { estimate: 161, actual: 91, name: 'analysis.march' }, + { estimate: 134, actual: 154, name: 'analysis.april' }, + { estimate: 105, actual: 162, name: 'analysis.may' }, + { estimate: 160, actual: 140, name: 'analysis.june' }, + { estimate: 165, actual: 145, name: 'analysis.july' }, + { estimate: 114, actual: 250, name: 'analysis.august' }, + { estimate: 163, actual: 134, name: 'analysis.september' }, + { estimate: 185, actual: 56, name: 'analysis.october' }, + { estimate: 118, actual: 99, name: 'analysis.november' }, + { estimate: 123, actual: 123, name: 'analysis.december' } + ] + set( + lineOptionsData, + 'xAxis.data', + data.map((v) => t(v.name)) + ) + set(lineOptionsData, 'series', [ + { + name: t('analysis.estimate'), + smooth: true, + type: 'line', + data: data.map((v) => v.estimate), + animationDuration: 2800, + animationEasing: 'cubicInOut' + }, + { + name: t('analysis.actual'), + smooth: true, + type: 'line', + itemStyle: {}, + data: data.map((v) => v.actual), + animationDuration: 2800, + animationEasing: 'quadraticOut' + } + ]) +} + +const getAllApi = async () => { + await Promise.all([getCount(), getUserAccessSource(), getWeeklyUserActivity(), getMonthlySales()]) + loading.value = false +} + +getAllApi() +</script> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-panel; + +.#{$prefix-cls} { + &__item { + &--peoples { + color: #40c9c6; + } + + &--message { + color: #36a3f7; + } + + &--money { + color: #f4516c; + } + + &--shopping { + color: #34bfa3; + } + + &:hover { + :deep(.#{$namespace}-icon) { + color: #fff !important; + } + + .#{$prefix-cls}__item--icon { + transition: all 0.38s ease-out; + } + + .#{$prefix-cls}__item--peoples { + background: #40c9c6; + } + + .#{$prefix-cls}__item--message { + background: #36a3f7; + } + + .#{$prefix-cls}__item--money { + background: #f4516c; + } + + .#{$prefix-cls}__item--shopping { + background: #34bfa3; + } + } + } +} +</style> diff --git a/src/views/Home/echarts-data.ts b/src/views/Home/echarts-data.ts new file mode 100644 index 0000000..56093f4 --- /dev/null +++ b/src/views/Home/echarts-data.ts @@ -0,0 +1,308 @@ +import { EChartsOption } from 'echarts' + +const { t } = useI18n() + +export const lineOptions: EChartsOption = { + title: { + text: t('analysis.monthlySales'), + left: 'center' + }, + xAxis: { + data: [ + t('analysis.january'), + t('analysis.february'), + t('analysis.march'), + t('analysis.april'), + t('analysis.may'), + t('analysis.june'), + t('analysis.july'), + t('analysis.august'), + t('analysis.september'), + t('analysis.october'), + t('analysis.november'), + t('analysis.december') + ], + boundaryGap: false, + axisTick: { + show: false + } + }, + grid: { + left: 20, + right: 20, + bottom: 20, + top: 80, + containLabel: true + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross' + }, + padding: [5, 10] + }, + yAxis: { + axisTick: { + show: false + } + }, + legend: { + data: [t('analysis.estimate'), t('analysis.actual')], + top: 50 + }, + series: [ + { + name: t('analysis.estimate'), + smooth: true, + type: 'line', + data: [100, 120, 161, 134, 105, 160, 165, 114, 163, 185, 118, 123], + animationDuration: 2800, + animationEasing: 'cubicInOut' + }, + { + name: t('analysis.actual'), + smooth: true, + type: 'line', + itemStyle: {}, + data: [120, 82, 91, 154, 162, 140, 145, 250, 134, 56, 99, 123], + animationDuration: 2800, + animationEasing: 'quadraticOut' + } + ] +} + +export const pieOptions: EChartsOption = { + title: { + text: t('analysis.userAccessSource'), + left: 'center' + }, + tooltip: { + trigger: 'item', + formatter: '{a} <br/>{b} : {c} ({d}%)' + }, + legend: { + orient: 'vertical', + left: 'left', + data: [ + t('analysis.directAccess'), + t('analysis.mailMarketing'), + t('analysis.allianceAdvertising'), + t('analysis.videoAdvertising'), + t('analysis.searchEngines') + ] + }, + series: [ + { + name: t('analysis.userAccessSource'), + type: 'pie', + radius: '55%', + center: ['50%', '60%'], + data: [ + { value: 335, name: t('analysis.directAccess') }, + { value: 310, name: t('analysis.mailMarketing') }, + { value: 234, name: t('analysis.allianceAdvertising') }, + { value: 135, name: t('analysis.videoAdvertising') }, + { value: 1548, name: t('analysis.searchEngines') } + ] + } + ] +} + +export const barOptions: EChartsOption = { + title: { + text: t('analysis.weeklyUserActivity'), + left: 'center' + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + grid: { + left: 50, + right: 20, + bottom: 20 + }, + xAxis: { + type: 'category', + data: [ + t('analysis.monday'), + t('analysis.tuesday'), + t('analysis.wednesday'), + t('analysis.thursday'), + t('analysis.friday'), + t('analysis.saturday'), + t('analysis.sunday') + ], + axisTick: { + alignWithLabel: true + } + }, + yAxis: { + type: 'value' + }, + series: [ + { + name: t('analysis.activeQuantity'), + data: [13253, 34235, 26321, 12340, 24643, 1322, 1324], + type: 'bar' + } + ] +} + +export const radarOption: EChartsOption = { + legend: { + data: [t('workplace.personal'), t('workplace.team')] + }, + radar: { + // shape: 'circle', + indicator: [ + { name: t('workplace.quote'), max: 65 }, + { name: t('workplace.contribution'), max: 160 }, + { name: t('workplace.hot'), max: 300 }, + { name: t('workplace.yield'), max: 130 }, + { name: t('workplace.follow'), max: 100 } + ] + }, + series: [ + { + name: `xxx${t('workplace.index')}`, + type: 'radar', + data: [ + { + value: [42, 30, 20, 35, 80], + name: t('workplace.personal') + }, + { + value: [50, 140, 290, 100, 90], + name: t('workplace.team') + } + ] + } + ] +} + +export const wordOptions = { + series: [ + { + type: 'wordCloud', + gridSize: 2, + sizeRange: [12, 50], + rotationRange: [-90, 90], + shape: 'pentagon', + width: 600, + height: 400, + drawOutOfBound: true, + textStyle: { + color: function () { + return ( + 'rgb(' + + [ + Math.round(Math.random() * 160), + Math.round(Math.random() * 160), + Math.round(Math.random() * 160) + ].join(',') + + ')' + ) + } + }, + emphasis: { + textStyle: { + shadowBlur: 10, + shadowColor: '#333' + } + }, + data: [ + { + name: 'Sam S Club', + value: 10000, + textStyle: { + color: 'black' + }, + emphasis: { + textStyle: { + color: 'red' + } + } + }, + { + name: 'Macys', + value: 6181 + }, + { + name: 'Amy Schumer', + value: 4386 + }, + { + name: 'Jurassic World', + value: 4055 + }, + { + name: 'Charter Communications', + value: 2467 + }, + { + name: 'Chick Fil A', + value: 2244 + }, + { + name: 'Planet Fitness', + value: 1898 + }, + { + name: 'Pitch Perfect', + value: 1484 + }, + { + name: 'Express', + value: 1112 + }, + { + name: 'Home', + value: 965 + }, + { + name: 'Johnny Depp', + value: 847 + }, + { + name: 'Lena Dunham', + value: 582 + }, + { + name: 'Lewis Hamilton', + value: 555 + }, + { + name: 'KXAN', + value: 550 + }, + { + name: 'Mary Ellen Mark', + value: 462 + }, + { + name: 'Farrah Abraham', + value: 366 + }, + { + name: 'Rita Ora', + value: 360 + }, + { + name: 'Serena Williams', + value: 282 + }, + { + name: 'NCAA baseball tournament', + value: 273 + }, + { + name: 'Point Break', + value: 265 + } + ] + } + ] +} diff --git a/src/views/Home/types.ts b/src/views/Home/types.ts new file mode 100644 index 0000000..e6313d3 --- /dev/null +++ b/src/views/Home/types.ts @@ -0,0 +1,55 @@ +export type WorkplaceTotal = { + project: number + access: number + todo: number +} + +export type Project = { + name: string + icon: string + message: string + personal: string + time: Date | number | string +} + +export type Notice = { + title: string + type: string + keys: string[] + date: Date | number | string +} + +export type Shortcut = { + name: string + icon: string + url: string +} + +export type RadarData = { + personal: number + team: number + max: number + name: string +} +export type AnalysisTotalTypes = { + users: number + messages: number + moneys: number + shoppings: number +} + +export type UserAccessSource = { + value: number + name: string +} + +export type WeeklyUserActivity = { + value: number + name: string +} + +export type MonthlySales = { + name: string + estimate: number + actual: number +} diff --git a/src/views/Login/Login.vue b/src/views/Login/Login.vue new file mode 100644 index 0000000..e124575 --- /dev/null +++ b/src/views/Login/Login.vue @@ -0,0 +1,106 @@ +<template> + <div + :class="prefixCls" + class="relative h-[100%] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px lt-xl:px-10px" + > + <div class="relative mx-auto h-full flex"> + <div + :class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden overflow-x-hidden overflow-y-auto`" + > + <!-- 左上角的 logo + 系统标题 --> + <div class="relative flex items-center text-white"> + <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" /> + <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span> + </div> + <!-- 左边的背景图 + 欢迎语 --> + <div class="h-[calc(100%-60px)] flex items-center justify-center"> + <TransitionGroup + appear + enter-active-class="animate__animated animate__bounceInLeft" + tag="div" + > + <img key="1" alt="" class="w-350px" src="@/assets/svgs/login-box-bg.svg" /> + <div key="2" class="text-3xl text-white">{{ t('login.welcome') }}</div> + <div key="3" class="mt-5 text-14px font-normal text-white"> + {{ t('login.message') }} + </div> + </TransitionGroup> + </div> + </div> + <div + class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px overflow-x-hidden overflow-y-auto" + > + <!-- 右上角的主题、语言选择 --> + <div + class="flex items-center justify-between text-white at-2xl:justify-end at-xl:justify-end" + > + <div class="flex items-center at-2xl:hidden at-xl:hidden"> + <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" /> + <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span> + </div> + <div class="flex items-center justify-end space-x-10px h-48px"> + <ThemeSwitch /> + <LocaleDropdown class="dark:text-white lt-xl:text-white" /> + </div> + </div> + <!-- 右边的登录界面 --> + <Transition appear enter-active-class="animate__animated animate__bounceInRight"> + <div + class="m-auto h-[calc(100%-60px)] w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px" + > + <!-- 账号登录 --> + <LoginForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> + <!-- 手机登录 --> + <MobileForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> + <!-- 二维码登录 --> + <QrCodeForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> + <!-- 注册 --> + <RegisterForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> + <!-- 三方登录 --> + <SSOLoginVue class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> + </div> + </Transition> + </div> + </div> + </div> +</template> +<script lang="ts" setup> +import { underlineToHump } from '@/utils' + +import { useDesign } from '@/hooks/web/useDesign' +import { useAppStore } from '@/store/modules/app' +import { ThemeSwitch } from '@/layout/components/ThemeSwitch' +import { LocaleDropdown } from '@/layout/components/LocaleDropdown' + +import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue } from './components' + +defineOptions({ name: 'Login' }) + +const { t } = useI18n() +const appStore = useAppStore() +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('login') +</script> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-login; + +.#{$prefix-cls} { + overflow: auto; + + &__left { + &::before { + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + background-image: url('@/assets/svgs/login-bg.svg'); + background-position: center; + background-repeat: no-repeat; + content: ''; + } + } +} +</style> diff --git a/src/views/Login/SocialLogin.vue b/src/views/Login/SocialLogin.vue new file mode 100644 index 0000000..00bc9fe --- /dev/null +++ b/src/views/Login/SocialLogin.vue @@ -0,0 +1,345 @@ +<template> + <div + :class="prefixCls" + class="relative h-[100%] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px lt-xl:px-10px" + > + <div class="relative mx-auto h-full flex"> + <div + :class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden overflow-x-hidden overflow-y-auto`" + > + <!-- 左上角的 logo + 系统标题 --> + <div class="relative flex items-center text-white"> + <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" /> + <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span> + </div> + <!-- 左边的背景图 + 欢迎语 --> + <div class="h-[calc(100%-60px)] flex items-center justify-center"> + <TransitionGroup + appear + enter-active-class="animate__animated animate__bounceInLeft" + tag="div" + > + <img key="1" alt="" class="w-350px" src="@/assets/svgs/login-box-bg.svg" /> + <div key="2" class="text-3xl text-white">{{ t('login.welcome') }}</div> + <div key="3" class="mt-5 text-14px font-normal text-white"> + {{ t('login.message') }} + </div> + </TransitionGroup> + </div> + </div> + <div + class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px overflow-x-hidden overflow-y-auto" + > + <!-- 右上角的主题、语言选择 --> + <div + class="flex items-center justify-between text-white at-2xl:justify-end at-xl:justify-end" + > + <div class="flex items-center at-2xl:hidden at-xl:hidden"> + <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" /> + <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span> + </div> + <div class="flex items-center justify-end space-x-10px h-48px"> + <ThemeSwitch /> + <LocaleDropdown class="dark:text-white lt-xl:text-white" /> + </div> + </div> + <!-- 右边的登录界面 --> + <Transition appear enter-active-class="animate__animated animate__bounceInRight"> + <div + class="m-auto h-[calc(100%-60px)] w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px" + > + <!-- 账号登录 --> + <el-form + v-show="getShow" + ref="formLogin" + :model="loginData.loginForm" + :rules="LoginRules" + class="login-form" + label-position="top" + label-width="120px" + size="large" + > + <el-row style="margin-right: -10px; margin-left: -10px"> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <LoginFormTitle style="width: 100%" /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item v-if="loginData.tenantEnable" prop="tenantName"> + <el-input + v-model="loginData.loginForm.tenantName" + :placeholder="t('login.tenantNamePlaceholder')" + :prefix-icon="iconHouse" + link + type="primary" + /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item prop="username"> + <el-input + v-model="loginData.loginForm.username" + :placeholder="t('login.usernamePlaceholder')" + :prefix-icon="iconAvatar" + /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item prop="password"> + <el-input + v-model="loginData.loginForm.password" + :placeholder="t('login.passwordPlaceholder')" + :prefix-icon="iconLock" + show-password + type="password" + @keyup.enter="getCode()" + /> + </el-form-item> + </el-col> + <el-col + :span="24" + style=" + padding-right: 10px; + padding-left: 10px; + margin-top: -20px; + margin-bottom: -20px; + " + > + <el-form-item> + <el-row justify="space-between" style="width: 100%"> + <el-col :span="6"> + <el-checkbox v-model="loginData.loginForm.rememberMe"> + {{ t('login.remember') }} + </el-checkbox> + </el-col> + <el-col :offset="6" :span="12"> + <el-link style="float: right" type="primary">{{ + t('login.forgetPassword') + }}</el-link> + </el-col> + </el-row> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <XButton + :loading="loginLoading" + :title="t('login.login')" + class="w-[100%]" + type="primary" + @click="getCode()" + /> + </el-form-item> + </el-col> + <Verify + ref="verify" + :captchaType="captchaType" + :imgSize="{ width: '400px', height: '200px' }" + mode="pop" + @success="handleLogin" + /> + </el-row> + </el-form> + </div> + </Transition> + </div> + </div> + </div> +</template> + +<script lang="ts" setup> +import { underlineToHump } from '@/utils' + +import { ElLoading } from 'element-plus' + +import { useDesign } from '@/hooks/web/useDesign' +import { useAppStore } from '@/store/modules/app' +import { useIcon } from '@/hooks/web/useIcon' +import { usePermissionStore } from '@/store/modules/permission' + +import * as LoginApi from '@/api/login' +import * as authUtil from '@/utils/auth' +import { ThemeSwitch } from '@/layout/components/ThemeSwitch' +import { LocaleDropdown } from '@/layout/components/LocaleDropdown' +import { LoginStateEnum, useFormValid, useLoginState } from './components/useLogin' +import LoginFormTitle from './components/LoginFormTitle.vue' +import router from '@/router' + +defineOptions({ name: 'SocialLogin' }) + +const { t } = useI18n() +const route = useRoute() + +const appStore = useAppStore() +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('login') +const iconHouse = useIcon({ icon: 'ep:house' }) +const iconAvatar = useIcon({ icon: 'ep:avatar' }) +const iconLock = useIcon({ icon: 'ep:lock' }) +const formLogin = ref<any>() +const { validForm } = useFormValid(formLogin) +const { getLoginState } = useLoginState() +const { push } = useRouter() +const permissionStore = usePermissionStore() +const loginLoading = ref(false) +const verify = ref() +const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 + +const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN) + +const LoginRules = { + tenantName: [required], + username: [required], + password: [required] +} +const loginData = reactive({ + isShowPassword: false, + captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE !== 'false', + tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE !== 'false', + loginForm: { + tenantName: '芋道源码', + username: 'admin', + password: 'admin123', + captchaVerification: '', + rememberMe: false + } +}) + +// 获取验证码 +const getCode = async () => { + // 情况一,未开启:则直接登录 + if (!loginData.captchaEnable) { + await handleLogin({}) + } else { + // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录 + // 弹出验证码 + verify.value.show() + } +} +//获取租户ID +const getTenantId = async () => { + if (loginData.tenantEnable) { + const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName) + authUtil.setTenantId(res) + } +} +// 记住我 +const getCookie = () => { + const loginForm = authUtil.getLoginForm() + if (loginForm) { + loginData.loginForm = { + ...loginData.loginForm, + username: loginForm.username ? loginForm.username : loginData.loginForm.username, + password: loginForm.password ? loginForm.password : loginData.loginForm.password, + rememberMe: loginForm.rememberMe ? true : false, + tenantName: loginForm.tenantName ? loginForm.tenantName : loginData.loginForm.tenantName + } + } +} +const loading = ref() // ElLoading.service 返回的实例 + +// tricky: 配合LoginForm.vue中redirectUri需要对参数进行encode,需要在回调后进行decode +function getUrlValue(key: string): string { + const url = new URL(decodeURIComponent(location.href)) + return url.searchParams.get(key) ?? '' +} + +// 尝试登录: 当账号已经绑定,socialLogin会直接获得token +const tryLogin = async () => { + try { + const type = getUrlValue('type') + const redirect = getUrlValue('redirect') + const code = route?.query?.code as string + const state = route?.query?.state as string + + const res = await LoginApi.socialLogin(type, code, state) + authUtil.setToken(res) + + router.push({ path: redirect || '/' }) + } catch (err) {} +} + +// 登录 +const handleLogin = async (params) => { + loginLoading.value = true + try { + await getTenantId() + const data = await validForm() + if (!data) { + return + } + + let redirect = getUrlValue('redirect') + + const type = getUrlValue('type') + const code = route?.query?.code as string + const state = route?.query?.state as string + + const res = await LoginApi.login({ + // 账号密码登录 + username: loginData.loginForm.username, + password: loginData.loginForm.password, + captchaVerification: params.captchaVerification, + // 社交登录 + socialCode: code, + socialState: state, + socialType: type + }) + if (!res) { + return + } + loading.value = ElLoading.service({ + lock: true, + text: '正在加载系统中...', + background: 'rgba(0, 0, 0, 0.7)' + }) + if (loginData.loginForm.rememberMe) { + authUtil.setLoginForm(loginData.loginForm) + } else { + authUtil.removeLoginForm() + } + authUtil.setToken(res) + if (!redirect) { + redirect = '/' + } + // 判断是否为SSO登录 + if (redirect.indexOf('sso') !== -1) { + window.location.href = window.location.href.replace('/login?redirect=', '') + } else { + push({ path: redirect || permissionStore.addRouters[0].path }) + } + } finally { + loginLoading.value = false + loading.value.close() + } +} + +onMounted(() => { + getCookie() + tryLogin() +}) +</script> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-login; + +.#{$prefix-cls} { + overflow: auto; + + &__left { + &::before { + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + background-image: url('@/assets/svgs/login-bg.svg'); + background-position: center; + background-repeat: no-repeat; + content: ''; + } + } +} +</style> diff --git a/src/views/Login/components/LoginForm.vue b/src/views/Login/components/LoginForm.vue new file mode 100644 index 0000000..3dbaff3 --- /dev/null +++ b/src/views/Login/components/LoginForm.vue @@ -0,0 +1,354 @@ +<template> + <el-form + v-show="getShow" + ref="formLogin" + :model="loginData.loginForm" + :rules="LoginRules" + class="login-form" + label-position="top" + label-width="120px" + size="large" + > + <el-row style="margin-right: -10px; margin-left: -10px"> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <LoginFormTitle style="width: 100%" /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName"> + <el-input + v-model="loginData.loginForm.tenantName" + :placeholder="t('login.tenantNamePlaceholder')" + :prefix-icon="iconHouse" + link + type="primary" + /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item prop="username"> + <el-input + v-model="loginData.loginForm.username" + :placeholder="t('login.usernamePlaceholder')" + :prefix-icon="iconAvatar" + /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item prop="password"> + <el-input + v-model="loginData.loginForm.password" + :placeholder="t('login.passwordPlaceholder')" + :prefix-icon="iconLock" + show-password + type="password" + @keyup.enter="getCode()" + /> + </el-form-item> + </el-col> + <el-col + :span="24" + style="padding-right: 10px; padding-left: 10px; margin-top: -20px; margin-bottom: -20px" + > + <el-form-item> + <el-row justify="space-between" style="width: 100%"> + <el-col :span="6"> + <el-checkbox v-model="loginData.loginForm.rememberMe"> + {{ t('login.remember') }} + </el-checkbox> + </el-col> + <el-col :offset="6" :span="12"> + <el-link style="float: right" type="primary">{{ t('login.forgetPassword') }}</el-link> + </el-col> + </el-row> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <XButton + :loading="loginLoading" + :title="t('login.login')" + class="w-[100%]" + type="primary" + @click="getCode()" + /> + </el-form-item> + </el-col> + <Verify + ref="verify" + :captchaType="captchaType" + :imgSize="{ width: '400px', height: '200px' }" + mode="pop" + @success="handleLogin" + /> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <el-row :gutter="5" justify="space-between" style="width: 100%"> + <el-col :span="8"> + <XButton + :title="t('login.btnMobile')" + class="w-[100%]" + @click="setLoginState(LoginStateEnum.MOBILE)" + /> + </el-col> + <el-col :span="8"> + <XButton + :title="t('login.btnQRCode')" + class="w-[100%]" + @click="setLoginState(LoginStateEnum.QR_CODE)" + /> + </el-col> + <el-col :span="8"> + <XButton + :title="t('login.btnRegister')" + class="w-[100%]" + @click="setLoginState(LoginStateEnum.REGISTER)" + /> + </el-col> + </el-row> + </el-form-item> + </el-col> + <el-divider content-position="center">{{ t('login.otherLogin') }}</el-divider> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <div class="w-[100%] flex justify-between"> + <Icon + v-for="(item, key) in socialList" + :key="key" + :icon="item.icon" + :size="30" + class="anticon cursor-pointer" + color="#999" + @click="doSocialLogin(item.type)" + /> + </div> + </el-form-item> + </el-col> + <el-divider content-position="center">萌新必读</el-divider> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <div class="w-[100%] flex justify-between"> + <el-link href="https://doc.iocoder.cn/" target="_blank">📚开发指南</el-link> + <el-link href="https://doc.iocoder.cn/video/" target="_blank">🔥视频教程</el-link> + <el-link href="https://www.iocoder.cn/Interview/good-collection/" target="_blank"> + ⚡面试手册 + </el-link> + <el-link href="http://static.yudao.iocoder.cn/mp/Aix9975.jpeg" target="_blank"> + 🤝外包咨询 + </el-link> + </div> + </el-form-item> + </el-col> + </el-row> + </el-form> +</template> +<script lang="ts" setup> +import { ElLoading } from 'element-plus' +import LoginFormTitle from './LoginFormTitle.vue' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +import { useIcon } from '@/hooks/web/useIcon' + +import * as authUtil from '@/utils/auth' +import { usePermissionStore } from '@/store/modules/permission' +import * as LoginApi from '@/api/login' +import { LoginStateEnum, useFormValid, useLoginState } from './useLogin' + +defineOptions({ name: 'LoginForm' }) + +const { t } = useI18n() +const message = useMessage() +const iconHouse = useIcon({ icon: 'ep:house' }) +const iconAvatar = useIcon({ icon: 'ep:avatar' }) +const iconLock = useIcon({ icon: 'ep:lock' }) +const formLogin = ref() +const { validForm } = useFormValid(formLogin) +const { setLoginState, getLoginState } = useLoginState() +const { currentRoute, push } = useRouter() +const permissionStore = usePermissionStore() +const redirect = ref<string>('') +const loginLoading = ref(false) +const verify = ref() +const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 + +const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN) + +const LoginRules = { + tenantName: [required], + username: [required], + password: [required] +} +const loginData = reactive({ + isShowPassword: false, + captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE, + tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE, + loginForm: { + tenantName: import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT || '', + username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '', + password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '', + captchaVerification: '', + rememberMe: true // 默认记录我。如果不需要,可手动修改 + } +}) + +const socialList = [ + { icon: 'ant-design:wechat-filled', type: 30 }, + { icon: 'ant-design:dingtalk-circle-filled', type: 20 }, + { icon: 'ant-design:github-filled', type: 0 }, + { icon: 'ant-design:alipay-circle-filled', type: 0 } +] + +// 获取验证码 +const getCode = async () => { + // 情况一,未开启:则直接登录 + if (loginData.captchaEnable === 'false') { + await handleLogin({}) + } else { + // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录 + // 弹出验证码 + verify.value.show() + } +} +// 获取租户 ID +const getTenantId = async () => { + if (loginData.tenantEnable === 'true') { + const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName) + authUtil.setTenantId(res) + } +} +// 记住我 +const getLoginFormCache = () => { + const loginForm = authUtil.getLoginForm() + if (loginForm) { + loginData.loginForm = { + ...loginData.loginForm, + username: loginForm.username ? loginForm.username : loginData.loginForm.username, + password: loginForm.password ? loginForm.password : loginData.loginForm.password, + rememberMe: loginForm.rememberMe, + tenantName: loginForm.tenantName ? loginForm.tenantName : loginData.loginForm.tenantName + } + } +} +// 根据域名,获得租户信息 +const getTenantByWebsite = async () => { + const website = location.host + const res = await LoginApi.getTenantByWebsite(website) + if (res) { + loginData.loginForm.tenantName = res.name + authUtil.setTenantId(res.id) + } +} +const loading = ref() // ElLoading.service 返回的实例 +// 登录 +const handleLogin = async (params) => { + loginLoading.value = true + try { + await getTenantId() + const data = await validForm() + if (!data) { + return + } + loginData.loginForm.captchaVerification = params.captchaVerification + const res = await LoginApi.login(loginData.loginForm) + if (!res) { + return + } + loading.value = ElLoading.service({ + lock: true, + text: '正在加载系统中...', + background: 'rgba(0, 0, 0, 0.7)' + }) + if (loginData.loginForm.rememberMe) { + authUtil.setLoginForm(loginData.loginForm) + } else { + authUtil.removeLoginForm() + } + authUtil.setToken(res) + if (!redirect.value) { + redirect.value = '/' + } + // 判断是否为SSO登录 + if (redirect.value.indexOf('sso') !== -1) { + window.location.href = window.location.href.replace('/login?redirect=', '') + } else { + push({ path: redirect.value || permissionStore.addRouters[0].path }) + } + } finally { + loginLoading.value = false + loading.value.close() + } +} + +// 社交登录 +const doSocialLogin = async (type: number) => { + if (type === 0) { + message.error('此方式未配置') + } else { + loginLoading.value = true + if (loginData.tenantEnable === 'true') { + // 尝试先通过 tenantName 获取租户 + await getTenantId() + // 如果获取不到,则需要弹出提示,进行处理 + if (!authUtil.getTenantId()) { + try { + const data = await message.prompt('请输入租户名称', t('common.reminder')) + if (data?.action !== 'confirm') throw 'cancel' + const res = await LoginApi.getTenantIdByName(data.value) + authUtil.setTenantId(res) + } catch (error) { + if (error === 'cancel') return + } finally { + loginLoading.value = false + } + } + } + // 计算 redirectUri + // tricky: type、redirect需要先encode一次,否则钉钉回调会丢失。 + // 配合 Login/SocialLogin.vue#getUrlValue() 使用 + const redirectUri = + location.origin + + '/social-login?' + + encodeURIComponent(`type=${type}&redirect=${redirect.value || '/'}`) + + // 进行跳转 + const res = await LoginApi.socialAuthRedirect(type, encodeURIComponent(redirectUri)) + window.location.href = res + } +} +watch( + () => currentRoute.value, + (route: RouteLocationNormalizedLoaded) => { + redirect.value = route?.query?.redirect as string + }, + { + immediate: true + } +) +onMounted(() => { + getLoginFormCache() + getTenantByWebsite() +}) +</script> + +<style lang="scss" scoped> +:deep(.anticon) { + &:hover { + color: var(--el-color-primary) !important; + } +} + +.login-code { + float: right; + width: 100%; + height: 38px; + + img { + width: 100%; + height: auto; + max-width: 100px; + vertical-align: middle; + cursor: pointer; + } +} +</style> diff --git a/src/views/Login/components/LoginFormTitle.vue b/src/views/Login/components/LoginFormTitle.vue new file mode 100644 index 0000000..cdf4fac --- /dev/null +++ b/src/views/Login/components/LoginFormTitle.vue @@ -0,0 +1,26 @@ +<template> + <h2 class="enter-x mb-3 text-center text-2xl font-bold xl:text-center xl:text-3xl"> + {{ getFormTitle }} + </h2> +</template> +<script lang="ts" setup> +import { LoginStateEnum, useLoginState } from './useLogin' + +defineOptions({ name: 'LoginFormTitle' }) + +const { t } = useI18n() + +const { getLoginState } = useLoginState() + +const getFormTitle = computed(() => { + const titleObj = { + [LoginStateEnum.RESET_PASSWORD]: t('sys.login.forgetFormTitle'), + [LoginStateEnum.LOGIN]: t('sys.login.signInFormTitle'), + [LoginStateEnum.REGISTER]: t('sys.login.signUpFormTitle'), + [LoginStateEnum.MOBILE]: t('sys.login.mobileSignInFormTitle'), + [LoginStateEnum.QR_CODE]: t('sys.login.qrSignInFormTitle'), + [LoginStateEnum.SSO]: t('sys.login.ssoFormTitle') + } + return titleObj[unref(getLoginState)] +}) +</script> diff --git a/src/views/Login/components/MobileForm.vue b/src/views/Login/components/MobileForm.vue new file mode 100644 index 0000000..7f5d994 --- /dev/null +++ b/src/views/Login/components/MobileForm.vue @@ -0,0 +1,226 @@ +<template> + <el-form + v-show="getShow" + ref="formSmsLogin" + :model="loginData.loginForm" + :rules="rules" + class="login-form" + label-position="top" + label-width="120px" + size="large" + > + <el-row style="margin-right: -10px; margin-left: -10px"> + <!-- 租户名 --> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <LoginFormTitle style="width: 100%" /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName"> + <el-input + v-model="loginData.loginForm.tenantName" + :placeholder="t('login.tenantNamePlaceholder')" + :prefix-icon="iconHouse" + type="primary" + link + /> + </el-form-item> + </el-col> + <!-- 手机号 --> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item prop="mobileNumber"> + <el-input + v-model="loginData.loginForm.mobileNumber" + :placeholder="t('login.mobileNumberPlaceholder')" + :prefix-icon="iconCellphone" + /> + </el-form-item> + </el-col> + <!-- 验证码 --> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item prop="code"> + <el-row :gutter="5" justify="space-between" style="width: 100%"> + <el-col :span="24"> + <el-input + v-model="loginData.loginForm.code" + :placeholder="t('login.codePlaceholder')" + :prefix-icon="iconCircleCheck" + > + <!-- <el-button class="w-[100%]"> --> + <template #append> + <span + v-if="mobileCodeTimer <= 0" + class="getMobileCode" + style="cursor: pointer" + @click="getSmsCode" + > + {{ t('login.getSmsCode') }} + </span> + <span v-if="mobileCodeTimer > 0" class="getMobileCode" style="cursor: pointer"> + {{ mobileCodeTimer }}秒后可重新获取 + </span> + </template> + </el-input> + <!-- </el-button> --> + </el-col> + </el-row> + </el-form-item> + </el-col> + <!-- 登录按钮 / 返回按钮 --> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <XButton + :loading="loginLoading" + :title="t('login.login')" + class="w-[100%]" + type="primary" + @click="signIn()" + /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <XButton + :loading="loginLoading" + :title="t('login.backLogin')" + class="w-[100%]" + @click="handleBackLogin()" + /> + </el-form-item> + </el-col> + </el-row> + </el-form> +</template> +<script lang="ts" setup> +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +import { useIcon } from '@/hooks/web/useIcon' + +import { setTenantId, setToken } from '@/utils/auth' +import { usePermissionStore } from '@/store/modules/permission' +import { getTenantIdByName, sendSmsCode, smsLogin } from '@/api/login' +import LoginFormTitle from './LoginFormTitle.vue' +import { LoginStateEnum, useFormValid, useLoginState } from './useLogin' +import { ElLoading } from 'element-plus' + +defineOptions({ name: 'MobileForm' }) + +const { t } = useI18n() +const message = useMessage() +const permissionStore = usePermissionStore() +const { currentRoute, push } = useRouter() +const formSmsLogin = ref() +const loginLoading = ref(false) +const iconHouse = useIcon({ icon: 'ep:house' }) +const iconCellphone = useIcon({ icon: 'ep:cellphone' }) +const iconCircleCheck = useIcon({ icon: 'ep:circle-check' }) +const { validForm } = useFormValid(formSmsLogin) +const { handleBackLogin, getLoginState } = useLoginState() +const getShow = computed(() => unref(getLoginState) === LoginStateEnum.MOBILE) + +const rules = { + tenantName: [required], + mobileNumber: [required], + code: [required] +} +const loginData = reactive({ + codeImg: '', + tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE, + token: '', + loading: { + signIn: false + }, + loginForm: { + uuid: '', + tenantName: '芋道源码', + mobileNumber: '', + code: '' + } +}) +const smsVO = reactive({ + smsCode: { + mobile: '', + scene: 21 + }, + loginSms: { + mobile: '', + code: '' + } +}) +const mobileCodeTimer = ref(0) +const redirect = ref<string>('') +const getSmsCode = async () => { + await getTenantId() + smsVO.smsCode.mobile = loginData.loginForm.mobileNumber + await sendSmsCode(smsVO.smsCode).then(async () => { + message.success(t('login.SmsSendMsg')) + // 设置倒计时 + mobileCodeTimer.value = 60 + let msgTimer = setInterval(() => { + mobileCodeTimer.value = mobileCodeTimer.value - 1 + if (mobileCodeTimer.value <= 0) { + clearInterval(msgTimer) + } + }, 1000) + }) +} +watch( + () => currentRoute.value, + (route: RouteLocationNormalizedLoaded) => { + redirect.value = route?.query?.redirect as string + }, + { + immediate: true + } +) +// 获取租户 ID +const getTenantId = async () => { + if (loginData.tenantEnable === 'true') { + const res = await getTenantIdByName(loginData.loginForm.tenantName) + setTenantId(res) + } +} +// 登录 +const signIn = async () => { + await getTenantId() + const data = await validForm() + if (!data) return + ElLoading.service({ + lock: true, + text: '正在加载系统中...', + background: 'rgba(0, 0, 0, 0.7)' + }) + loginLoading.value = true + smsVO.loginSms.mobile = loginData.loginForm.mobileNumber + smsVO.loginSms.code = loginData.loginForm.code + await smsLogin(smsVO.loginSms) + .then(async (res) => { + setToken(res) + if (!redirect.value) { + redirect.value = '/' + } + push({ path: redirect.value || permissionStore.addRouters[0].path }) + }) + .catch(() => {}) + .finally(() => { + loginLoading.value = false + setTimeout(() => { + const loadingInstance = ElLoading.service() + loadingInstance.close() + }, 400) + }) +} +</script> + +<style lang="scss" scoped> +:deep(.anticon) { + &:hover { + color: var(--el-color-primary) !important; + } +} + +.smsbtn { + margin-top: 33px; +} +</style> diff --git a/src/views/Login/components/QrCodeForm.vue b/src/views/Login/components/QrCodeForm.vue new file mode 100644 index 0000000..877c59e --- /dev/null +++ b/src/views/Login/components/QrCodeForm.vue @@ -0,0 +1,30 @@ +<template> + <el-row class="login-form" v-show="getShow" style="margin-right: -10px; margin-left: -10px"> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <LoginFormTitle style="width: 100%" /> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-card class="mb-10px text-center" shadow="hover"> + <Qrcode :logo="logoImg" /> + </el-card> + </el-col> + <el-divider class="enter-x">{{ t('login.qrcode') }}</el-divider> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <div class="mt-15px w-[100%]"> + <XButton :title="t('login.backLogin')" class="w-[100%]" @click="handleBackLogin()" /> + </div> + </el-col> + </el-row> +</template> +<script lang="ts" setup> +import logoImg from '@/assets/imgs/logo.png' + +import LoginFormTitle from './LoginFormTitle.vue' +import { LoginStateEnum, useLoginState } from './useLogin' + +defineOptions({ name: 'QrCodeForm' }) + +const { t } = useI18n() +const { handleBackLogin, getLoginState } = useLoginState() +const getShow = computed(() => unref(getLoginState) === LoginStateEnum.QR_CODE) +</script> diff --git a/src/views/Login/components/RegisterForm.vue b/src/views/Login/components/RegisterForm.vue new file mode 100644 index 0000000..51ec81e --- /dev/null +++ b/src/views/Login/components/RegisterForm.vue @@ -0,0 +1,142 @@ +<template> + <Form + v-show="getShow" + :rules="rules" + :schema="schema" + class="w-[100%] dark:(border-1 border-[var(--el-border-color)] border-solid)" + hide-required-asterisk + label-position="top" + size="large" + @register="register" + > + <template #title> + <LoginFormTitle style="width: 100%" /> + </template> + + <template #code="form"> + <div class="w-[100%] flex"> + <el-input v-model="form['code']" :placeholder="t('login.codePlaceholder')" /> + </div> + </template> + + <template #register> + <div class="w-[100%]"> + <XButton + :loading="loading" + :title="t('login.register')" + class="w-[100%]" + type="primary" + @click="loginRegister()" + /> + </div> + <div class="mt-15px w-[100%]"> + <XButton :title="t('login.hasUser')" class="w-[100%]" @click="handleBackLogin()" /> + </div> + </template> + </Form> +</template> +<script lang="ts" setup> +import type { FormRules } from 'element-plus' + +import { useForm } from '@/hooks/web/useForm' +import { useValidator } from '@/hooks/web/useValidator' +import LoginFormTitle from './LoginFormTitle.vue' +import { LoginStateEnum, useLoginState } from './useLogin' +import { FormSchema } from '@/types/form' + +defineOptions({ name: 'RegisterForm' }) + +const { t } = useI18n() +const { required } = useValidator() +const { register, elFormRef } = useForm() +const { handleBackLogin, getLoginState } = useLoginState() +const getShow = computed(() => unref(getLoginState) === LoginStateEnum.REGISTER) + +const schema = reactive<FormSchema[]>([ + { + field: 'title', + colProps: { + span: 24 + } + }, + { + field: 'username', + label: t('login.username'), + value: '', + component: 'Input', + colProps: { + span: 24 + }, + componentProps: { + placeholder: t('login.usernamePlaceholder') + } + }, + { + field: 'password', + label: t('login.password'), + value: '', + component: 'InputPassword', + colProps: { + span: 24 + }, + componentProps: { + style: { + width: '100%' + }, + strength: true, + placeholder: t('login.passwordPlaceholder') + } + }, + { + field: 'check_password', + label: t('login.checkPassword'), + value: '', + component: 'InputPassword', + colProps: { + span: 24 + }, + componentProps: { + style: { + width: '100%' + }, + strength: true, + placeholder: t('login.passwordPlaceholder') + } + }, + { + field: 'code', + label: t('login.code'), + colProps: { + span: 24 + } + }, + { + field: 'register', + colProps: { + span: 24 + } + } +]) + +const rules: FormRules = { + username: [required()], + password: [required()], + check_password: [required()], + code: [required()] +} + +const loading = ref(false) + +const loginRegister = async () => { + const formRef = unref(elFormRef) + formRef?.validate(async (valid) => { + if (valid) { + try { + loading.value = true + } finally { + loading.value = false + } + } + }) +} +</script> diff --git a/src/views/Login/components/SSOLogin.vue b/src/views/Login/components/SSOLogin.vue new file mode 100644 index 0000000..f31ab0e --- /dev/null +++ b/src/views/Login/components/SSOLogin.vue @@ -0,0 +1,199 @@ +<template> + <div v-show="ssoVisible" class="form-cont"> + <!-- 应用名 --> + <LoginFormTitle style="width: 100%" /> + <el-tabs class="form" style="float: none" value="uname"> + <el-tab-pane :label="client.name" name="uname" /> + </el-tabs> + <div> + <el-form :model="formData" class="login-form"> + <!-- 授权范围的选择 --> + 此第三方应用请求获得以下权限: + <el-form-item prop="scopes"> + <el-checkbox-group v-model="formData.scopes"> + <el-checkbox + v-for="scope in queryParams.scopes" + :key="scope" + :label="scope" + style="display: block; margin-bottom: -10px" + > + {{ formatScope(scope) }} + </el-checkbox> + </el-checkbox-group> + </el-form-item> + <!-- 下方的登录按钮 --> + <el-form-item class="w-1/1"> + <el-button + :loading="formLoading" + class="w-6/10" + type="primary" + @click.prevent="handleAuthorize(true)" + > + <span v-if="!formLoading">同意授权</span> + <span v-else>授 权 中...</span> + </el-button> + <el-button class="w-3/10" @click.prevent="handleAuthorize(false)">拒绝</el-button> + </el-form-item> + </el-form> + </div> + </div> +</template> +<script lang="ts" setup> +import LoginFormTitle from './LoginFormTitle.vue' +import * as OAuth2Api from '@/api/login/oauth2' +import { LoginStateEnum, useLoginState } from './useLogin' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +defineOptions({ name: 'SSOLogin' }) + +const route = useRoute() // 路由 +const { currentRoute } = useRouter() // 路由 +const { getLoginState, setLoginState } = useLoginState() + +const client = ref({ + // 客户端信息 + name: '', + logo: '' +}) +interface queryType { + responseType: string + clientId: string + redirectUri: string + state: string + scopes: string[] +} +const queryParams = reactive<queryType>({ + // URL 上的 client_id、scope 等参数 + responseType: '', + clientId: '', + redirectUri: '', + state: '', + scopes: [] // 优先从 query 参数获取;如果未传递,从后端获取 +}) +const ssoVisible = computed(() => unref(getLoginState) === LoginStateEnum.SSO) // 是否展示 SSO 登录的表单 +interface formType { + scopes: string[] +} +const formData = reactive<formType>({ + scopes: [] // 已选中的 scope 数组 +}) +const formLoading = ref(false) // 表单是否提交中 + +/** 初始化授权信息 */ +const init = async () => { + // 防止在没有登录的情况下循环弹窗 + if (typeof route.query.client_id === 'undefined') return + // 解析参数 + // 例如说【自动授权不通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write + // 例如说【自动授权通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read + queryParams.responseType = route.query.response_type as string + queryParams.clientId = route.query.client_id as string + queryParams.redirectUri = route.query.redirect_uri as string + queryParams.state = route.query.state as string + if (route.query.scope) { + queryParams.scopes = (route.query.scope as string).split(' ') + } + + // 如果有 scope 参数,先执行一次自动授权,看看是否之前都授权过了。 + if (queryParams.scopes.length > 0) { + const data = await doAuthorize(true, queryParams.scopes, []) + if (data) { + location.href = data + return + } + } + + // 获取授权页的基本信息 + const data = await OAuth2Api.getAuthorize(queryParams.clientId) + client.value = data.client + // 解析 scope + let scopes + // 1.1 如果 params.scope 非空,则过滤下返回的 scopes + if (queryParams.scopes.length > 0) { + scopes = [] + for (const scope of data.scopes) { + if (queryParams.scopes.indexOf(scope.key) >= 0) { + scopes.push(scope) + } + } + // 1.2 如果 params.scope 为空,则使用返回的 scopes 设置它 + } else { + scopes = data.scopes + for (const scope of scopes) { + queryParams.scopes.push(scope.key) + } + } + // 生成已选中的 checkedScopes + for (const scope of scopes) { + if (scope.value) { + formData.scopes.push(scope.key) + } + } +} + +/** 处理授权的提交 */ +const handleAuthorize = async (approved) => { + // 计算 checkedScopes + uncheckedScopes + let checkedScopes + let uncheckedScopes + if (approved) { + // 同意授权,按照用户的选择 + checkedScopes = formData.scopes + uncheckedScopes = queryParams.scopes.filter((item) => checkedScopes.indexOf(item) === -1) + } else { + // 拒绝,则都是取消 + checkedScopes = [] + uncheckedScopes = queryParams.scopes + } + // 提交授权的请求 + formLoading.value = true + try { + const data = await doAuthorize(false, checkedScopes, uncheckedScopes) + if (!data) { + return + } + location.href = data + } finally { + formLoading.value = false + } +} + +/** 调用授权 API 接口 */ +const doAuthorize = (autoApprove, checkedScopes, uncheckedScopes) => { + return OAuth2Api.authorize( + queryParams.responseType, + queryParams.clientId, + queryParams.redirectUri, + queryParams.state, + autoApprove, + checkedScopes, + uncheckedScopes + ) +} + +/** 格式化 scope 文本 */ +const formatScope = (scope) => { + // 格式化 scope 授权范围,方便用户理解。 + // 这里仅仅是一个 demo,可以考虑录入到字典数据中,例如说字典类型 "system_oauth2_scope",它的每个 scope 都是一条字典数据。 + switch (scope) { + case 'user.read': + return '访问你的个人信息' + case 'user.write': + return '修改你的个人信息' + default: + return scope + } +} + +/** 监听当前路由为 SSOLogin 时,进行数据的初始化 */ +watch( + () => currentRoute.value, + (route: RouteLocationNormalizedLoaded) => { + if (route.name === 'SSOLogin') { + setLoginState(LoginStateEnum.SSO) + init() + } + }, + { immediate: true } +) +</script> diff --git a/src/views/Login/components/index.ts b/src/views/Login/components/index.ts new file mode 100644 index 0000000..204ad73 --- /dev/null +++ b/src/views/Login/components/index.ts @@ -0,0 +1,8 @@ +import LoginForm from './LoginForm.vue' +import MobileForm from './MobileForm.vue' +import LoginFormTitle from './LoginFormTitle.vue' +import RegisterForm from './RegisterForm.vue' +import QrCodeForm from './QrCodeForm.vue' +import SSOLoginVue from './SSOLogin.vue' + +export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm, SSOLoginVue } diff --git a/src/views/Login/components/useLogin.ts b/src/views/Login/components/useLogin.ts new file mode 100644 index 0000000..b4a02f8 --- /dev/null +++ b/src/views/Login/components/useLogin.ts @@ -0,0 +1,42 @@ +import { Ref } from 'vue' + +export enum LoginStateEnum { + LOGIN, + REGISTER, + RESET_PASSWORD, + MOBILE, + QR_CODE, + SSO +} + +const currentState = ref(LoginStateEnum.LOGIN) + +export function useLoginState() { + function setLoginState(state: LoginStateEnum) { + currentState.value = state + } + const getLoginState = computed(() => currentState.value) + + function handleBackLogin() { + setLoginState(LoginStateEnum.LOGIN) + } + + return { + setLoginState, + getLoginState, + handleBackLogin + } +} + +export function useFormValid<T extends Object = any>(formRef: Ref<any>) { + async function validForm() { + const form = unref(formRef) + if (!form) return + const data = await form.validate() + return data as T + } + + return { + validForm + } +} diff --git a/src/views/Profile/Index.vue b/src/views/Profile/Index.vue new file mode 100644 index 0000000..8e1695b --- /dev/null +++ b/src/views/Profile/Index.vue @@ -0,0 +1,65 @@ +<template> + <div class="flex"> + <el-card class="user w-1/3" shadow="hover"> + <template #header> + <div class="card-header"> + <span>{{ t('profile.user.title') }}</span> + </div> + </template> + <ProfileUser /> + </el-card> + <el-card class="user ml-3 w-2/3" shadow="hover"> + <template #header> + <div class="card-header"> + <span>{{ t('profile.info.title') }}</span> + </div> + </template> + <div> + <el-tabs v-model="activeName" class="profile-tabs" style="height: 400px" tab-position="top"> + <el-tab-pane :label="t('profile.info.basicInfo')" name="basicInfo"> + <BasicInfo /> + </el-tab-pane> + <el-tab-pane :label="t('profile.info.resetPwd')" name="resetPwd"> + <ResetPwd /> + </el-tab-pane> + <el-tab-pane :label="t('profile.info.userSocial')" name="userSocial"> + <UserSocial v-model:activeName="activeName" /> + </el-tab-pane> + </el-tabs> + </div> + </el-card> + </div> +</template> +<script lang="ts" setup> +import { BasicInfo, ProfileUser, ResetPwd, UserSocial } from './components' + +const { t } = useI18n() +defineOptions({ name: 'Profile' }) +const activeName = ref('basicInfo') +</script> +<style scoped> +.user { + max-height: 960px; + padding: 15px 20px 20px; +} + +.card-header { + display: flex; + justify-content: center; + align-items: center; +} + +:deep(.el-card .el-card__header, .el-card .el-card__body) { + padding: 15px !important; +} + +.profile-tabs > .el-tabs__content { + padding: 32px; + font-weight: 600; + color: #6b778c; +} + +.el-tabs--left .el-tabs__content { + height: 100%; +} +</style> diff --git a/src/views/Profile/components/BasicInfo.vue b/src/views/Profile/components/BasicInfo.vue new file mode 100644 index 0000000..ddec27c --- /dev/null +++ b/src/views/Profile/components/BasicInfo.vue @@ -0,0 +1,96 @@ +<template> + <Form ref="formRef" :labelWidth="200" :rules="rules" :schema="schema"> + <template #sex="form"> + <el-radio-group v-model="form['sex']"> + <el-radio :label="1">{{ t('profile.user.man') }}</el-radio> + <el-radio :label="2">{{ t('profile.user.woman') }}</el-radio> + </el-radio-group> + </template> + </Form> + <div style="text-align: center"> + <XButton :title="t('common.save')" type="primary" @click="submit()" /> + <XButton :title="t('common.reset')" type="danger" @click="init()" /> + </div> +</template> +<script lang="ts" setup> +import type { FormRules } from 'element-plus' +import { FormSchema } from '@/types/form' +import type { FormExpose } from '@/components/Form' +import { + getUserProfile, + updateUserProfile, + UserProfileUpdateReqVO +} from '@/api/system/user/profile' +import { useUserStore } from '@/store/modules/user' + +defineOptions({ name: 'BasicInfo' }) + +const { t } = useI18n() +const message = useMessage() // 消息弹窗 +const userStore = useUserStore() +// 表单校验 +const rules = reactive<FormRules>({ + nickname: [{ required: true, message: t('profile.rules.nickname'), trigger: 'blur' }], + email: [ + { required: true, message: t('profile.rules.mail'), trigger: 'blur' }, + { + type: 'email', + message: t('profile.rules.truemail'), + trigger: ['blur', 'change'] + } + ], + mobile: [ + { required: true, message: t('profile.rules.phone'), trigger: 'blur' }, + { + pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, + message: t('profile.rules.truephone'), + trigger: 'blur' + } + ] +}) +const schema = reactive<FormSchema[]>([ + { + field: 'nickname', + label: t('profile.user.nickname'), + component: 'Input' + }, + { + field: 'mobile', + label: t('profile.user.mobile'), + component: 'Input' + }, + { + field: 'email', + label: t('profile.user.email'), + component: 'Input' + }, + { + field: 'sex', + label: t('profile.user.sex'), + component: 'InputNumber', + value: 0 + } +]) +const formRef = ref<FormExpose>() // 表单 Ref +const submit = () => { + const elForm = unref(formRef)?.getElFormRef() + if (!elForm) return + elForm.validate(async (valid) => { + if (valid) { + const data = unref(formRef)?.formModel as UserProfileUpdateReqVO + await updateUserProfile(data) + message.success(t('common.updateSuccess')) + const profile = await init() + userStore.setUserNicknameAction(profile.nickname) + } + }) +} +const init = async () => { + const res = await getUserProfile() + unref(formRef)?.setValues(res) + return res +} +onMounted(async () => { + await init() +}) +</script> diff --git a/src/views/Profile/components/ProfileUser.vue b/src/views/Profile/components/ProfileUser.vue new file mode 100644 index 0000000..0d469ef --- /dev/null +++ b/src/views/Profile/components/ProfileUser.vue @@ -0,0 +1,99 @@ +<template> + <div> + <div class="text-center"> + <UserAvatar :img="userInfo?.avatar" /> + </div> + <ul class="list-group list-group-striped"> + <li class="list-group-item"> + <Icon class="mr-5px" icon="ep:user" /> + {{ t('profile.user.username') }} + <div class="pull-right">{{ userInfo?.username }}</div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="ep:phone" /> + {{ t('profile.user.mobile') }} + <div class="pull-right">{{ userInfo?.mobile }}</div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="fontisto:email" /> + {{ t('profile.user.email') }} + <div class="pull-right">{{ userInfo?.email }}</div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="carbon:tree-view-alt" /> + {{ t('profile.user.dept') }} + <div v-if="userInfo?.dept" class="pull-right">{{ userInfo?.dept.name }}</div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="ep:suitcase" /> + {{ t('profile.user.posts') }} + <div v-if="userInfo?.posts" class="pull-right"> + {{ userInfo?.posts.map((post) => post.name).join(',') }} + </div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="icon-park-outline:peoples" /> + {{ t('profile.user.roles') }} + <div v-if="userInfo?.roles" class="pull-right"> + {{ userInfo?.roles.map((role) => role.name).join(',') }} + </div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="ep:calendar" /> + {{ t('profile.user.createTime') }} + <div class="pull-right">{{ formatDate(userInfo.createTime) }}</div> + </li> + </ul> + </div> +</template> +<script lang="ts" setup> +import { formatDate } from '@/utils/formatTime' +import UserAvatar from './UserAvatar.vue' + +import { getUserProfile, ProfileVO } from '@/api/system/user/profile' + +defineOptions({ name: 'ProfileUser' }) + +const { t } = useI18n() +const userInfo = ref({} as ProfileVO) +const getUserInfo = async () => { + const users = await getUserProfile() + userInfo.value = users +} +onMounted(async () => { + await getUserInfo() +}) +</script> + +<style scoped> +.text-center { + position: relative; + height: 120px; + text-align: center; +} + +.list-group-striped > .list-group-item { + padding-right: 0; + padding-left: 0; + border-right: 0; + border-left: 0; + border-radius: 0; +} + +.list-group { + padding-left: 0; + list-style: none; +} + +.list-group-item { + padding: 11px 0; + margin-bottom: -1px; + font-size: 13px; + border-top: 1px solid #e7eaec; + border-bottom: 1px solid #e7eaec; +} + +.pull-right { + float: right !important; +} +</style> diff --git a/src/views/Profile/components/ResetPwd.vue b/src/views/Profile/components/ResetPwd.vue new file mode 100644 index 0000000..477be91 --- /dev/null +++ b/src/views/Profile/components/ResetPwd.vue @@ -0,0 +1,73 @@ +<template> + <el-form ref="formRef" :model="password" :rules="rules" :label-width="200"> + <el-form-item :label="t('profile.password.oldPassword')" prop="oldPassword"> + <InputPassword v-model="password.oldPassword" /> + </el-form-item> + <el-form-item :label="t('profile.password.newPassword')" prop="newPassword"> + <InputPassword v-model="password.newPassword" strength /> + </el-form-item> + <el-form-item :label="t('profile.password.confirmPassword')" prop="confirmPassword"> + <InputPassword v-model="password.confirmPassword" strength /> + </el-form-item> + <el-form-item> + <XButton :title="t('common.save')" type="primary" @click="submit(formRef)" /> + <XButton :title="t('common.reset')" type="danger" @click="reset(formRef)" /> + </el-form-item> + </el-form> +</template> +<script lang="ts" setup> +import type { FormInstance, FormRules } from 'element-plus' + +import { InputPassword } from '@/components/InputPassword' +import { updateUserPassword } from '@/api/system/user/profile' + +defineOptions({ name: 'ResetPwd' }) + +const { t } = useI18n() +const message = useMessage() +const formRef = ref<FormInstance>() +const password = reactive({ + oldPassword: '', + newPassword: '', + confirmPassword: '' +}) + +// 表单校验 +const equalToPassword = (_rule, value, callback) => { + if (password.newPassword !== value) { + callback(new Error(t('profile.password.diffPwd'))) + } else { + callback() + } +} + +const rules = reactive<FormRules>({ + oldPassword: [ + { required: true, message: t('profile.password.oldPwdMsg'), trigger: 'blur' }, + { min: 6, max: 20, message: t('profile.password.pwdRules'), trigger: 'blur' } + ], + newPassword: [ + { required: true, message: t('profile.password.newPwdMsg'), trigger: 'blur' }, + { min: 6, max: 20, message: t('profile.password.pwdRules'), trigger: 'blur' } + ], + confirmPassword: [ + { required: true, message: t('profile.password.cfPwdMsg'), trigger: 'blur' }, + { required: true, validator: equalToPassword, trigger: 'blur' } + ] +}) + +const submit = (formEl: FormInstance | undefined) => { + if (!formEl) return + formEl.validate(async (valid) => { + if (valid) { + await updateUserPassword(password.oldPassword, password.newPassword) + message.success(t('common.updateSuccess')) + } + }) +} + +const reset = (formEl: FormInstance | undefined) => { + if (!formEl) return + formEl.resetFields() +} +</script> diff --git a/src/views/Profile/components/UserAvatar.vue b/src/views/Profile/components/UserAvatar.vue new file mode 100644 index 0000000..63e1e16 --- /dev/null +++ b/src/views/Profile/components/UserAvatar.vue @@ -0,0 +1,45 @@ +<template> + <div class="change-avatar"> + <CropperAvatar + ref="cropperRef" + :btnProps="{ preIcon: 'ant-design:cloud-upload-outlined' }" + :showBtn="false" + :value="img" + width="120px" + @change="handelUpload" + /> + </div> +</template> +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { uploadAvatar } from '@/api/system/user/profile' +import { CropperAvatar } from '@/components/Cropper' +import { useUserStore } from '@/store/modules/user' + + +defineOptions({ name: 'UserAvatar' }) + +defineProps({ + img: propTypes.string.def('') +}) + +const userStore = useUserStore() + + +const cropperRef = ref() +const handelUpload = async ({ data }) => { + const res = await uploadAvatar({ avatarFile: data }) + cropperRef.value.close() + userStore.setUserAvatarAction(res.data) +} +</script> + +<style lang="scss" scoped> +.change-avatar { + img { + display: block; + margin-bottom: 15px; + border-radius: 50%; + } +} +</style> diff --git a/src/views/Profile/components/UserSocial.vue b/src/views/Profile/components/UserSocial.vue new file mode 100644 index 0000000..b7f955b --- /dev/null +++ b/src/views/Profile/components/UserSocial.vue @@ -0,0 +1,107 @@ +<template> + <el-table :data="socialUsers" :show-header="false"> + <el-table-column fixed="left" title="序号" type="seq" width="60" /> + <el-table-column align="left" label="社交平台" width="120"> + <template #default="{ row }"> + <img :src="row.img" alt="" class="h-5 align-middle" /> + <p class="mr-5">{{ row.title }}</p> + </template> + </el-table-column> + <el-table-column align="center" label="操作"> + <template #default="{ row }"> + <template v-if="row.openid"> + 已绑定 + <XTextButton class="mr-5" title="(解绑)" type="primary" @click="unbind(row)" /> + </template> + <template v-else> + 未绑定 + <XTextButton class="mr-5" title="(绑定)" type="primary" @click="bind(row)" /> + </template> + </template> + </el-table-column> + </el-table> +</template> +<script lang="ts" setup> +import { SystemUserSocialTypeEnum } from '@/utils/constants' +import { getUserProfile, ProfileVO } from '@/api/system/user/profile' +import { socialAuthRedirect, socialBind, socialUnbind } from '@/api/system/user/socialUser' + +defineOptions({ name: 'UserSocial' }) +defineProps<{ + activeName: string +}>() +const message = useMessage() +const socialUsers = ref<any[]>([]) +const userInfo = ref<ProfileVO>() + +const initSocial = async () => { + socialUsers.value = [] // 重置避免无限增长 + const res = await getUserProfile() + userInfo.value = res + for (const i in SystemUserSocialTypeEnum) { + const socialUser = { ...SystemUserSocialTypeEnum[i] } + socialUsers.value.push(socialUser) + if (userInfo.value?.socialUsers) { + for (const j in userInfo.value.socialUsers) { + if (socialUser.type === userInfo.value.socialUsers[j].type) { + socialUser.openid = userInfo.value.socialUsers[j].openid + break + } + } + } + } +} +const route = useRoute() +const emit = defineEmits<{ + (e: 'update:activeName', v: string): void +}>() +const bindSocial = () => { + // 社交绑定 + const type = getUrlValue('type') + const code = route.query.code + const state = route.query.state + if (!code) { + return + } + socialBind(type, code, state).then(() => { + message.success('绑定成功') + emit('update:activeName', 'userSocial') + }) +} + +// 双层 encode 需要在回调后进行 decode +function getUrlValue(key: string): string { + const url = new URL(decodeURIComponent(location.href)) + return url.searchParams.get(key) ?? '' +} + +const bind = (row) => { + // 双层 encode 解决钉钉回调 type 参数丢失的问题 + const redirectUri = location.origin + '/user/profile?' + encodeURIComponent(`type=${row.type}`) + // 进行跳转 + socialAuthRedirect(row.type, encodeURIComponent(redirectUri)).then((res) => { + window.location.href = res + }) +} +const unbind = async (row) => { + const res = await socialUnbind(row.type, row.openid) + if (res) { + row.openid = undefined + } + message.success('解绑成功') +} + +onMounted(async () => { + await initSocial() +}) + +watch( + () => route, + () => { + bindSocial() + }, + { + immediate: true + } +) +</script> diff --git a/src/views/Profile/components/index.ts b/src/views/Profile/components/index.ts new file mode 100644 index 0000000..9e1883c --- /dev/null +++ b/src/views/Profile/components/index.ts @@ -0,0 +1,7 @@ +import BasicInfo from './BasicInfo.vue' +import ProfileUser from './ProfileUser.vue' +import ResetPwd from './ResetPwd.vue' +import UserAvatarVue from './UserAvatar.vue' +import UserSocial from './UserSocial.vue' + +export { BasicInfo, ProfileUser, ResetPwd, UserAvatarVue, UserSocial } diff --git a/src/views/Redirect/Redirect.vue b/src/views/Redirect/Redirect.vue new file mode 100644 index 0000000..f7717ce --- /dev/null +++ b/src/views/Redirect/Redirect.vue @@ -0,0 +1,28 @@ +<template> + <div></div> +</template> +<script lang="ts" setup> +defineOptions({ name: 'Redirect' }) + +const { currentRoute, replace } = useRouter() +const { params, query } = unref(currentRoute) +const { path, _redirect_type = 'path' } = params + +Reflect.deleteProperty(params, '_redirect_type') +Reflect.deleteProperty(params, 'path') + +const _path = Array.isArray(path) ? path.join('/') : path + +if (_redirect_type === 'name') { + replace({ + name: _path, + query, + params + }) +} else { + replace({ + path: _path.startsWith('/') ? _path : '/' + _path, + query + }) +} +</script> diff --git a/src/views/ai/chat/index/components/conversation/ConversationList.vue b/src/views/ai/chat/index/components/conversation/ConversationList.vue new file mode 100644 index 0000000..54940f8 --- /dev/null +++ b/src/views/ai/chat/index/components/conversation/ConversationList.vue @@ -0,0 +1,472 @@ +<!-- AI 对话 --> +<template> + <el-aside width="260px" class="conversation-container h-100%"> + <!-- 左顶部:对话 --> + <div class="h-100%"> + <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation"> + <Icon icon="ep:plus" class="mr-5px" /> + 新建对话 + </el-button> + + <!-- 左顶部:搜索对话 --> + <el-input + v-model="searchName" + size="large" + class="mt-10px search-input" + placeholder="搜索历史记录" + @keyup="searchConversation" + > + <template #prefix> + <Icon icon="ep:search" /> + </template> + </el-input> + + <!-- 左中间:对话列表 --> + <div class="conversation-list"> + <!-- 情况一:加载中 --> + <el-empty v-if="loading" description="." :v-loading="loading" /> + <!-- 情况二:按照 group 分组,展示聊天会话 list 列表 --> + <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey"> + <div + class="conversation-item classify-title" + v-if="conversationMap[conversationKey].length" + > + <el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text> + </div> + <div + class="conversation-item" + v-for="conversation in conversationMap[conversationKey]" + :key="conversation.id" + @click="handleConversationClick(conversation.id)" + @mouseover="hoverConversationId = conversation.id" + @mouseout="hoverConversationId = ''" + > + <div + :class=" + conversation.id === activeConversationId ? 'conversation active' : 'conversation' + " + > + <div class="title-wrapper"> + <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" /> + <span class="title">{{ conversation.title }}</span> + </div> + <div class="button-wrapper" v-show="hoverConversationId === conversation.id"> + <el-button class="btn" link @click.stop="handleTop(conversation)"> + <el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon> + <el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon> + </el-button> + <el-button class="btn" link @click.stop="updateConversationTitle(conversation)"> + <el-icon title="编辑"> + <Icon icon="ep:edit" /> + </el-icon> + </el-button> + <el-button class="btn" link @click.stop="deleteChatConversation(conversation)"> + <el-icon title="删除对话"> + <Icon icon="ep:delete" /> + </el-icon> + </el-button> + </div> + </div> + </div> + </div> + <!-- 底部占位 --> + <div class="h-160px w-100%"></div> + </div> + </div> + + <!-- 左底部:工具栏 --> + <div class="tool-box"> + <div @click="handleRoleRepository"> + <Icon icon="ep:user" /> + <el-text size="small">角色仓库</el-text> + </div> + <div @click="handleClearConversation"> + <Icon icon="ep:delete" /> + <el-text size="small">清空未置顶对话</el-text> + </div> + </div> + + <!-- 角色仓库抽屉 --> + <el-drawer v-model="roleRepositoryOpen" title="角色仓库" size="754px"> + <RoleRepository /> + </el-drawer> + </el-aside> +</template> + +<script setup lang="ts"> +import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' +import RoleRepository from '../role/RoleRepository.vue' +import { Bottom, Top } from '@element-plus/icons-vue' +import roleAvatarDefaultImg from '@/assets/ai/gpt.svg' + +const message = useMessage() // 消息弹窗 + +// 定义属性 +const searchName = ref<string>('') // 对话搜索 +const activeConversationId = ref<number | null>(null) // 选中的对话,默认为 null +const hoverConversationId = ref<number | null>(null) // 悬浮上去的对话 +const conversationList = ref([] as ChatConversationVO[]) // 对话列表 +const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前) +const loading = ref<boolean>(false) // 加载中 +const loadingTime = ref<any>() // 加载中定时器 + +// 定义组件 props +const props = defineProps({ + activeId: { + type: String || null, + required: true + } +}) + +// 定义钩子 +const emits = defineEmits([ + 'onConversationCreate', + 'onConversationClick', + 'onConversationClear', + 'onConversationDelete' +]) + +/** 搜索对话 */ +const searchConversation = async (e) => { + // 恢复数据 + if (!searchName.value.trim().length) { + conversationMap.value = await getConversationGroupByCreateTime(conversationList.value) + } else { + // 过滤 + const filterValues = conversationList.value.filter((item) => { + return item.title.includes(searchName.value.trim()) + }) + conversationMap.value = await getConversationGroupByCreateTime(filterValues) + } +} + +/** 点击对话 */ +const handleConversationClick = async (id: number) => { + // 过滤出选中的对话 + const filterConversation = conversationList.value.filter((item) => { + return item.id === id + }) + // 回调 onConversationClick + // noinspection JSVoidFunctionReturnValueUsed + const success = emits('onConversationClick', filterConversation[0]) + // 切换对话 + if (success) { + activeConversationId.value = id + } +} + +/** 获取对话列表 */ +const getChatConversationList = async () => { + try { + // 加载中 + loadingTime.value = setTimeout(() => { + loading.value = true + }, 50) + + // 1.1 获取 对话数据 + conversationList.value = await ChatConversationApi.getChatConversationMyList() + // 1.2 排序 + conversationList.value.sort((a, b) => { + return b.createTime - a.createTime + }) + // 1.3 没有任何对话情况 + if (conversationList.value.length === 0) { + activeConversationId.value = null + conversationMap.value = {} + return + } + + // 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前) + conversationMap.value = await getConversationGroupByCreateTime(conversationList.value) + } finally { + // 清理定时器 + if (loadingTime.value) { + clearTimeout(loadingTime.value) + } + // 加载完成 + loading.value = false + } +} + +/** 按照 creteTime 创建时间,进行分组 */ +const getConversationGroupByCreateTime = async (list: ChatConversationVO[]) => { + // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前) + // noinspection NonAsciiCharacters + const groupMap = { + 置顶: [], + 今天: [], + 一天前: [], + 三天前: [], + 七天前: [], + 三十天前: [] + } + // 当前时间的时间戳 + const now = Date.now() + // 定义时间间隔常量(单位:毫秒) + const oneDay = 24 * 60 * 60 * 1000 + const threeDays = 3 * oneDay + const sevenDays = 7 * oneDay + const thirtyDays = 30 * oneDay + for (const conversation of list) { + // 置顶 + if (conversation.pinned) { + groupMap['置顶'].push(conversation) + continue + } + // 计算时间差(单位:毫秒) + const diff = now - conversation.createTime + // 根据时间间隔判断 + if (diff < oneDay) { + groupMap['今天'].push(conversation) + } else if (diff < threeDays) { + groupMap['一天前'].push(conversation) + } else if (diff < sevenDays) { + groupMap['三天前'].push(conversation) + } else if (diff < thirtyDays) { + groupMap['七天前'].push(conversation) + } else { + groupMap['三十天前'].push(conversation) + } + } + return groupMap +} + +/** 新建对话 */ +const createConversation = async () => { + // 1. 新建对话 + const conversationId = await ChatConversationApi.createChatConversationMy( + {} as unknown as ChatConversationVO + ) + // 2. 获取对话内容 + await getChatConversationList() + // 3. 选中对话 + await handleConversationClick(conversationId) + // 4. 回调 + emits('onConversationCreate') +} + +/** 修改对话的标题 */ +const updateConversationTitle = async (conversation: ChatConversationVO) => { + // 1. 二次确认 + const { value } = await ElMessageBox.prompt('修改标题', { + inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格 + inputErrorMessage: '标题不能为空', + inputValue: conversation.title + }) + // 2. 发起修改 + await ChatConversationApi.updateChatConversationMy({ + id: conversation.id, + title: value + } as ChatConversationVO) + message.success('重命名成功') + // 3. 刷新列表 + await getChatConversationList() + // 4. 过滤当前切换的 + const filterConversationList = conversationList.value.filter((item) => { + return item.id === conversation.id + }) + if (filterConversationList.length > 0) { + // tip:避免切换对话 + if (activeConversationId.value === filterConversationList[0].id) { + emits('onConversationClick', filterConversationList[0]) + } + } +} + +/** 删除聊天对话 */ +const deleteChatConversation = async (conversation: ChatConversationVO) => { + try { + // 删除的二次确认 + await message.delConfirm(`是否确认删除对话 - ${conversation.title}?`) + // 发起删除 + await ChatConversationApi.deleteChatConversationMy(conversation.id) + message.success('对话已删除') + // 刷新列表 + await getChatConversationList() + // 回调 + emits('onConversationDelete', conversation) + } catch {} +} + +/** 清空对话 */ +const handleClearConversation = async () => { + try { + await message.confirm('确认后对话会全部清空,置顶的对话除外。') + await ChatConversationApi.deleteChatConversationMyByUnpinned() + ElMessage({ + message: '操作成功!', + type: 'success' + }) + // 清空 对话 和 对话内容 + activeConversationId.value = null + // 获取 对话列表 + await getChatConversationList() + // 回调 方法 + emits('onConversationClear') + } catch {} +} + +/** 对话置顶 */ +const handleTop = async (conversation: ChatConversationVO) => { + // 更新对话置顶 + conversation.pinned = !conversation.pinned + await ChatConversationApi.updateChatConversationMy(conversation) + // 刷新对话 + await getChatConversationList() +} + +// ============ 角色仓库 ============ + +/** 角色仓库抽屉 */ +const roleRepositoryOpen = ref<boolean>(false) // 角色仓库是否打开 +const handleRoleRepository = async () => { + roleRepositoryOpen.value = !roleRepositoryOpen.value +} + +/** 监听选中的对话 */ +const { activeId } = toRefs(props) +watch(activeId, async (newValue, oldValue) => { + activeConversationId.value = newValue as string +}) + +// 定义 public 方法 +defineExpose({ createConversation }) + +/** 初始化 */ +onMounted(async () => { + // 获取 对话列表 + await getChatConversationList() + // 默认选中 + if (props.activeId) { + activeConversationId.value = props.activeId + } else { + // 首次默认选中第一个 + if (conversationList.value.length) { + activeConversationId.value = conversationList.value[0].id + // 回调 onConversationClick + await emits('onConversationClick', conversationList.value[0]) + } + } +}) +</script> + +<style scoped lang="scss"> +.conversation-container { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 10px 10px 0; + overflow: hidden; + + .btn-new-conversation { + padding: 18px 0; + } + + .search-input { + margin-top: 20px; + } + + .conversation-list { + overflow: auto; + height: 100%; + + .classify-title { + padding-top: 10px; + } + + .conversation-item { + margin-top: 5px; + } + + .conversation { + display: flex; + flex-direction: row; + justify-content: space-between; + flex: 1; + padding: 0 5px; + cursor: pointer; + border-radius: 5px; + align-items: center; + line-height: 30px; + + &.active { + background-color: #e6e6e6; + + .button { + display: inline-block; + } + } + + .title-wrapper { + display: flex; + flex-direction: row; + align-items: center; + } + + .title { + padding: 2px 10px; + max-width: 220px; + font-size: 14px; + font-weight: 400; + color: rgba(0, 0, 0, 0.77); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .avatar { + width: 25px; + height: 25px; + border-radius: 5px; + display: flex; + flex-direction: row; + justify-items: center; + } + + // 对话编辑、删除 + .button-wrapper { + right: 2px; + display: flex; + flex-direction: row; + justify-items: center; + color: #606266; + + .btn { + margin: 0; + } + } + } + } + + // 角色仓库、清空未设置对话 + .tool-box { + position: absolute; + bottom: 0; + left: 0; + right: 0; + //width: 100%; + padding: 0 20px; + background-color: #f4f4f4; + box-shadow: 0 0 1px 1px rgba(228, 228, 228, 0.8); + line-height: 35px; + display: flex; + justify-content: space-between; + align-items: center; + color: var(--el-text-color); + + > div { + display: flex; + align-items: center; + color: #606266; + padding: 0; + margin: 0; + cursor: pointer; + + > span { + margin-left: 5px; + } + } + } +} +</style> diff --git a/src/views/ai/chat/index/components/conversation/ConversationUpdateForm.vue b/src/views/ai/chat/index/components/conversation/ConversationUpdateForm.vue new file mode 100644 index 0000000..bff094f --- /dev/null +++ b/src/views/ai/chat/index/components/conversation/ConversationUpdateForm.vue @@ -0,0 +1,145 @@ +<template> + <Dialog title="设定" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="130px" + v-loading="formLoading" + > + <el-form-item label="角色设定" prop="systemMessage"> + <el-input + type="textarea" + v-model="formData.systemMessage" + rows="4" + placeholder="请输入角色设定" + /> + </el-form-item> + <el-form-item label="模型" prop="modelId"> + <el-select v-model="formData.modelId" placeholder="请选择模型"> + <el-option + v-for="chatModel in chatModelList" + :key="chatModel.id" + :label="chatModel.name" + :value="chatModel.id" + /> + </el-select> + </el-form-item> + <el-form-item label="温度参数" prop="temperature"> + <el-input-number + v-model="formData.temperature" + placeholder="请输入温度参数" + :min="0" + :max="2" + :precision="2" + /> + </el-form-item> + <el-form-item label="回复数 Token 数" prop="maxTokens"> + <el-input-number + v-model="formData.maxTokens" + placeholder="请输入回复数 Token 数" + :min="0" + :max="4096" + /> + </el-form-item> + <el-form-item label="上下文数量" prop="maxContexts"> + <el-input-number + v-model="formData.maxContexts" + placeholder="请输入上下文数量" + :min="0" + :max="20" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { CommonStatusEnum } from '@/utils/constants' +import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel' +import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' + +/** AI 聊天对话的更新表单 */ +defineOptions({ name: 'ChatConversationUpdateForm' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, + systemMessage: undefined, + modelId: undefined, + temperature: undefined, + maxTokens: undefined, + maxContexts: undefined +}) +const formRules = reactive({ + modelId: [{ required: true, message: '模型不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }], + temperature: [{ required: true, message: '温度参数不能为空', trigger: 'blur' }], + maxTokens: [{ required: true, message: '回复数 Token 数不能为空', trigger: 'blur' }], + maxContexts: [{ required: true, message: '上下文数量不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表 + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = await ChatConversationApi.getChatConversationMy(id) + formData.value = Object.keys(formData.value).reduce((obj, key) => { + if (data.hasOwnProperty(key)) { + obj[key] = data[key] + } + return obj + }, {}) + } finally { + formLoading.value = false + } + } + // 获得下拉数据 + chatModelList.value = await ChatModelApi.getChatModelSimpleList(CommonStatusEnum.ENABLE) +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ChatConversationVO + await ChatConversationApi.updateChatConversationMy(data) + message.success('对话配置已更新') + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + systemMessage: undefined, + modelId: undefined, + temperature: undefined, + maxTokens: undefined, + maxContexts: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/ai/chat/index/components/message/MessageList.vue b/src/views/ai/chat/index/components/message/MessageList.vue new file mode 100644 index 0000000..2cc8407 --- /dev/null +++ b/src/views/ai/chat/index/components/message/MessageList.vue @@ -0,0 +1,282 @@ +<template> + <div ref="messageContainer" class="h-100% overflow-y-auto relative"> + <div class="chat-list" v-for="(item, index) in list" :key="index"> + <!-- 靠左 message:system、assistant 类型 --> + <div class="left-message message-item" v-if="item.type !== 'user'"> + <div class="avatar"> + <el-avatar :src="roleAvatar" /> + </div> + <div class="message"> + <div> + <el-text class="time">{{ formatDate(item.createTime) }}</el-text> + </div> + <div class="left-text-container" ref="markdownViewRef"> + <MarkdownView class="left-text" :content="item.content" /> + </div> + <div class="left-btns"> + <el-button class="btn-cus" link @click="copyContent(item.content)"> + <img class="btn-image" src="@/assets/ai/copy.svg" /> + </el-button> + <el-button v-if="item.id > 0" class="btn-cus" link @click="onDelete(item.id)"> + <img class="btn-image h-17px" src="@/assets/ai/delete.svg" /> + </el-button> + </div> + </div> + </div> + <!-- 靠右 message:user 类型 --> + <div class="right-message message-item" v-if="item.type === 'user'"> + <div class="avatar"> + <el-avatar :src="userAvatar" /> + </div> + <div class="message"> + <div> + <el-text class="time">{{ formatDate(item.createTime) }}</el-text> + </div> + <div class="right-text-container"> + <div class="right-text">{{ item.content }}</div> + </div> + <div class="right-btns"> + <el-button class="btn-cus" link @click="copyContent(item.content)"> + <img class="btn-image" src="@/assets/ai/copy.svg" /> + </el-button> + <el-button class="btn-cus" link @click="onDelete(item.id)"> + <img class="btn-image h-17px mr-12px" src="@/assets/ai/delete.svg" /> + </el-button> + <el-button class="btn-cus" link @click="onRefresh(item)"> + <el-icon size="17"><RefreshRight /></el-icon> + </el-button> + <el-button class="btn-cus" link @click="onEdit(item)"> + <el-icon size="17"><Edit /></el-icon> + </el-button> + </div> + </div> + </div> + </div> + </div> + <!-- 回到底部 --> + <div v-if="isScrolling" class="to-bottom" @click="handleGoBottom"> + <el-button :icon="ArrowDownBold" circle /> + </div> +</template> +<script setup lang="ts"> +import { PropType } from 'vue' +import { formatDate } from '@/utils/formatTime' +import MarkdownView from '@/components/MarkdownView/index.vue' +import { useClipboard } from '@vueuse/core' +import { ArrowDownBold, Edit, RefreshRight } from '@element-plus/icons-vue' +import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message' +import { ChatConversationVO } from '@/api/ai/chat/conversation' +import { useUserStore } from '@/store/modules/user' +import userAvatarDefaultImg from '@/assets/imgs/avatar.gif' +import roleAvatarDefaultImg from '@/assets/ai/gpt.svg' + +const message = useMessage() // 消息弹窗 +const { copy } = useClipboard() // 初始化 copy 到粘贴板 +const userStore = useUserStore() + +// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方) +const messageContainer: any = ref(null) +const isScrolling = ref(false) //用于判断用户是否在滚动 + +const userAvatar = computed(() => userStore.user.avatar ?? userAvatarDefaultImg) +const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg) + +// 定义 props +const props = defineProps({ + conversation: { + type: Object as PropType<ChatConversationVO>, + required: true + }, + list: { + type: Array as PropType<ChatMessageVO[]>, + required: true + } +}) + +const { list } = toRefs(props) // 消息列表 + +const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义 emits + +// ============ 处理对话滚动 ============== + +/** 滚动到底部 */ +const scrollToBottom = async (isIgnore?: boolean) => { + // 注意要使用 nextTick 以免获取不到 dom + await nextTick() + if (isIgnore || !isScrolling.value) { + messageContainer.value.scrollTop = + messageContainer.value.scrollHeight - messageContainer.value.offsetHeight + } +} + +function handleScroll() { + const scrollContainer = messageContainer.value + const scrollTop = scrollContainer.scrollTop + const scrollHeight = scrollContainer.scrollHeight + const offsetHeight = scrollContainer.offsetHeight + if (scrollTop + offsetHeight < scrollHeight - 100) { + // 用户开始滚动并在最底部之上,取消保持在最底部的效果 + isScrolling.value = true + } else { + // 用户停止滚动并滚动到最底部,开启保持到最底部的效果 + isScrolling.value = false + } +} + +/** 回到底部 */ +const handleGoBottom = async () => { + const scrollContainer = messageContainer.value + scrollContainer.scrollTop = scrollContainer.scrollHeight +} + +/** 回到顶部 */ +const handlerGoTop = async () => { + const scrollContainer = messageContainer.value + scrollContainer.scrollTop = 0 +} + +defineExpose({ scrollToBottom, handlerGoTop }) // 提供方法给 parent 调用 + +// ============ 处理消息操作 ============== + +/** 复制 */ +const copyContent = async (content) => { + await copy(content) + message.success('复制成功!') +} + +/** 删除 */ +const onDelete = async (id) => { + // 删除 message + await ChatMessageApi.deleteChatMessage(id) + message.success('删除成功!') + // 回调 + emits('onDeleteSuccess') +} + +/** 刷新 */ +const onRefresh = async (message: ChatMessageVO) => { + emits('onRefresh', message) +} + +/** 编辑 */ +const onEdit = async (message: ChatMessageVO) => { + emits('onEdit', message) +} + +/** 初始化 */ +onMounted(async () => { + messageContainer.value.addEventListener('scroll', handleScroll) +}) +</script> + +<style scoped lang="scss"> +.message-container { + position: relative; + overflow-y: scroll; +} + +// 中间 +.chat-list { + display: flex; + flex-direction: column; + overflow-y: hidden; + padding: 0 20px; + .message-item { + margin-top: 50px; + } + + .left-message { + display: flex; + flex-direction: row; + } + + .right-message { + display: flex; + flex-direction: row-reverse; + justify-content: flex-start; + } + + .message { + display: flex; + flex-direction: column; + text-align: left; + margin: 0 15px; + + .time { + text-align: left; + line-height: 30px; + } + + .left-text-container { + position: relative; + display: flex; + flex-direction: column; + overflow-wrap: break-word; + background-color: rgba(228, 228, 228, 0.8); + box-shadow: 0 0 0 1px rgba(228, 228, 228, 0.8); + border-radius: 10px; + padding: 10px 10px 5px 10px; + + .left-text { + color: #393939; + font-size: 0.95rem; + } + } + + .right-text-container { + display: flex; + flex-direction: row-reverse; + + .right-text { + font-size: 0.95rem; + color: #fff; + display: inline; + background-color: #267fff; + box-shadow: 0 0 0 1px #267fff; + border-radius: 10px; + padding: 10px; + width: auto; + overflow-wrap: break-word; + white-space: pre-wrap; + } + } + + .left-btns { + display: flex; + flex-direction: row; + margin-top: 8px; + } + + .right-btns { + display: flex; + flex-direction: row-reverse; + margin-top: 8px; + } + } + + // 复制、删除按钮 + .btn-cus { + display: flex; + background-color: transparent; + align-items: center; + + .btn-image { + height: 20px; + } + } + + .btn-cus:hover { + cursor: pointer; + background-color: #f6f6f6; + } +} + +// 回到底部 +.to-bottom { + position: absolute; + z-index: 1000; + bottom: 0; + right: 50%; +} +</style> diff --git a/src/views/ai/chat/index/components/message/MessageListEmpty.vue b/src/views/ai/chat/index/components/message/MessageListEmpty.vue new file mode 100644 index 0000000..b042fd6 --- /dev/null +++ b/src/views/ai/chat/index/components/message/MessageListEmpty.vue @@ -0,0 +1,83 @@ +<!-- 消息列表为空时,展示 prompt 列表 --> +<template> + <div class="chat-empty"> + <!-- title --> + <div class="center-container"> + <div class="title">芋道 AI</div> + <div class="role-list"> + <div + class="role-item" + v-for="prompt in promptList" + :key="prompt.prompt" + @click="handlerPromptClick(prompt)" + > + {{ prompt.prompt }} + </div> + </div> + </div> + </div> +</template> +<script setup lang="ts"> +const promptList = [ + { + prompt: '今天气怎么样?' + }, + { + prompt: '写一首好听的诗歌?' + } +] // prompt 列表 + +const emits = defineEmits(['onPrompt']) + +/** 选中 prompt 点击 */ +const handlerPromptClick = async ({ prompt }) => { + emits('onPrompt', prompt) +} +</script> +<style scoped lang="scss"> +.chat-empty { + position: relative; + display: flex; + flex-direction: row; + justify-content: center; + width: 100%; + height: 100%; + + .center-container { + display: flex; + flex-direction: column; + justify-content: center; + + .title { + font-size: 28px; + font-weight: bold; + text-align: center; + } + + .role-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: center; + width: 460px; + margin-top: 20px; + + .role-item { + display: flex; + justify-content: center; + width: 180px; + line-height: 50px; + border: 1px solid #e4e4e4; + border-radius: 10px; + margin: 10px; + cursor: pointer; + } + + .role-item:hover { + background-color: rgba(243, 243, 243, 0.73); + } + } + } +} +</style> diff --git a/src/views/ai/chat/index/components/message/MessageLoading.vue b/src/views/ai/chat/index/components/message/MessageLoading.vue new file mode 100644 index 0000000..f3198cb --- /dev/null +++ b/src/views/ai/chat/index/components/message/MessageLoading.vue @@ -0,0 +1,15 @@ +<!-- message 加载页面 --> +<template> + <div class="message-loading" > + <el-skeleton animated /> + </div> +</template> + +<script setup lang="ts"> + +</script> +<style scoped lang="scss"> +.message-loading { + padding: 30px 30px; +} +</style> diff --git a/src/views/ai/chat/index/components/message/MessageNewConversation.vue b/src/views/ai/chat/index/components/message/MessageNewConversation.vue new file mode 100644 index 0000000..40c3107 --- /dev/null +++ b/src/views/ai/chat/index/components/message/MessageNewConversation.vue @@ -0,0 +1,46 @@ +<!-- 无聊天对话时,在 message 区域,可以新增对话 --> +<template> + <div class="new-chat"> + <div class="box-center"> + <div class="tip">点击下方按钮,开始你的对话吧</div> + <div class="btns"> + <el-button type="primary" round @click="handlerNewChat">新建对话</el-button> + </div> + </div> + </div> +</template> +<script setup lang="ts"> +const emits = defineEmits(['onNewConversation']) + +/** 新建 conversation 聊天对话 */ +const handlerNewChat = () => { + emits('onNewConversation') +} +</script> +<style scoped lang="scss"> +.new-chat { + display: flex; + flex-direction: row; + justify-content: center; + width: 100%; + height: 100%; + + .box-center { + display: flex; + flex-direction: column; + justify-content: center; + + .tip { + font-size: 14px; + color: #858585; + } + + .btns { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: 20px; + } + } +} +</style> diff --git a/src/views/ai/chat/index/components/role/RoleCategoryList.vue b/src/views/ai/chat/index/components/role/RoleCategoryList.vue new file mode 100644 index 0000000..c02126d --- /dev/null +++ b/src/views/ai/chat/index/components/role/RoleCategoryList.vue @@ -0,0 +1,53 @@ +<template> + <div class="category-list"> + <div class="category" v-for="category in categoryList" :key="category"> + <el-button + plain + round + size="small" + :type="category === active ? 'primary' : ''" + @click="handleCategoryClick(category)" + > + {{ category }} + </el-button> + </div> + </div> +</template> +<script setup lang="ts"> +import { PropType } from 'vue' + +// 定义属性 +defineProps({ + categoryList: { + type: Array as PropType<string[]>, + required: true + }, + active: { + type: String, + required: false, + default: '全部' + } +}) + +// 定义回调 +const emits = defineEmits(['onCategoryClick']) + +/** 处理分类点击事件 */ +const handleCategoryClick = async (category: string) => { + emits('onCategoryClick', category) +} +</script> +<style scoped lang="scss"> +.category-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + + .category { + display: flex; + flex-direction: row; + margin-right: 10px; + } +} +</style> diff --git a/src/views/ai/chat/index/components/role/RoleHeader.vue b/src/views/ai/chat/index/components/role/RoleHeader.vue new file mode 100644 index 0000000..17b1693 --- /dev/null +++ b/src/views/ai/chat/index/components/role/RoleHeader.vue @@ -0,0 +1,48 @@ +<!-- header --> +<template> + <el-header class="chat-header"> + <div class="title"> + {{ title }} + </div> + <div class="title-right"> + <slot></slot> + </div> + </el-header> +</template> + +<script setup lang="ts"> +// 设置组件属性 +defineProps({ + title: { + type: String, + required: true + } +}) +</script> + +<style scoped lang="scss"> +.chat-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 0 10px; + white-space: nowrap; + text-overflow: ellipsis; + background-color: #ececec; + width: 100%; + + .title { + font-size: 20px; + font-weight: bold; + overflow: hidden; + color: #3e3e3e; + max-width: 220px; + } + + .title-right { + display: flex; + flex-direction: row; + } +} +</style> diff --git a/src/views/ai/chat/index/components/role/RoleList.vue b/src/views/ai/chat/index/components/role/RoleList.vue new file mode 100644 index 0000000..b148b22 --- /dev/null +++ b/src/views/ai/chat/index/components/role/RoleList.vue @@ -0,0 +1,174 @@ +<template> + <div class="card-list" ref="tabsRef" @scroll="handleTabsScroll"> + <div class="card-item" v-for="role in roleList" :key="role.id"> + <el-card class="card" body-class="card-body"> + <!-- 更多操作 --> + <div class="more-container" v-if="showMore"> + <el-dropdown @command="handleMoreClick"> + <span class="el-dropdown-link"> + <el-button type="text"> + <el-icon><More /></el-icon> + </el-button> + </span> + <template #dropdown> + <el-dropdown-menu> + <el-dropdown-item :command="['edit', role]"> + <Icon icon="ep:edit" color="#787878" />编辑 + </el-dropdown-item> + <el-dropdown-item :command="['delete', role]" style="color: red"> + <Icon icon="ep:delete" color="red" />删除 + </el-dropdown-item> + </el-dropdown-menu> + </template> + </el-dropdown> + </div> + <!-- 角色信息 --> + <div> + <img class="avatar" :src="role.avatar" /> + </div> + <div class="right-container"> + <div class="content-container"> + <div class="title">{{ role.name }}</div> + <div class="description">{{ role.description }}</div> + </div> + <div class="btn-container"> + <el-button type="primary" size="small" @click="handleUseClick(role)">使用</el-button> + </div> + </div> + </el-card> + </div> + </div> +</template> + +<script setup lang="ts"> +import {ChatRoleVO} from '@/api/ai/model/chatRole' +import {PropType, ref} from 'vue' +import {More} from '@element-plus/icons-vue' + +const tabsRef = ref<any>() // tabs ref + +// 定义属性 +const props = defineProps({ + loading: { + type: Boolean, + required: true + }, + roleList: { + type: Array as PropType<ChatRoleVO[]>, + required: true + }, + showMore: { + type: Boolean, + required: false, + default: false + } +}) + +// 定义钩子 +const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage']) + +/** 操作:编辑、删除 */ +const handleMoreClick = async (data) => { + const type = data[0] + const role = data[1] + if (type === 'delete') { + emits('onDelete', role) + } else { + emits('onEdit', role) + } +} + +/** 选中 */ +const handleUseClick = (role) => { + emits('onUse', role) +} + +/** 滚动 */ +const handleTabsScroll = async () => { + if (tabsRef.value) { + const { scrollTop, scrollHeight, clientHeight } = tabsRef.value + if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) { + await emits('onPage') + } + } +} +</script> + +<style lang="scss"> +// 重写 card 组件 body 样式 +.card-body { + max-width: 240px; + width: 240px; + padding: 15px 15px 10px 15px; + + display: flex; + flex-direction: row; + justify-content: flex-start; + position: relative; +} +</style> +<style scoped lang="scss"> +// 卡片列表 +.card-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + position: relative; + height: 100%; + overflow: auto; + padding: 0px 25px; + padding-bottom: 140px; + align-items: start; + align-content: flex-start; + justify-content: start; + + .card { + display: inline-block; + margin-right: 20px; + border-radius: 10px; + margin-bottom: 20px; + position: relative; + + .more-container { + position: absolute; + top: 0; + right: 12px; + } + + .avatar { + width: 40px; + height: 40px; + border-radius: 10px; + overflow: hidden; + } + + .right-container { + margin-left: 10px; + width: 100%; + //height: 100px; + + .content-container { + height: 85px; + + .title { + font-size: 18px; + font-weight: bold; + color: #3e3e3e; + } + + .description { + margin-top: 10px; + font-size: 14px; + color: #6a6a6a; + } + } + + .btn-container { + display: flex; + flex-direction: row-reverse; + margin-top: 2px; + } + } + } +} +</style> diff --git a/src/views/ai/chat/index/components/role/RoleRepository.vue b/src/views/ai/chat/index/components/role/RoleRepository.vue new file mode 100644 index 0000000..246dcb4 --- /dev/null +++ b/src/views/ai/chat/index/components/role/RoleRepository.vue @@ -0,0 +1,289 @@ +<!-- chat 角色仓库 --> +<template> + <el-container class="role-container"> + <ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" /> + <!-- header --> + <RoleHeader title="角色仓库" class="relative" /> + <!-- main --> + <el-main class="role-main"> + <div class="search-container"> + <!-- 搜索按钮 --> + <el-input + :loading="loading" + v-model="search" + class="search-input" + size="default" + placeholder="请输入搜索的内容" + :suffix-icon="Search" + @change="getActiveTabsRole" + /> + <el-button + v-if="activeTab == 'my-role'" + type="primary" + @click="handlerAddRole" + class="ml-20px" + > + <Icon icon="ep:user" style="margin-right: 5px;" /> + 添加角色 + </el-button> + </div> + <!-- tabs --> + <el-tabs v-model="activeTab" class="tabs" @tab-click="handleTabsClick"> + <el-tab-pane class="role-pane" label="我的角色" name="my-role"> + <RoleList + :loading="loading" + :role-list="myRoleList" + :show-more="true" + @on-delete="handlerCardDelete" + @on-edit="handlerCardEdit" + @on-use="handlerCardUse" + @on-page="handlerCardPage('my')" + class="mt-20px" + /> + </el-tab-pane> + <el-tab-pane label="公共角色" name="public-role"> + <RoleCategoryList + class="role-category-list" + :category-list="categoryList" + :active="activeCategory" + @on-category-click="handlerCategoryClick" + /> + <RoleList + :role-list="publicRoleList" + @on-delete="handlerCardDelete" + @on-edit="handlerCardEdit" + @on-use="handlerCardUse" + @on-page="handlerCardPage('public')" + class="mt-20px" + loading + /> + </el-tab-pane> + </el-tabs> + </el-main> + </el-container> +</template> + +<script setup lang="ts"> +import {ref} from 'vue' +import RoleHeader from './RoleHeader.vue' +import RoleList from './RoleList.vue' +import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue' +import RoleCategoryList from './RoleCategoryList.vue' +import {ChatRoleApi, ChatRolePageReqVO, ChatRoleVO} from '@/api/ai/model/chatRole' +import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation' +import {Search} from '@element-plus/icons-vue' +import {TabsPaneContext} from 'element-plus' + +const router = useRouter() // 路由对象 + +// 属性定义 +const loading = ref<boolean>(false) // 加载中 +const activeTab = ref<string>('my-role') // 选中的角色 Tab +const search = ref<string>('') // 加载中 +const myRoleParams = reactive({ + pageNo: 1, + pageSize: 50 +}) +const myRoleList = ref<ChatRoleVO[]>([]) // my 分页大小 +const publicRoleParams = reactive({ + pageNo: 1, + pageSize: 50 +}) +const publicRoleList = ref<ChatRoleVO[]>([]) // public 分页大小 +const activeCategory = ref<string>('全部') // 选择中的分类 +const categoryList = ref<string[]>([]) // 角色分类类别 + +/** tabs 点击 */ +const handleTabsClick = async (tab: TabsPaneContext) => { + // 设置切换状态 + activeTab.value = tab.paneName + '' + // 切换的时候重新加载数据 + await getActiveTabsRole() +} + +/** 获取 my role 我的角色 */ +const getMyRole = async (append?: boolean) => { + const params: ChatRolePageReqVO = { + ...myRoleParams, + name: search.value, + publicStatus: false + } + const { list } = await ChatRoleApi.getMyPage(params) + if (append) { + myRoleList.value.push.apply(myRoleList.value, list) + } else { + myRoleList.value = list + } +} + +/** 获取 public role 公共角色 */ +const getPublicRole = async (append?: boolean) => { + const params: ChatRolePageReqVO = { + ...publicRoleParams, + category: activeCategory.value === '全部' ? '' : activeCategory.value, + name: search.value, + publicStatus: true + } + const { total, list } = await ChatRoleApi.getMyPage(params) + if (append) { + publicRoleList.value.push.apply(publicRoleList.value, list) + } else { + publicRoleList.value = list + } +} + +/** 获取选中的 tabs 角色 */ +const getActiveTabsRole = async () => { + if (activeTab.value === 'my-role') { + myRoleParams.pageNo = 1 + await getMyRole() + } else { + publicRoleParams.pageNo = 1 + await getPublicRole() + } +} + +/** 获取角色分类列表 */ +const getRoleCategoryList = async () => { + categoryList.value = ['全部', ...(await ChatRoleApi.getCategoryList())] +} + +/** 处理分类点击 */ +const handlerCategoryClick = async (category: string) => { + // 切换选择的分类 + activeCategory.value = category + // 筛选 + await getActiveTabsRole() +} + +/** 添加/修改操作 */ +const formRef = ref() +const handlerAddRole = async () => { + formRef.value.open('my-create', null, '添加角色') +} +/** 编辑角色 */ +const handlerCardEdit = async (role) => { + formRef.value.open('my-update', role.id, '编辑角色') +} + +/** 添加角色成功 */ +const handlerAddRoleSuccess = async (e) => { + // 刷新数据 + await getActiveTabsRole() +} + +/** 删除角色 */ +const handlerCardDelete = async (role) => { + await ChatRoleApi.deleteMy(role.id) + // 刷新数据 + await getActiveTabsRole() +} + +/** 角色分页:获取下一页 */ +const handlerCardPage = async (type) => { + try { + loading.value = true + if (type === 'public') { + publicRoleParams.pageNo++ + await getPublicRole(true) + } else { + myRoleParams.pageNo++ + await getMyRole(true) + } + } finally { + loading.value = false + } +} + +/** 选择 card 角色:新建聊天对话 */ +const handlerCardUse = async (role) => { + // 1. 创建对话 + const data: ChatConversationVO = { + roleId: role.id + } as unknown as ChatConversationVO + const conversationId = await ChatConversationApi.createChatConversationMy(data) + + // 2. 跳转页面 + await router.push({ + name: 'AiChat', + query: { + conversationId: conversationId + } + }) +} + +/** 初始化 **/ +onMounted(async () => { + // 获取分类 + await getRoleCategoryList() + // 获取 role 数据 + await getActiveTabsRole() +}) +</script> +<!-- 覆盖 element ui css --> +<style lang="scss"> +.el-tabs__content { + position: relative; + height: 100%; + overflow: hidden; +} +.el-tabs__nav-scroll { + margin: 10px 20px; +} +</style> +<!-- 样式 --> +<style scoped lang="scss"> +// 跟容器 +.role-container { + position: absolute; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: #ffffff; + overflow: hidden; + display: flex; + flex-direction: column; + + .role-main { + flex: 1; + overflow: hidden; + margin: 0; + padding: 0; + position: relative; + + .search-container { + margin: 20px 20px 0px 20px; + position: absolute; + right: 0; + top: -5px; + z-index: 100; + } + + .search-input { + width: 240px; + } + + .tabs { + position: relative; + height: 100%; + + .role-category-list { + margin: 0 27px; + } + } + + .role-pane { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + position: relative; + } + } +} +</style> diff --git a/src/views/ai/chat/index/index.vue b/src/views/ai/chat/index/index.vue new file mode 100644 index 0000000..28f1d65 --- /dev/null +++ b/src/views/ai/chat/index/index.vue @@ -0,0 +1,772 @@ +<template> + <el-container class="ai-layout"> + <!-- 左侧:对话列表 --> + <ConversationList + :active-id="activeConversationId" + ref="conversationListRef" + @on-conversation-create="handleConversationCreateSuccess" + @on-conversation-click="handleConversationClick" + @on-conversation-clear="handleConversationClear" + @on-conversation-delete="handlerConversationDelete" + /> + <!-- 右侧:对话详情 --> + <el-container class="detail-container"> + <el-header class="header"> + <div class="title"> + {{ activeConversation?.title ? activeConversation?.title : '对话' }} + <span v-if="activeMessageList.length">({{ activeMessageList.length }})</span> + </div> + <div class="btns" v-if="activeConversation"> + <el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm"> + <span v-html="activeConversation?.modelName"></span> + <Icon icon="ep:setting" class="ml-10px" /> + </el-button> + <el-button size="small" class="btn" @click="handlerMessageClear"> + <Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" /> + </el-button> + <el-button size="small" class="btn"> + <Icon icon="ep:download" color="#787878" /> + </el-button> + <el-button size="small" class="btn" @click="handleGoTopMessage"> + <Icon icon="ep:top" color="#787878" /> + </el-button> + </div> + </el-header> + + <!-- main:消息列表 --> + <el-main class="main-container"> + <div> + <div class="message-container"> + <!-- 情况一:消息加载中 --> + <MessageLoading v-if="activeMessageListLoading" /> + <!-- 情况二:无聊天对话时 --> + <MessageNewConversation + v-if="!activeConversation" + @on-new-conversation="handleConversationCreate" + /> + <!-- 情况三:消息列表为空 --> + <MessageListEmpty + v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation" + @on-prompt="doSendMessage" + /> + <!-- 情况四:消息列表不为空 --> + <MessageList + v-if="!activeMessageListLoading && messageList.length > 0" + ref="messageRef" + :conversation="activeConversation" + :list="messageList" + @on-delete-success="handleMessageDelete" + @on-edit="handleMessageEdit" + @on-refresh="handleMessageRefresh" + /> + </div> + </div> + </el-main> + + <!-- 底部 --> + <el-footer class="footer-container"> + <form class="prompt-from"> + <textarea + class="prompt-input" + v-model="prompt" + @keydown="handleSendByKeydown" + @input="handlePromptInput" + @compositionstart="onCompositionstart" + @compositionend="onCompositionend" + placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)" + ></textarea> + <div class="prompt-btns"> + <div> + <el-switch v-model="enableContext" /> + <span class="ml-5px text-14px text-#8f8f8f">上下文</span> + </div> + <el-button + type="primary" + size="default" + @click="handleSendByButton" + :loading="conversationInProgress" + v-if="conversationInProgress == false" + > + {{ conversationInProgress ? '进行中' : '发送' }} + </el-button> + <el-button + type="danger" + size="default" + @click="stopStream()" + v-if="conversationInProgress == true" + > + 停止 + </el-button> + </div> + </form> + </el-footer> + </el-container> + + <!-- 更新对话 Form --> + <ConversationUpdateForm + ref="conversationUpdateFormRef" + @success="handleConversationUpdateSuccess" + /> + </el-container> +</template> + +<script setup lang="ts"> +import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message' +import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' +import ConversationList from './components/conversation/ConversationList.vue' +import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue' +import MessageList from './components/message/MessageList.vue' +import MessageListEmpty from './components/message/MessageListEmpty.vue' +import MessageLoading from './components/message/MessageLoading.vue' +import MessageNewConversation from './components/message/MessageNewConversation.vue' + +/** AI 聊天对话 列表 */ +defineOptions({ name: 'AiChat' }) + +const route = useRoute() // 路由 +const message = useMessage() // 消息弹窗 + +// 聊天对话 +const conversationListRef = ref() +const activeConversationId = ref<number | null>(null) // 选中的对话编号 +const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation +const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作 + +// 消息列表 +const messageRef = ref() +const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表 +const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中 +const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中 +// 消息滚动 +const textSpeed = ref<number>(50) // Typing speed in milliseconds +const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds + +// 发送消息输入框 +const isComposing = ref(false) // 判断用户是否在输入 +const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话) +const inputTimeout = ref<any>() // 处理输入中回车的定时器 +const prompt = ref<string>() // prompt +const enableContext = ref<boolean>(true) // 是否开启上下文 +// 接收 Stream 消息 +const receiveMessageFullText = ref('') +const receiveMessageDisplayedText = ref('') + +// =========== 【聊天对话】相关 =========== + +/** 获取对话信息 */ +const getConversation = async (id: number | null) => { + if (!id) { + return + } + const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id) + if (!conversation) { + return + } + activeConversation.value = conversation + activeConversationId.value = conversation.id +} + +/** + * 点击某个对话 + * + * @param conversation 选中的对话 + * @return 是否切换成功 + */ +const handleConversationClick = async (conversation: ChatConversationVO) => { + // 对话进行中,不允许切换 + if (conversationInProgress.value) { + message.alert('对话中,不允许切换!') + return false + } + + // 更新选中的对话 id + activeConversationId.value = conversation.id + activeConversation.value = conversation + // 刷新 message 列表 + await getMessageList() + // 滚动底部 + scrollToBottom(true) + // 清空输入框 + prompt.value = '' + return true +} + +/** 删除某个对话*/ +const handlerConversationDelete = async (delConversation: ChatConversationVO) => { + // 删除的对话如果是当前选中的,那么就重置 + if (activeConversationId.value === delConversation.id) { + await handleConversationClear() + } +} +/** 清空选中的对话 */ +const handleConversationClear = async () => { + // 对话进行中,不允许切换 + if (conversationInProgress.value) { + message.alert('对话中,不允许切换!') + return false + } + activeConversationId.value = null + activeConversation.value = null + activeMessageList.value = [] +} + +/** 修改聊天对话 */ +const conversationUpdateFormRef = ref() +const openChatConversationUpdateForm = async () => { + conversationUpdateFormRef.value.open(activeConversationId.value) +} +const handleConversationUpdateSuccess = async () => { + // 对话更新成功,刷新最新信息 + await getConversation(activeConversationId.value) +} + +/** 处理聊天对话的创建成功 */ +const handleConversationCreate = async () => { + // 创建对话 + await conversationListRef.value.createConversation() +} +/** 处理聊天对话的创建成功 */ +const handleConversationCreateSuccess = async () => { + // 创建新的对话,清空输入框 + prompt.value = '' +} + +// =========== 【消息列表】相关 =========== + +/** 获取消息 message 列表 */ +const getMessageList = async () => { + try { + if (activeConversationId.value === null) { + return + } + // Timer 定时器,如果加载速度很快,就不进入加载中 + activeMessageListLoadingTimer.value = setTimeout(() => { + activeMessageListLoading.value = true + }, 60) + + // 获取消息列表 + activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId( + activeConversationId.value + ) + + // 滚动到最下面 + await nextTick() + await scrollToBottom() + } finally { + // time 定时器,如果加载速度很快,就不进入加载中 + if (activeMessageListLoadingTimer.value) { + clearTimeout(activeMessageListLoadingTimer.value) + } + // 加载结束 + activeMessageListLoading.value = false + } +} + +/** + * 消息列表 + * + * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去 + */ +const messageList = computed(() => { + if (activeMessageList.value.length > 0) { + return activeMessageList.value + } + // 没有消息时,如果有 systemMessage 则展示它 + if (activeConversation.value?.systemMessage) { + return [ + { + id: 0, + type: 'system', + content: activeConversation.value.systemMessage + } + ] + } + return [] +}) + +/** 处理删除 message 消息 */ +const handleMessageDelete = () => { + if (conversationInProgress.value) { + message.alert('回答中,不能删除!') + return + } + // 刷新 message 列表 + getMessageList() +} + +/** 处理 message 清空 */ +const handlerMessageClear = async () => { + if (!activeConversationId.value) { + return + } + try { + // 确认提示 + await message.delConfirm('确认清空对话消息?') + // 清空对话 + await ChatMessageApi.deleteByConversationId(activeConversationId.value) + // 刷新 message 列表 + activeMessageList.value = [] + } catch {} +} + +/** 回到 message 列表的顶部 */ +const handleGoTopMessage = () => { + messageRef.value.handlerGoTop() +} + +// =========== 【发送消息】相关 =========== + +/** 处理来自 keydown 的发送消息 */ +const handleSendByKeydown = async (event) => { + // 判断用户是否在输入 + if (isComposing.value) { + return + } + // 进行中不允许发送 + if (conversationInProgress.value) { + return + } + const content = prompt.value?.trim() as string + if (event.key === 'Enter') { + if (event.shiftKey) { + // 插入换行 + prompt.value += '\r\n' + event.preventDefault() // 防止默认的换行行为 + } else { + // 发送消息 + await doSendMessage(content) + event.preventDefault() // 防止默认的提交行为 + } + } +} + +/** 处理来自【发送】按钮的发送消息 */ +const handleSendByButton = () => { + doSendMessage(prompt.value?.trim() as string) +} + +/** 处理 prompt 输入变化 */ +const handlePromptInput = (event) => { + // 非输入法 输入设置为 true + if (!isComposing.value) { + // 回车 event data 是 null + if (event.data == null) { + return + } + isComposing.value = true + } + // 清理定时器 + if (inputTimeout.value) { + clearTimeout(inputTimeout.value) + } + // 重置定时器 + inputTimeout.value = setTimeout(() => { + isComposing.value = false + }, 400) +} +// TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑 +const onCompositionstart = () => { + isComposing.value = true +} +const onCompositionend = () => { + // console.log('输入结束...') + setTimeout(() => { + isComposing.value = false + }, 200) +} + +/** 真正执行【发送】消息操作 */ +const doSendMessage = async (content: string) => { + // 校验 + if (content.length < 1) { + message.error('发送失败,原因:内容为空!') + return + } + if (activeConversationId.value == null) { + message.error('还没创建对话,不能发送!') + return + } + // 清空输入框 + prompt.value = '' + // 执行发送 + await doSendMessageStream({ + conversationId: activeConversationId.value, + content: content + } as ChatMessageVO) +} + +/** 真正执行【发送】消息操作 */ +const doSendMessageStream = async (userMessage: ChatMessageVO) => { + // 创建 AbortController 实例,以便中止请求 + conversationInAbortController.value = new AbortController() + // 标记对话进行中 + conversationInProgress.value = true + // 设置为空 + receiveMessageFullText.value = '' + + try { + // 1.1 先添加两个假数据,等 stream 返回再替换 + activeMessageList.value.push({ + id: -1, + conversationId: activeConversationId.value, + type: 'user', + content: userMessage.content, + createTime: new Date() + } as ChatMessageVO) + activeMessageList.value.push({ + id: -2, + conversationId: activeConversationId.value, + type: 'assistant', + content: '思考中...', + createTime: new Date() + } as ChatMessageVO) + // 1.2 滚动到最下面 + await nextTick() + await scrollToBottom() // 底部 + // 1.3 开始滚动 + textRoll() + + // 2. 发送 event stream + let isFirstChunk = true // 是否是第一个 chunk 消息段 + await ChatMessageApi.sendChatMessageStream( + userMessage.conversationId, + userMessage.content, + conversationInAbortController.value, + enableContext.value, + async (res) => { + const { code, data, msg } = JSON.parse(res.data) + if (code !== 0) { + message.alert(`对话异常! ${msg}`) + return + } + + // 如果内容为空,就不处理。 + if (data.receive.content === '') { + return + } + // 首次返回需要添加一个 message 到页面,后面的都是更新 + if (isFirstChunk) { + isFirstChunk = false + // 弹出两个假数据 + activeMessageList.value.pop() + activeMessageList.value.pop() + // 更新返回的数据 + activeMessageList.value.push(data.send) + activeMessageList.value.push(data.receive) + } + // debugger + receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content + // 滚动到最下面 + await scrollToBottom() + }, + (error) => { + message.alert(`对话异常! ${error}`) + stopStream() + }, + () => { + stopStream() + } + ) + } catch {} +} + +/** 停止 stream 流式调用 */ +const stopStream = async () => { + // tip:如果 stream 进行中的 message,就需要调用 controller 结束 + if (conversationInAbortController.value) { + conversationInAbortController.value.abort() + } + // 设置为 false + conversationInProgress.value = false +} + +/** 编辑 message:设置为 prompt,可以再次编辑 */ +const handleMessageEdit = (message: ChatMessageVO) => { + prompt.value = message.content +} + +/** 刷新 message:基于指定消息,再次发起对话 */ +const handleMessageRefresh = (message: ChatMessageVO) => { + doSendMessage(message.content) +} + +// ============== 【消息滚动】相关 ============= + +/** 滚动到 message 底部 */ +const scrollToBottom = async (isIgnore?: boolean) => { + await nextTick() + if (messageRef.value) { + messageRef.value.scrollToBottom(isIgnore) + } +} + +/** 自提滚动效果 */ +const textRoll = async () => { + let index = 0 + try { + // 只能执行一次 + if (textRoleRunning.value) { + return + } + // 设置状态 + textRoleRunning.value = true + receiveMessageDisplayedText.value = '' + const task = async () => { + // 调整速度 + const diff = + (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10 + if (diff > 5) { + textSpeed.value = 10 + } else if (diff > 2) { + textSpeed.value = 30 + } else if (diff > 1.5) { + textSpeed.value = 50 + } else { + textSpeed.value = 100 + } + // 对话结束,就按 30 的速度 + if (!conversationInProgress.value) { + textSpeed.value = 10 + } + + if (index < receiveMessageFullText.value.length) { + receiveMessageDisplayedText.value += receiveMessageFullText.value[index] + index++ + + // 更新 message + const lastMessage = activeMessageList.value[activeMessageList.value.length - 1] + lastMessage.content = receiveMessageDisplayedText.value + // 滚动到住下面 + await scrollToBottom() + // 重新设置任务 + timer = setTimeout(task, textSpeed.value) + } else { + // 不是对话中可以结束 + if (!conversationInProgress.value) { + textRoleRunning.value = false + clearTimeout(timer) + } else { + // 重新设置任务 + timer = setTimeout(task, textSpeed.value) + } + } + } + let timer = setTimeout(task, textSpeed.value) + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + // 如果有 conversationId 参数,则默认选中 + if (route.query.conversationId) { + const id = route.query.conversationId as unknown as number + activeConversationId.value = id + await getConversation(id) + } + + // 获取列表数据 + activeMessageListLoading.value = true + await getMessageList() +}) +</script> + +<style lang="scss" scoped> +.ai-layout { + position: absolute; + flex: 1; + top: 0; + left: 0; + height: 100%; + width: 100%; +} + +.conversation-container { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 10px 10px 0; + + .btn-new-conversation { + padding: 18px 0; + } + + .search-input { + margin-top: 20px; + } + + .conversation-list { + margin-top: 20px; + + .conversation { + display: flex; + flex-direction: row; + justify-content: space-between; + flex: 1; + padding: 0 5px; + margin-top: 10px; + cursor: pointer; + border-radius: 5px; + align-items: center; + line-height: 30px; + + &.active { + background-color: #e6e6e6; + + .button { + display: inline-block; + } + } + + .title-wrapper { + display: flex; + flex-direction: row; + align-items: center; + } + + .title { + padding: 5px 10px; + max-width: 220px; + font-size: 14px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .avatar { + width: 28px; + height: 28px; + display: flex; + flex-direction: row; + justify-items: center; + } + + // 对话编辑、删除 + .button-wrapper { + right: 2px; + display: flex; + flex-direction: row; + justify-items: center; + color: #606266; + + .el-icon { + margin-right: 5px; + } + } + } + } + + // 角色仓库、清空未设置对话 + .tool-box { + line-height: 35px; + display: flex; + justify-content: space-between; + align-items: center; + color: var(--el-text-color); + + > div { + display: flex; + align-items: center; + color: #606266; + padding: 0; + margin: 0; + cursor: pointer; + + > span { + margin-left: 5px; + } + } + } +} + +// 头部 +.detail-container { + background: #ffffff; + + .header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background: #fbfbfb; + box-shadow: 0 0 0 0 #dcdfe6; + + .title { + font-size: 18px; + font-weight: bold; + } + + .btns { + display: flex; + width: 300px; + flex-direction: row; + justify-content: flex-end; + //justify-content: space-between; + + .btn { + padding: 10px; + } + } + } +} + +// main 容器 +.main-container { + margin: 0; + padding: 0; + position: relative; + height: 100%; + width: 100%; + + .message-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow-y: hidden; + padding: 0; + margin: 0; + } +} + +// 底部 +.footer-container { + display: flex; + flex-direction: column; + height: auto; + margin: 0; + padding: 0; + + .prompt-from { + display: flex; + flex-direction: column; + height: auto; + border: 1px solid #e3e3e3; + border-radius: 10px; + margin: 10px 20px 20px 20px; + padding: 9px 10px; + } + + .prompt-input { + height: 80px; + //box-shadow: none; + border: none; + box-sizing: border-box; + resize: none; + padding: 0 2px; + overflow: auto; + } + + .prompt-input:focus { + outline: none; + } + + .prompt-btns { + display: flex; + justify-content: space-between; + padding-bottom: 0; + padding-top: 5px; + } +} +</style> diff --git a/src/views/ai/chat/manager/ChatConversationList.vue b/src/views/ai/chat/manager/ChatConversationList.vue new file mode 100644 index 0000000..23933f0 --- /dev/null +++ b/src/views/ai/chat/manager/ChatConversationList.vue @@ -0,0 +1,163 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-select + v-model="queryParams.userId" + clearable + placeholder="请输入用户编号" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="聊天编号" prop="title"> + <el-input + v-model="queryParams.title" + placeholder="请输入聊天编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="对话编号" align="center" prop="id" width="180" fixed="left" /> + <el-table-column label="对话标题" align="center" prop="title" width="180" fixed="left" /> + <el-table-column label="用户" align="center" prop="userId" width="180"> + <template #default="scope"> + <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span> + </template> + </el-table-column> + <el-table-column label="角色" align="center" prop="roleName" width="180" /> + <el-table-column label="模型标识" align="center" prop="model" width="180" /> + <el-table-column label="消息数" align="center" prop="messageCount" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="温度参数" align="center" prop="temperature" /> + <el-table-column label="回复 Token 数" align="center" prop="maxTokens" width="120" /> + <el-table-column label="上下文数量" align="center" prop="maxContexts" width="120" /> + <el-table-column label="操作" align="center" width="180" fixed="right"> + <template #default="scope"> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:chat-conversation:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' +import * as UserApi from '@/api/system/user' + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ChatConversationVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: undefined, + title: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ChatConversationApi.getChatConversationPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ChatConversationApi.deleteChatConversationByAdmin(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + getList() + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/ai/chat/manager/ChatMessageList.vue b/src/views/ai/chat/manager/ChatMessageList.vue new file mode 100644 index 0000000..0d84184 --- /dev/null +++ b/src/views/ai/chat/manager/ChatMessageList.vue @@ -0,0 +1,175 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="对话编号" prop="conversationId"> + <el-input + v-model="queryParams.conversationId" + placeholder="请输入对话编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="用户编号" prop="userId"> + <el-select + v-model="queryParams.userId" + clearable + placeholder="请输入用户编号" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="消息编号" align="center" prop="id" width="180" fixed="left" /> + <el-table-column + label="对话编号" + align="center" + prop="conversationId" + width="180" + fixed="left" + /> + <el-table-column label="用户" align="center" prop="userId" width="180"> + <template #default="scope"> + <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span> + </template> + </el-table-column> + <el-table-column label="角色" align="center" prop="roleName" width="180" /> + <el-table-column label="消息类型" align="center" prop="type" width="100" /> + <el-table-column label="模型标识" align="center" prop="model" width="180" /> + <el-table-column label="消息内容" align="center" prop="content" width="300" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="回复消息编号" align="center" prop="replyId" width="180" /> + <el-table-column label="携带上下文" align="center" prop="useContext" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.useContext" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right"> + <template #default="scope"> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:chat-message:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message' +import * as UserApi from '@/api/system/user' +import { DICT_TYPE } from '@/utils/dict' + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ChatMessageVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + conversationId: undefined, + userId: undefined, + content: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ChatMessageApi.getChatMessagePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ChatMessageApi.deleteChatMessageByAdmin(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + getList() + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/ai/chat/manager/index.vue b/src/views/ai/chat/manager/index.vue new file mode 100644 index 0000000..ca2d092 --- /dev/null +++ b/src/views/ai/chat/manager/index.vue @@ -0,0 +1,20 @@ +<template> + <ContentWrap> + <el-tabs> + <el-tab-pane label="对话列表"> + <ChatConversationList /> + </el-tab-pane> + <el-tab-pane label="消息列表"> + <ChatMessageList /> + </el-tab-pane> + </el-tabs> + </ContentWrap> +</template> + +<script setup lang="ts"> +import ChatConversationList from './ChatConversationList.vue' +import ChatMessageList from './ChatMessageList.vue' + +/** AI 聊天对话 列表 */ +defineOptions({ name: 'AiChatManager' }) +</script> diff --git a/src/views/ai/image/index/components/ImageCard.vue b/src/views/ai/image/index/components/ImageCard.vue new file mode 100644 index 0000000..4ba78ca --- /dev/null +++ b/src/views/ai/image/index/components/ImageCard.vue @@ -0,0 +1,162 @@ +<template> + <el-card body-class="" class="image-card"> + <div class="image-operation"> + <div> + <el-button type="primary" text bg v-if="detail?.status === AiImageStatusEnum.IN_PROGRESS"> + 生成中 + </el-button> + <el-button text bg v-else-if="detail?.status === AiImageStatusEnum.SUCCESS"> + 已完成 + </el-button> + <el-button type="danger" text bg v-else-if="detail?.status === AiImageStatusEnum.FAIL"> + 异常 + </el-button> + </div> + <!-- 操作区 --> + <div> + <el-button + class="btn" + text + :icon="Download" + @click="handleButtonClick('download', detail)" + /> + <el-button + class="btn" + text + :icon="RefreshRight" + @click="handleButtonClick('regeneration', detail)" + /> + <el-button class="btn" text :icon="Delete" @click="handleButtonClick('delete', detail)" /> + <el-button class="btn" text :icon="More" @click="handleButtonClick('more', detail)" /> + </div> + </div> + <div class="image-wrapper" ref="cardImageRef"> + <el-image + class="image" + :src="detail?.picUrl" + :preview-src-list="[detail.picUrl]" + preview-teleported + /> + <div v-if="detail?.status === AiImageStatusEnum.FAIL"> + {{ detail?.errorMessage }} + </div> + </div> + <!-- Midjourney 专属操作 --> + <div class="image-mj-btns"> + <el-button + size="small" + v-for="button in detail?.buttons" + :key="button" + class="min-w-40px ml-0 mr-10px mt-5px" + @click="handleMidjourneyBtnClick(button)" + > + {{ button.label }}{{ button.emoji }} + </el-button> + </div> + </el-card> +</template> +<script setup lang="ts"> +import { Delete, Download, More, RefreshRight } from '@element-plus/icons-vue' +import { ImageVO, ImageMidjourneyButtonsVO } from '@/api/ai/image' +import { PropType } from 'vue' +import { ElLoading, LoadingOptionsResolved } from 'element-plus' +import { AiImageStatusEnum } from '@/views/ai/utils/constants' + +const message = useMessage() // 消息 + +const props = defineProps({ + detail: { + type: Object as PropType<ImageVO>, + require: true + } +}) + +const cardImageRef = ref<any>() // 卡片 image ref +const cardImageLoadingInstance = ref<any>() // 卡片 image ref + +/** 处理点击事件 */ +const handleButtonClick = async (type, detail: ImageVO) => { + emits('onBtnClick', type, detail) +} + +/** 处理 Midjourney 按钮点击事件 */ +const handleMidjourneyBtnClick = async (button: ImageMidjourneyButtonsVO) => { + // 确认窗体 + await message.confirm(`确认操作 "${button.label} ${button.emoji}" ?`) + emits('onMjBtnClick', button, props.detail) +} + +const emits = defineEmits(['onBtnClick', 'onMjBtnClick']) // emits + +/** 监听详情 */ +const { detail } = toRefs(props) +watch(detail, async (newVal, oldVal) => { + await handleLoading(newVal.status as string) +}) + +/** 处理加载状态 */ +const handleLoading = async (status: number) => { + // 情况一:如果是生成中,则设置加载中的 loading + if (status === AiImageStatusEnum.IN_PROGRESS) { + cardImageLoadingInstance.value = ElLoading.service({ + target: cardImageRef.value, + text: '生成中...' + } as LoadingOptionsResolved) + // 情况二:如果已经生成结束,则移除 loading + } else { + if (cardImageLoadingInstance.value) { + cardImageLoadingInstance.value.close() + cardImageLoadingInstance.value = null + } + } +} + +/** 初始化 */ +onMounted(async () => { + await handleLoading(props.detail.status as string) +}) +</script> + +<style scoped lang="scss"> +.image-card { + width: 320px; + height: auto; + border-radius: 10px; + position: relative; + display: flex; + flex-direction: column; + + .image-operation { + display: flex; + flex-direction: row; + justify-content: space-between; + + .btn { + //border: 1px solid red; + padding: 10px; + margin: 0; + } + } + + .image-wrapper { + overflow: hidden; + margin-top: 20px; + height: 280px; + flex: 1; + + .image { + width: 100%; + border-radius: 10px; + } + } + + .image-mj-btns { + margin-top: 5px; + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + } +} +</style> diff --git a/src/views/ai/image/index/components/ImageDetail.vue b/src/views/ai/image/index/components/ImageDetail.vue new file mode 100644 index 0000000..ad15aa8 --- /dev/null +++ b/src/views/ai/image/index/components/ImageDetail.vue @@ -0,0 +1,224 @@ +<template> + <el-drawer + v-model="showDrawer" + title="图片详细" + @close="handleDrawerClose" + custom-class="drawer-class" + > + <!-- 图片 --> + <div class="item"> + <div class="body"> + <el-image + class="image" + :src="detail?.picUrl" + :preview-src-list="[detail.picUrl]" + preview-teleported + /> + </div> + </div> + <!-- 时间 --> + <div class="item"> + <div class="tip">时间</div> + <div class="body"> + <div>提交时间:{{ formatTime(detail.createTime, 'yyyy-MM-dd HH:mm:ss') }}</div> + <div>生成时间:{{ formatTime(detail.finishTime, 'yyyy-MM-dd HH:mm:ss') }}</div> + </div> + </div> + <!-- 模型 --> + <div class="item"> + <div class="tip">模型</div> + <div class="body"> {{ detail.model }}({{ detail.height }}x{{ detail.width }}) </div> + </div> + <!-- 提示词 --> + <div class="item"> + <div class="tip">提示词</div> + <div class="body"> + {{ detail.prompt }} + </div> + </div> + <!-- 地址 --> + <div class="item"> + <div class="tip">图片地址</div> + <div class="body"> + {{ detail.picUrl }} + </div> + </div> + <!-- StableDiffusion 专属区域 --> + <div + class="item" + v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.sampler" + > + <div class="tip">采样方法</div> + <div class="body"> + {{ + StableDiffusionSamplers.find( + (item: ImageModelVO) => item.key === detail?.options?.sampler + )?.name + }} + </div> + </div> + <div + class="item" + v-if=" + detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.clipGuidancePreset + " + > + <div class="tip">CLIP</div> + <div class="body"> + {{ + StableDiffusionClipGuidancePresets.find( + (item: ImageModelVO) => item.key === detail?.options?.clipGuidancePreset + )?.name + }} + </div> + </div> + <div + class="item" + v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.stylePreset" + > + <div class="tip">风格</div> + <div class="body"> + {{ + StableDiffusionStylePresets.find( + (item: ImageModelVO) => item.key === detail?.options?.stylePreset + )?.name + }} + </div> + </div> + <div + class="item" + v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.steps" + > + <div class="tip">迭代步数</div> + <div class="body"> + {{ detail?.options?.steps }} + </div> + </div> + <div + class="item" + v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.scale" + > + <div class="tip">引导系数</div> + <div class="body"> + {{ detail?.options?.scale }} + </div> + </div> + <div + class="item" + v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.seed" + > + <div class="tip">随机因子</div> + <div class="body"> + {{ detail?.options?.seed }} + </div> + </div> + <!-- Dall3 专属区域 --> + <div class="item" v-if="detail.platform === AiPlatformEnum.OPENAI && detail?.options?.style"> + <div class="tip">风格选择</div> + <div class="body"> + {{ Dall3StyleList.find((item: ImageModelVO) => item.key === detail?.options?.style)?.name }} + </div> + </div> + <!-- Midjourney 专属区域 --> + <div + class="item" + v-if="detail.platform === AiPlatformEnum.MIDJOURNEY && detail?.options?.version" + > + <div class="tip">模型版本</div> + <div class="body"> + {{ detail?.options?.version }} + </div> + </div> + <div + class="item" + v-if="detail.platform === AiPlatformEnum.MIDJOURNEY && detail?.options?.referImageUrl" + > + <div class="tip">参考图</div> + <div class="body"> + <el-image :src="detail.options.referImageUrl" /> + </div> + </div> + </el-drawer> +</template> + +<script setup lang="ts"> +import { ImageApi, ImageVO } from '@/api/ai/image' +import { + AiPlatformEnum, + Dall3StyleList, + ImageModelVO, + StableDiffusionClipGuidancePresets, + StableDiffusionSamplers, + StableDiffusionStylePresets +} from '@/views/ai/utils/constants' +import { formatTime } from '@/utils' + +const showDrawer = ref<boolean>(false) // 是否显示 +const detail = ref<ImageVO>({} as ImageVO) // 图片详细信息 + +const props = defineProps({ + show: { + type: Boolean, + require: true, + default: false + }, + id: { + type: Number, + required: true + } +}) + +/** 关闭抽屉 */ +const handleDrawerClose = async () => { + emits('handleDrawerClose') +} + +/** 监听 drawer 是否打开 */ +const { show } = toRefs(props) +watch(show, async (newValue, oldValue) => { + showDrawer.value = newValue as boolean +}) + +/** 获取图片详情 */ +const getImageDetail = async (id: number) => { + detail.value = await ImageApi.getImageMy(id) +} + +/** 监听 id 变化,加载最新图片详情 */ +const { id } = toRefs(props) +watch(id, async (newVal, oldVal) => { + if (newVal) { + await getImageDetail(newVal) + } +}) + +const emits = defineEmits(['handleDrawerClose']) +</script> +<style scoped lang="scss"> +.item { + margin-bottom: 20px; + width: 100%; + overflow: hidden; + word-wrap: break-word; + + .header { + display: flex; + flex-direction: row; + justify-content: space-between; + } + + .tip { + font-weight: bold; + font-size: 16px; + } + + .body { + margin-top: 10px; + color: #616161; + + .taskImage { + border-radius: 10px; + } + } +} +</style> diff --git a/src/views/ai/image/index/components/ImageList.vue b/src/views/ai/image/index/components/ImageList.vue new file mode 100644 index 0000000..ced006f --- /dev/null +++ b/src/views/ai/image/index/components/ImageList.vue @@ -0,0 +1,245 @@ +<template> + <el-card class="dr-task" body-class="task-card" shadow="never"> + <template #header> + 绘画任务 + <!-- TODO @fan:看看,怎么优化下这个样子哈。 --> + <el-button @click="handleViewPublic">绘画作品</el-button> + </template> + <!-- 图片列表 --> + <div class="task-image-list" ref="imageListRef"> + <ImageCard + v-for="image in imageList" + :key="image.id" + :detail="image" + @on-btn-click="handleImageButtonClick" + @on-mj-btn-click="handleImageMidjourneyButtonClick" + /> + </div> + <div class="task-image-pagination"> + <Pagination + :total="pageTotal" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getImageList" + /> + </div> + </el-card> + + <!-- 图片详情 --> + <ImageDetail + :show="isShowImageDetail" + :id="showImageDetailId" + @handle-drawer-close="handleDetailClose" + /> +</template> +<script setup lang="ts"> +import { + ImageApi, + ImageVO, + ImageMidjourneyActionVO, + ImageMidjourneyButtonsVO +} from '@/api/ai/image' +import ImageDetail from './ImageDetail.vue' +import ImageCard from './ImageCard.vue' +import { ElLoading, LoadingOptionsResolved } from 'element-plus' +import { AiImageStatusEnum } from '@/views/ai/utils/constants' +import download from '@/utils/download' + +const message = useMessage() // 消息弹窗 +const router = useRouter() // 路由 + +// 图片分页相关的参数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10 +}) +const pageTotal = ref<number>(0) // page size +const imageList = ref<ImageVO[]>([]) // image 列表 +const imageListLoadingInstance = ref<any>() // image 列表是否正在加载中 +const imageListRef = ref<any>() // ref +// 图片轮询相关的参数(正在生成中的) +const inProgressImageMap = ref<{}>({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 image +const inProgressTimer = ref<any>() // 生成中的 image 定时器,轮询生成进展 +// 图片详情相关的参数 +const isShowImageDetail = ref<boolean>(false) // 图片详情是否展示 +const showImageDetailId = ref<number>(0) // 图片详情的图片编号 + +/** 处理查看绘图作品 */ +const handleViewPublic = () => { + router.push({ + name: 'AiImageSquare' + }) +} + +/** 查看图片的详情 */ +const handleDetailOpen = async () => { + isShowImageDetail.value = true +} + +/** 关闭图片的详情 */ +const handleDetailClose = async () => { + isShowImageDetail.value = false +} + +/** 获得 image 图片列表 */ +const getImageList = async () => { + try { + // 1. 加载图片列表 + imageListLoadingInstance.value = ElLoading.service({ + target: imageListRef.value, + text: '加载中...' + } as LoadingOptionsResolved) + const { list, total } = await ImageApi.getImagePageMy(queryParams) + imageList.value = list + pageTotal.value = total + + // 2. 计算需要轮询的图片 + const newWatImages = {} + imageList.value.forEach((item) => { + if (item.status === AiImageStatusEnum.IN_PROGRESS) { + newWatImages[item.id] = item + } + }) + inProgressImageMap.value = newWatImages + } finally { + // 关闭正在“加载中”的 Loading + if (imageListLoadingInstance.value) { + imageListLoadingInstance.value.close() + imageListLoadingInstance.value = null + } + } +} + +/** 轮询生成中的 image 列表 */ +const refreshWatchImages = async () => { + const imageIds = Object.keys(inProgressImageMap.value).map(Number) + if (imageIds.length == 0) { + return + } + const list = (await ImageApi.getImageListMyByIds(imageIds)) as ImageVO[] + const newWatchImages = {} + list.forEach((image) => { + if (image.status === AiImageStatusEnum.IN_PROGRESS) { + newWatchImages[image.id] = image + } else { + const index = imageList.value.findIndex((oldImage) => image.id === oldImage.id) + if (index >= 0) { + // 更新 imageList + imageList.value[index] = image + } + } + }) + inProgressImageMap.value = newWatchImages +} + +/** 图片的点击事件 */ +const handleImageButtonClick = async (type: string, imageDetail: ImageVO) => { + // 详情 + if (type === 'more') { + showImageDetailId.value = imageDetail.id + await handleDetailOpen() + return + } + // 删除 + if (type === 'delete') { + await message.confirm(`是否删除照片?`) + await ImageApi.deleteImageMy(imageDetail.id) + await getImageList() + message.success('删除成功!') + return + } + // 下载 + if (type === 'download') { + await download.image({ url: imageDetail.picUrl }) + return + } + // 重新生成 + if (type === 'regeneration') { + await emits('onRegeneration', imageDetail) + return + } +} + +/** 处理 Midjourney 按钮点击事件 */ +const handleImageMidjourneyButtonClick = async ( + button: ImageMidjourneyButtonsVO, + imageDetail: ImageVO +) => { + // 1. 构建 params 参数 + const data = { + id: imageDetail.id, + customId: button.customId + } as ImageMidjourneyActionVO + // 2. 发送 action + await ImageApi.midjourneyAction(data) + // 3. 刷新列表 + await getImageList() +} + +defineExpose({ getImageList }) // 暴露组件方法 + +const emits = defineEmits(['onRegeneration']) + +/** 组件挂在的时候 */ +onMounted(async () => { + // 获取 image 列表 + await getImageList() + // 自动刷新 image 列表 + inProgressTimer.value = setInterval(async () => { + await refreshWatchImages() + }, 1000 * 3) +}) + +/** 组件取消挂在的时候 */ +onUnmounted(async () => { + if (inProgressTimer.value) { + clearInterval(inProgressTimer.value) + } +}) +</script> +<style lang="scss"> +.dr-task { + width: 100%; + height: 100%; +} +.task-card { + margin: 0; + padding: 0; + height: 100%; + position: relative; +} + +.task-image-list { + position: relative; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: flex-start; + height: 100%; + overflow: auto; + padding: 20px 20px 140px; + box-sizing: border-box; /* 确保内边距不会增加高度 */ + + > div { + margin-right: 20px; + margin-bottom: 20px; + } + > div:last-of-type { + //margin-bottom: 100px; + } +} + +.task-image-pagination { + position: absolute; + bottom: 60px; + height: 50px; + line-height: 90px; + width: 100%; + z-index: 999; + background-color: #ffffff; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} +</style> diff --git a/src/views/ai/image/index/components/dall3/index.vue b/src/views/ai/image/index/components/dall3/index.vue new file mode 100644 index 0000000..5c891ab --- /dev/null +++ b/src/views/ai/image/index/components/dall3/index.vue @@ -0,0 +1,320 @@ +<!-- dall3 --> +<template> + <div class="prompt"> + <el-text tag="b">画面描述</el-text> + <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text> + <el-input + v-model="prompt" + maxlength="1024" + rows="5" + class="w-100% mt-15px" + input-style="border-radius: 7px;" + placeholder="例如:童话里的小屋应该是什么样子?" + show-word-limit + type="textarea" + /> + </div> + <div class="hot-words"> + <div> + <el-text tag="b">随机热词</el-text> + </div> + <el-space wrap class="word-list"> + <el-button + round + class="btn" + :type="selectHotWord === hotWord ? 'primary' : 'default'" + v-for="hotWord in ImageHotWords" + :key="hotWord" + @click="handleHotWordClick(hotWord)" + > + {{ hotWord }} + </el-button> + </el-space> + </div> + <div class="model"> + <div> + <el-text tag="b">模型选择</el-text> + </div> + <el-space wrap class="model-list"> + <div + :class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'" + v-for="model in Dall3Models" + :key="model.key" + > + <el-image :src="model.image" fit="contain" @click="handleModelClick(model)" /> + <div class="model-font">{{ model.name }}</div> + </div> + </el-space> + </div> + <div class="image-style"> + <div> + <el-text tag="b">风格选择</el-text> + </div> + <el-space wrap class="image-style-list"> + <div + :class="style === imageStyle.key ? 'image-style-item selectImageStyle' : 'image-style-item'" + v-for="imageStyle in Dall3StyleList" + :key="imageStyle.key" + > + <el-image :src="imageStyle.image" fit="contain" @click="handleStyleClick(imageStyle)" /> + <div class="style-font">{{ imageStyle.name }}</div> + </div> + </el-space> + </div> + <div class="image-size"> + <div> + <el-text tag="b">画面比例</el-text> + </div> + <el-space wrap class="size-list"> + <div + class="size-item" + v-for="imageSize in Dall3SizeList" + :key="imageSize.key" + @click="handleSizeClick(imageSize)" + > + <div + :class="selectSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'" + > + <div :style="imageSize.style"></div> + </div> + <div class="size-font">{{ imageSize.name }}</div> + </div> + </el-space> + </div> + <div class="btns"> + <el-button type="primary" size="large" round :loading="drawIn" @click="handleGenerateImage"> + {{ drawIn ? '生成中' : '生成内容' }} + </el-button> + </div> +</template> +<script setup lang="ts"> +import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image' +import { + Dall3Models, + Dall3StyleList, + ImageHotWords, + Dall3SizeList, + ImageModelVO, + AiPlatformEnum +} from '@/views/ai/utils/constants' + +const message = useMessage() // 消息弹窗 + +// 定义属性 +const prompt = ref<string>('') // 提示词 +const drawIn = ref<boolean>(false) // 生成中 +const selectHotWord = ref<string>('') // 选中的热词 +const selectModel = ref<string>('dall-e-3') // 模型 +const selectSize = ref<string>('1024x1024') // 选中 size +const style = ref<string>('vivid') // style 样式 + +const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits + +/** 选择热词 */ +const handleHotWordClick = async (hotWord: string) => { + // 情况一:取消选中 + if (selectHotWord.value == hotWord) { + selectHotWord.value = '' + return + } + + // 情况二:选中 + selectHotWord.value = hotWord + prompt.value = hotWord +} + +/** 选择 model 模型 */ +const handleModelClick = async (model: ImageModelVO) => { + selectModel.value = model.key +} + +/** 选择 style 样式 */ +const handleStyleClick = async (imageStyle: ImageModelVO) => { + style.value = imageStyle.key +} + +/** 选择 size 大小 */ +const handleSizeClick = async (imageSize: ImageSizeVO) => { + selectSize.value = imageSize.key +} + +/** 图片生产 */ +const handleGenerateImage = async () => { + // 二次确认 + await message.confirm(`确认生成内容?`) + try { + // 加载中 + drawIn.value = true + // 回调 + emits('onDrawStart', AiPlatformEnum.OPENAI) + const imageSize = Dall3SizeList.find((item) => item.key === selectSize.value) as ImageSizeVO + const form = { + platform: AiPlatformEnum.OPENAI, + prompt: prompt.value, // 提示词 + model: selectModel.value, // 模型 + width: imageSize.width, // size 不能为空 + height: imageSize.height, // size 不能为空 + options: { + style: style.value // 图像生成的风格 + } + } as ImageDrawReqVO + // 发送请求 + await ImageApi.drawImage(form) + } finally { + // 回调 + emits('onDrawComplete', AiPlatformEnum.OPENAI) + // 加载结束 + drawIn.value = false + } +} + +/** 填充值 */ +const settingValues = async (detail: ImageVO) => { + prompt.value = detail.prompt + selectModel.value = detail.model + style.value = detail.options?.style + const imageSize = Dall3SizeList.find( + (item) => item.key === `${detail.width}x${detail.height}` + ) as ImageSizeVO + await handleSizeClick(imageSize) +} + +/** 暴露组件方法 */ +defineExpose({ settingValues }) +</script> +<style scoped lang="scss"> +// 提示词 +.prompt { +} + +// 热词 +.hot-words { + display: flex; + flex-direction: column; + margin-top: 30px; + + .word-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: start; + margin-top: 15px; + + .btn { + margin: 0; + } + } +} + +// 模型 +.model { + margin-top: 30px; + + .model-list { + margin-top: 15px; + + .modal-item { + width: 110px; + //outline: 1px solid blue; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + border: 3px solid transparent; + cursor: pointer; + + .model-font { + font-size: 14px; + color: #3e3e3e; + font-weight: bold; + } + } + + .selectModel { + border: 3px solid #1293ff; + border-radius: 5px; + } + } +} + +// 样式 style +.image-style { + margin-top: 30px; + + .image-style-list { + margin-top: 15px; + + .image-style-item { + width: 110px; + //outline: 1px solid blue; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + border: 3px solid transparent; + cursor: pointer; + + .style-font { + font-size: 14px; + color: #3e3e3e; + font-weight: bold; + } + } + + .selectImageStyle { + border: 3px solid #1293ff; + border-radius: 5px; + } + } +} + +// 尺寸 +.image-size { + width: 100%; + margin-top: 30px; + + .size-list { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + margin-top: 20px; + + .size-item { + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + + .size-wrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 7px; + padding: 4px; + width: 50px; + height: 50px; + background-color: #fff; + border: 1px solid #fff; + } + + .size-font { + font-size: 14px; + color: #3e3e3e; + font-weight: bold; + } + } + } + + .selectImageSize { + border: 1px solid #1293ff !important; + } +} + +.btns { + display: flex; + justify-content: center; + margin-top: 50px; +} +</style> diff --git a/src/views/ai/image/index/components/midjourney/index.vue b/src/views/ai/image/index/components/midjourney/index.vue new file mode 100644 index 0000000..1d7fda1 --- /dev/null +++ b/src/views/ai/image/index/components/midjourney/index.vue @@ -0,0 +1,326 @@ +<!-- dall3 --> +<template> + <div class="prompt"> + <el-text tag="b">画面描述</el-text> + <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开.</el-text> + <el-input + v-model="prompt" + maxlength="1024" + rows="5" + class="w-100% mt-15px" + input-style="border-radius: 7px;" + placeholder="例如:童话里的小屋应该是什么样子?" + show-word-limit + type="textarea" + /> + </div> + <div class="hot-words"> + <div> + <el-text tag="b">随机热词</el-text> + </div> + <el-space wrap class="word-list"> + <el-button + round + class="btn" + :type="selectHotWord === hotWord ? 'primary' : 'default'" + v-for="hotWord in ImageHotWords" + :key="hotWord" + @click="handleHotWordClick(hotWord)" + > + {{ hotWord }} + </el-button> + </el-space> + </div> + <div class="image-size"> + <div> + <el-text tag="b">尺寸</el-text> + </div> + <el-space wrap class="size-list"> + <div + class="size-item" + v-for="imageSize in MidjourneySizeList" + :key="imageSize.key" + @click="handleSizeClick(imageSize)" + > + <div + :class="selectSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'" + > + <div :style="imageSize.style"></div> + </div> + <div class="size-font">{{ imageSize.key }}</div> + </div> + </el-space> + </div> + <div class="model"> + <div> + <el-text tag="b">模型</el-text> + </div> + <el-space wrap class="model-list"> + <div + :class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'" + v-for="model in MidjourneyModels" + :key="model.key" + > + <el-image :src="model.image" fit="contain" @click="handleModelClick(model)" /> + <div class="model-font">{{ model.name }}</div> + </div> + </el-space> + </div> + <div class="version"> + <div> + <el-text tag="b">版本</el-text> + </div> + <el-space wrap class="version-list"> + <el-select + v-model="selectVersion" + class="version-select !w-350px" + clearable + placeholder="请选择版本" + > + <el-option + v-for="item in versionList" + :key="item.value" + :label="item.label" + :value="item.value" + /> + </el-select> + </el-space> + </div> + <div class="model"> + <div> + <el-text tag="b">参考图</el-text> + </div> + <el-space wrap class="model-list"> + <UploadImg v-model="referImageUrl" height="120px" width="120px" /> + </el-space> + </div> + <div class="btns"> + <el-button type="primary" size="large" round @click="handleGenerateImage"> + {{ drawIn ? '生成中' : '生成内容' }} + </el-button> + </div> +</template> +<script setup lang="ts"> +import { ImageApi, ImageMidjourneyImagineReqVO, ImageVO } from '@/api/ai/image' +import { + AiPlatformEnum, + ImageHotWords, + ImageSizeVO, + ImageModelVO, + MidjourneyModels, + MidjourneySizeList, + MidjourneyVersions, + NijiVersionList +} from '@/views/ai/utils/constants' + +const message = useMessage() // 消息弹窗 + +// 定义属性 +const drawIn = ref<boolean>(false) // 生成中 +const selectHotWord = ref<string>('') // 选中的热词 +// 表单 +const prompt = ref<string>('') // 提示词 +const referImageUrl = ref<any>() // 参考图 +const selectModel = ref<string>('midjourney') // 选中的模型 +const selectSize = ref<string>('1:1') // 选中 size +const selectVersion = ref<any>('6.0') // 选中的 version +const versionList = ref<any>(MidjourneyVersions) // version 列表 +const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits + +/** 选择热词 */ +const handleHotWordClick = async (hotWord: string) => { + // 情况一:取消选中 + if (selectHotWord.value == hotWord) { + selectHotWord.value = '' + return + } + + // 情况二:选中 + selectHotWord.value = hotWord // 选中 + prompt.value = hotWord // 设置提示次 +} + +/** 点击 size 尺寸 */ +const handleSizeClick = async (imageSize: ImageSizeVO) => { + selectSize.value = imageSize.key +} + +/** 点击 model 模型 */ +const handleModelClick = async (model: ImageModelVO) => { + selectModel.value = model.key + if (model.key === 'niji') { + versionList.value = NijiVersionList // 默认选择 niji + } else { + versionList.value = MidjourneyVersions // 默认选择 midjourney + } + selectVersion.value = versionList.value[0].value +} + +/** 图片生成 */ +const handleGenerateImage = async () => { + // 二次确认 + await message.confirm(`确认生成内容?`) + try { + // 加载中 + drawIn.value = true + // 回调 + emits('onDrawStart', AiPlatformEnum.MIDJOURNEY) + // 发送请求 + const imageSize = MidjourneySizeList.find( + (item) => selectSize.value === item.key + ) as ImageSizeVO + const req = { + prompt: prompt.value, + model: selectModel.value, + width: imageSize.width, + height: imageSize.height, + version: selectVersion.value, + referImageUrl: referImageUrl.value + } as ImageMidjourneyImagineReqVO + await ImageApi.midjourneyImagine(req) + } finally { + // 回调 + emits('onDrawComplete', AiPlatformEnum.MIDJOURNEY) + // 加载结束 + drawIn.value = false + } +} + +/** 填充值 */ +const settingValues = async (detail: ImageVO) => { + // 提示词 + prompt.value = detail.prompt + // image size + const imageSize = MidjourneySizeList.find( + (item) => item.key === `${detail.width}:${detail.height}` + ) as ImageSizeVO + selectSize.value = imageSize.key + // 选中模型 + const model = MidjourneyModels.find((item) => item.key === detail.options?.model) as ImageModelVO + await handleModelClick(model) + // 版本 + selectVersion.value = versionList.value.find( + (item) => item.value === detail.options?.version + ).value + // image + referImageUrl.value = detail.options.referImageUrl +} + +/** 暴露组件方法 */ +defineExpose({ settingValues }) +</script> +<style scoped lang="scss"> +// 提示词 +.prompt { +} + +// 热词 +.hot-words { + display: flex; + flex-direction: column; + margin-top: 30px; + + .word-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: start; + margin-top: 15px; + + .btn { + margin: 0; + } + } +} + +// version +.version { + margin-top: 20px; + + .version-list { + margin-top: 20px; + width: 100%; + } +} + +// 模型 +.model { + margin-top: 30px; + + .model-list { + margin-top: 15px; + + .modal-item { + display: flex; + flex-direction: column; + align-items: center; + width: 150px; + //outline: 1px solid blue; + overflow: hidden; + border: 3px solid transparent; + cursor: pointer; + + .model-font { + font-size: 14px; + color: #3e3e3e; + font-weight: bold; + } + } + + .selectModel { + border: 3px solid #1293ff; + border-radius: 5px; + } + } +} + +// 尺寸 +.image-size { + width: 100%; + margin-top: 30px; + + .size-list { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + margin-top: 20px; + + .size-item { + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + + .size-wrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 7px; + padding: 4px; + width: 50px; + height: 50px; + background-color: #fff; + border: 1px solid #fff; + } + + .size-font { + font-size: 14px; + color: #3e3e3e; + font-weight: bold; + } + } + } + + .selectImageSize { + border: 1px solid #1293ff !important; + } +} + +.btns { + display: flex; + justify-content: center; + margin-top: 50px; +} +</style> diff --git a/src/views/ai/image/index/components/other/index.vue b/src/views/ai/image/index/components/other/index.vue new file mode 100644 index 0000000..a688be1 --- /dev/null +++ b/src/views/ai/image/index/components/other/index.vue @@ -0,0 +1,216 @@ +<!-- dall3 --> +<template> + <div class="prompt"> + <el-text tag="b">画面描述</el-text> + <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text> + <el-input + v-model="prompt" + maxlength="1024" + rows="5" + class="w-100% mt-15px" + input-style="border-radius: 7px;" + placeholder="例如:童话里的小屋应该是什么样子?" + show-word-limit + type="textarea" + /> + </div> + <div class="hot-words"> + <div> + <el-text tag="b">随机热词</el-text> + </div> + <el-space wrap class="word-list"> + <el-button + round + class="btn" + :type="selectHotWord === hotWord ? 'primary' : 'default'" + v-for="hotWord in ImageHotWords" + :key="hotWord" + @click="handleHotWordClick(hotWord)" + > + {{ hotWord }} + </el-button> + </el-space> + </div> + <div class="group-item"> + <div> + <el-text tag="b">平台</el-text> + </div> + <el-space wrap class="group-item-body"> + <el-select + v-model="otherPlatform" + placeholder="Select" + size="large" + class="!w-350px" + @change="handlerPlatformChange" + > + <el-option + v-for="item in OtherPlatformEnum" + :key="item.key" + :label="item.name" + :value="item.key" + /> + </el-select> + </el-space> + </div> + <div class="group-item"> + <div> + <el-text tag="b">模型</el-text> + </div> + <el-space wrap class="group-item-body"> + <el-select v-model="model" placeholder="Select" size="large" class="!w-350px"> + <el-option v-for="item in models" :key="item.key" :label="item.name" :value="item.key" /> + </el-select> + </el-space> + </div> + <div class="group-item"> + <div> + <el-text tag="b">图片尺寸</el-text> + </div> + <el-space wrap class="group-item-body"> + <el-input v-model="width" type="number" class="w-170px" placeholder="图片宽度" /> + <el-input v-model="height" type="number" class="w-170px" placeholder="图片高度" /> + </el-space> + </div> + <div class="btns"> + <el-button type="primary" size="large" round :loading="drawIn" @click="handleGenerateImage"> + {{ drawIn ? '生成中' : '生成内容' }} + </el-button> + </div> +</template> +<script setup lang="ts"> +import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image' +import { + AiPlatformEnum, + ChatGlmModels, + ImageHotWords, + ImageModelVO, + OtherPlatformEnum, + QianFanModels, + TongYiWanXiangModels +} from '@/views/ai/utils/constants' + +const message = useMessage() // 消息弹窗 + +// 定义属性 +const drawIn = ref<boolean>(false) // 生成中 +const selectHotWord = ref<string>('') // 选中的热词 +// 表单 +const prompt = ref<string>('') // 提示词 +const width = ref<number>(512) // 图片宽度 +const height = ref<number>(512) // 图片高度 +const otherPlatform = ref<string>(AiPlatformEnum.TONG_YI) // 平台 +const models = ref<ImageModelVO[]>(TongYiWanXiangModels) // 模型 TongYiWanXiangModels、QianFanModels +const model = ref<string>(models.value[0].key) // 模型 + +const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits + +/** 选择热词 */ +const handleHotWordClick = async (hotWord: string) => { + // 情况一:取消选中 + if (selectHotWord.value == hotWord) { + selectHotWord.value = '' + return + } + + // 情况二:选中 + selectHotWord.value = hotWord // 选中 + prompt.value = hotWord // 替换提示词 +} + +/** 图片生成 */ +const handleGenerateImage = async () => { + // 二次确认 + await message.confirm(`确认生成内容?`) + try { + // 加载中 + drawIn.value = true + // 回调 + emits('onDrawStart', AiPlatformEnum.STABLE_DIFFUSION) + // 发送请求 + const form = { + platform: otherPlatform.value, + model: model.value, // 模型 + prompt: prompt.value, // 提示词 + width: width.value, // 图片宽度 + height: height.value, // 图片高度 + options: {} + } as unknown as ImageDrawReqVO + await ImageApi.drawImage(form) + } finally { + // 回调 + emits('onDrawComplete', AiPlatformEnum.STABLE_DIFFUSION) + // 加载结束 + drawIn.value = false + } +} + +/** 填充值 */ +const settingValues = async (detail: ImageVO) => { + prompt.value = detail.prompt + width.value = detail.width + height.value = detail.height +} + +/** 平台切换 */ +const handlerPlatformChange = async (platform: string) => { + // 切换平台,切换模型、风格 + if (AiPlatformEnum.TONG_YI === platform) { + models.value = TongYiWanXiangModels + } else if (AiPlatformEnum.YI_YAN === platform) { + models.value = QianFanModels + } else if (AiPlatformEnum.ZHI_PU === platform) { + models.value = ChatGlmModels + } else { + models.value = [] + } + // 切换平台,默认选择一个风格 + if (models.value.length > 0) { + model.value = models.value[0].key + } else { + model.value = '' + } +} + +/** 暴露组件方法 */ +defineExpose({ settingValues }) +</script> +<style scoped lang="scss"> +// 提示词 +.prompt { +} + +// 热词 +.hot-words { + display: flex; + flex-direction: column; + margin-top: 30px; + + .word-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: start; + margin-top: 15px; + + .btn { + margin: 0; + } + } +} + +// 模型 +.group-item { + margin-top: 30px; + + .group-item-body { + margin-top: 15px; + width: 100%; + } +} + +.btns { + display: flex; + justify-content: center; + margin-top: 50px; +} +</style> diff --git a/src/views/ai/image/index/components/stableDiffusion/index.vue b/src/views/ai/image/index/components/stableDiffusion/index.vue new file mode 100644 index 0000000..169938f --- /dev/null +++ b/src/views/ai/image/index/components/stableDiffusion/index.vue @@ -0,0 +1,272 @@ +<!-- dall3 --> +<template> + <div class="prompt"> + <el-text tag="b">画面描述</el-text> + <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text> + <el-input + v-model="prompt" + maxlength="1024" + rows="5" + class="w-100% mt-15px" + input-style="border-radius: 7px;" + placeholder="例如:童话里的小屋应该是什么样子?" + show-word-limit + type="textarea" + /> + </div> + <div class="hot-words"> + <div> + <el-text tag="b">随机热词</el-text> + </div> + <el-space wrap class="word-list"> + <el-button + round + class="btn" + :type="selectHotWord === hotWord ? 'primary' : 'default'" + v-for="hotWord in ImageHotEnglishWords" + :key="hotWord" + @click="handleHotWordClick(hotWord)" + > + {{ hotWord }} + </el-button> + </el-space> + </div> + <div class="group-item"> + <div> + <el-text tag="b">采样方法</el-text> + </div> + <el-space wrap class="group-item-body"> + <el-select v-model="sampler" placeholder="Select" size="large" class="!w-350px"> + <el-option + v-for="item in StableDiffusionSamplers" + :key="item.key" + :label="item.name" + :value="item.key" + /> + </el-select> + </el-space> + </div> + <div class="group-item"> + <div> + <el-text tag="b">CLIP</el-text> + </div> + <el-space wrap class="group-item-body"> + <el-select v-model="clipGuidancePreset" placeholder="Select" size="large" class="!w-350px"> + <el-option + v-for="item in StableDiffusionClipGuidancePresets" + :key="item.key" + :label="item.name" + :value="item.key" + /> + </el-select> + </el-space> + </div> + <div class="group-item"> + <div> + <el-text tag="b">风格</el-text> + </div> + <el-space wrap class="group-item-body"> + <el-select v-model="stylePreset" placeholder="Select" size="large" class="!w-350px"> + <el-option + v-for="item in StableDiffusionStylePresets" + :key="item.key" + :label="item.name" + :value="item.key" + /> + </el-select> + </el-space> + </div> + <div class="group-item"> + <div> + <el-text tag="b">图片尺寸</el-text> + </div> + <el-space wrap class="group-item-body"> + <el-input v-model="width" class="w-170px" placeholder="图片宽度" /> + <el-input v-model="height" class="w-170px" placeholder="图片高度" /> + </el-space> + </div> + <div class="group-item"> + <div> + <el-text tag="b">迭代步数</el-text> + </div> + <el-space wrap class="group-item-body"> + <el-input + v-model="steps" + type="number" + size="large" + class="!w-350px" + placeholder="Please input" + /> + </el-space> + </div> + <div class="group-item"> + <div> + <el-text tag="b">引导系数</el-text> + </div> + <el-space wrap class="group-item-body"> + <el-input + v-model="scale" + type="number" + size="large" + class="!w-350px" + placeholder="Please input" + /> + </el-space> + </div> + <div class="group-item"> + <div> + <el-text tag="b">随机因子</el-text> + </div> + <el-space wrap class="group-item-body"> + <el-input + v-model="seed" + type="number" + size="large" + class="!w-350px" + placeholder="Please input" + /> + </el-space> + </div> + <div class="btns"> + <el-button type="primary" size="large" round :loading="drawIn" @click="handleGenerateImage"> + {{ drawIn ? '生成中' : '生成内容' }} + </el-button> + </div> +</template> +<script setup lang="ts"> +import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image' +import { hasChinese } from '@/views/ai/utils/utils' +import { + AiPlatformEnum, + ImageHotEnglishWords, + StableDiffusionClipGuidancePresets, + StableDiffusionSamplers, + StableDiffusionStylePresets +} from '@/views/ai/utils/constants' + +const message = useMessage() // 消息弹窗 + +// 定义属性 +const drawIn = ref<boolean>(false) // 生成中 +const selectHotWord = ref<string>('') // 选中的热词 +// 表单 +const prompt = ref<string>('') // 提示词 +const width = ref<number>(512) // 图片宽度 +const height = ref<number>(512) // 图片高度 +const sampler = ref<string>('DDIM') // 采样方法 +const steps = ref<number>(20) // 迭代步数 +const seed = ref<number>(42) // 控制生成图像的随机性 +const scale = ref<number>(7.5) // 引导系数 +const clipGuidancePreset = ref<string>('NONE') // 文本提示相匹配的图像(clip_guidance_preset) 简称 CLIP +const stylePreset = ref<string>('3d-model') // 风格 + +const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits + +/** 选择热词 */ +const handleHotWordClick = async (hotWord: string) => { + // 情况一:取消选中 + if (selectHotWord.value == hotWord) { + selectHotWord.value = '' + return + } + + // 情况二:选中 + selectHotWord.value = hotWord // 选中 + prompt.value = hotWord // 替换提示词 +} + +/** 图片生成 */ +const handleGenerateImage = async () => { + // 二次确认 + if (hasChinese(prompt.value)) { + message.alert('暂不支持中文!') + return + } + await message.confirm(`确认生成内容?`) + + try { + // 加载中 + drawIn.value = true + // 回调 + emits('onDrawStart', AiPlatformEnum.STABLE_DIFFUSION) + // 发送请求 + const form = { + platform: AiPlatformEnum.STABLE_DIFFUSION, + model: 'stable-diffusion-v1-6', + prompt: prompt.value, // 提示词 + width: width.value, // 图片宽度 + height: height.value, // 图片高度 + options: { + seed: seed.value, // 随机种子 + steps: steps.value, // 图片生成步数 + scale: scale.value, // 引导系数 + sampler: sampler.value, // 采样算法 + clipGuidancePreset: clipGuidancePreset.value, // 文本提示相匹配的图像 CLIP + stylePreset: stylePreset.value // 风格 + } + } as ImageDrawReqVO + await ImageApi.drawImage(form) + } finally { + // 回调 + emits('onDrawComplete', AiPlatformEnum.STABLE_DIFFUSION) + // 加载结束 + drawIn.value = false + } +} + +/** 填充值 */ +const settingValues = async (detail: ImageVO) => { + prompt.value = detail.prompt + width.value = detail.width + height.value = detail.height + seed.value = detail.options?.seed + steps.value = detail.options?.steps + scale.value = detail.options?.scale + sampler.value = detail.options?.sampler + clipGuidancePreset.value = detail.options?.clipGuidancePreset + stylePreset.value = detail.options?.stylePreset +} + +/** 暴露组件方法 */ +defineExpose({ settingValues }) +</script> +<style scoped lang="scss"> +// 提示词 +.prompt { +} + +// 热词 +.hot-words { + display: flex; + flex-direction: column; + margin-top: 30px; + + .word-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: start; + margin-top: 15px; + + .btn { + margin: 0; + } + } +} + +// 模型 +.group-item { + margin-top: 30px; + + .group-item-body { + margin-top: 15px; + width: 100%; + } +} + +.btns { + display: flex; + justify-content: center; + margin-top: 50px; +} +</style> diff --git a/src/views/ai/image/index/index.vue b/src/views/ai/image/index/index.vue new file mode 100644 index 0000000..1217e79 --- /dev/null +++ b/src/views/ai/image/index/index.vue @@ -0,0 +1,141 @@ +<!-- image --> +<template> + <div class="ai-image"> + <div class="left"> + <div class="segmented"> + <el-segmented v-model="selectPlatform" :options="platformOptions" /> + </div> + <div class="modal-switch-container"> + <Dall3 + v-if="selectPlatform === AiPlatformEnum.OPENAI" + ref="dall3Ref" + @on-draw-start="handleDrawStart" + @on-draw-complete="handleDrawComplete" + /> + <Midjourney v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY" ref="midjourneyRef" /> + <StableDiffusion + v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION" + ref="stableDiffusionRef" + @on-draw-complete="handleDrawComplete" + /> + <Other + v-if="selectPlatform === 'other'" + ref="otherRef" + @on-draw-complete="handleDrawComplete" + /> + </div> + </div> + <div class="main"> + <ImageList ref="imageListRef" @on-regeneration="handleRegeneration" /> + </div> + </div> +</template> + +<script setup lang="ts"> +import ImageList from './components/ImageList.vue' +import { AiPlatformEnum } from '@/views/ai/utils/constants' +import { ImageVO } from '@/api/ai/image' +import Dall3 from './components/dall3/index.vue' +import Midjourney from './components/midjourney/index.vue' +import StableDiffusion from './components/stableDiffusion/index.vue' +import Other from './components/other/index.vue' + +const imageListRef = ref<any>() // image 列表 ref +const dall3Ref = ref<any>() // dall3(openai) ref +const midjourneyRef = ref<any>() // midjourney ref +const stableDiffusionRef = ref<any>() // stable diffusion ref +const otherRef = ref<any>() // stable diffusion ref + +// 定义属性 +const selectPlatform = ref(AiPlatformEnum.MIDJOURNEY) +const platformOptions = [ + { + label: 'DALL3 绘画', + value: AiPlatformEnum.OPENAI + }, + { + label: 'MJ 绘画', + value: AiPlatformEnum.MIDJOURNEY + }, + { + label: 'Stable Diffusion', + value: AiPlatformEnum.STABLE_DIFFUSION + }, + { + label: '其它', + value: 'other' + } +] + +/** 绘画 start */ +const handleDrawStart = async (platform: string) => {} + +/** 绘画 complete */ +const handleDrawComplete = async (platform: string) => { + await imageListRef.value.getImageList() +} + +/** 重新生成:将画图详情填充到对应平台 */ +const handleRegeneration = async (image: ImageVO) => { + // 切换平台 + selectPlatform.value = image.platform + // 根据不同平台填充 image + await nextTick() + if (image.platform === AiPlatformEnum.MIDJOURNEY) { + midjourneyRef.value.settingValues(image) + } else if (image.platform === AiPlatformEnum.OPENAI) { + dall3Ref.value.settingValues(image) + } else if (image.platform === AiPlatformEnum.STABLE_DIFFUSION) { + stableDiffusionRef.value.settingValues(image) + } + // TODO @fan:貌似 other 重新设置不行? +} +</script> + +<style scoped lang="scss"> +.ai-image { + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 0; + + display: flex; + flex-direction: row; + height: 100%; + width: 100%; + + .left { + display: flex; + flex-direction: column; + padding: 20px; + width: 350px; + + .segmented { + } + + .segmented .el-segmented { + --el-border-radius-base: 16px; + --el-segmented-item-selected-color: #fff; + background-color: #ececec; + width: 350px; + } + + .modal-switch-container { + height: 100%; + overflow-y: auto; + margin-top: 30px; + } + } + + .main { + flex: 1; + background-color: #fff; + } + + .right { + width: 350px; + background-color: #f7f8fa; + } +} +</style> diff --git a/src/views/ai/image/manager/index.vue b/src/views/ai/image/manager/index.vue new file mode 100644 index 0000000..84403f3 --- /dev/null +++ b/src/views/ai/image/manager/index.vue @@ -0,0 +1,251 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-select + v-model="queryParams.userId" + clearable + placeholder="请输入用户编号" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="平台" prop="platform"> + <el-select v-model="queryParams.status" placeholder="请选择平台" clearable class="!w-240px"> + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="绘画状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择绘画状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.AI_IMAGE_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="是否发布" prop="publicStatus"> + <el-select + v-model="queryParams.publicStatus" + placeholder="请选择是否发布" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" width="180" fixed="left" /> + <el-table-column label="图片" align="center" prop="picUrl" width="110px" fixed="left"> + <template #default="{ row }"> + <el-image + class="h-80px w-80px" + lazy + :src="row.picUrl" + :preview-src-list="[row.picUrl]" + preview-teleported + fit="cover" + v-if="row.picUrl?.length > 0" + /> + </template> + </el-table-column> + <el-table-column label="用户" align="center" prop="userId" width="180"> + <template #default="scope"> + <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span> + </template> + </el-table-column> + <el-table-column label="平台" align="center" prop="platform" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" /> + </template> + </el-table-column> + <el-table-column label="模型" align="center" prop="model" width="180" /> + <el-table-column label="绘画状态" align="center" prop="status" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_IMAGE_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="是否发布" align="center" prop="publicStatus"> + <template #default="scope"> + <el-switch + v-model="scope.row.publicStatus" + :active-value="true" + :inactive-value="false" + @change="handleUpdatePublicStatusChange(scope.row)" + :disabled="scope.row.status !== AiImageStatusEnum.SUCCESS" + /> + </template> + </el-table-column> + <el-table-column label="提示词" align="center" prop="prompt" width="180" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="宽度" align="center" prop="width" /> + <el-table-column label="高度" align="center" prop="height" /> + <el-table-column label="错误信息" align="center" prop="errorMessage" /> + <el-table-column label="任务编号" align="center" prop="taskId" /> + <el-table-column label="操作" align="center" width="100" fixed="right"> + <template #default="scope"> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:image:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE, getStrDictOptions, getBoolDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { ImageApi, ImageVO } from '@/api/ai/image' +import * as UserApi from '@/api/system/user' +import { AiImageStatusEnum } from '@/views/ai/utils/constants' + +/** AI 绘画 列表 */ +defineOptions({ name: 'AiImageManager' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ImageVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: undefined, + platform: undefined, + status: undefined, + publicStatus: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ImageApi.getImagePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ImageApi.deleteImage(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 修改是否发布 */ +const handleUpdatePublicStatusChange = async (row: ImageVO) => { + try { + // 修改状态的二次确认 + const text = row.publicStatus ? '公开' : '私有' + await message.confirm('确认要"' + text + '"该图片吗?') + // 发起修改状态 + await ImageApi.updateImage({ + id: row.id, + publicStatus: row.publicStatus + }) + await getList() + } catch { + row.publicStatus = !row.publicStatus + } +} + +/** 初始化 **/ +onMounted(async () => { + getList() + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/ai/image/square/index.vue b/src/views/ai/image/square/index.vue new file mode 100644 index 0000000..3da6cde --- /dev/null +++ b/src/views/ai/image/square/index.vue @@ -0,0 +1,104 @@ +<template> + <div class="square-container"> + <!-- TODO @fan:style 建议换成 unocss --> + <!-- TODO @fan:Search 可以换成 Icon 组件么? --> + <el-input + v-model="queryParams.prompt" + style="width: 100%; margin-bottom: 20px" + size="large" + placeholder="请输入要搜索的内容" + :suffix-icon="Search" + @keyup.enter="handleQuery" + /> + <div class="gallery"> + <!-- TODO @fan:这个图片的风格,要不和 ImageCard.vue 界面一致?(只有卡片,没有操作);因为看着更有相框的感觉~~~ --> + <div v-for="item in list" :key="item.id" class="gallery-item"> + <img :src="item.picUrl" class="img" /> + </div> + </div> + <!-- TODO @fan:缺少翻页 --> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </div> +</template> +<script setup lang="ts"> +import { ImageApi, ImageVO } from '@/api/ai/image' +import { Search } from '@element-plus/icons-vue' + +// TODO @fan:加个 loading 加载中的状态 +const loading = ref(true) // 列表的加载中 +const list = ref<ImageVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + publicStatus: true, + prompt: undefined +}) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ImageApi.getImagePageMy(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 初始化 */ +onMounted(async () => { + await getList() +}) +</script> +<style scoped lang="scss"> +.square-container { + background-color: #fff; + padding: 20px; + + .gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 10px; + //max-width: 1000px; + background-color: #fff; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + } + + .gallery-item { + position: relative; + overflow: hidden; + background: #f0f0f0; + cursor: pointer; + transition: transform 0.3s; + } + + .gallery-item img { + width: 100%; + height: auto; + display: block; + transition: transform 0.3s; + } + + .gallery-item:hover img { + transform: scale(1.1); + } + + .gallery-item:hover { + transform: scale(1.05); + } +} +</style> diff --git a/src/views/ai/mindmap/index/components/Left.vue b/src/views/ai/mindmap/index/components/Left.vue new file mode 100644 index 0000000..e684b88 --- /dev/null +++ b/src/views/ai/mindmap/index/components/Left.vue @@ -0,0 +1,78 @@ +<template> + <div class="w-[350px] p-5 flex flex-col bg-[#f5f7f9]"> + <h3 class="w-full h-full h-7 text-5 text-center leading-[28px] title">思维导图创作中心</h3> + <!--下面表单部分--> + <div class="flex-grow overflow-y-auto"> + <div class="mt-[30ppx]"> + <el-text tag="b">您的需求?</el-text> + <el-input + v-model="formData.prompt" + maxlength="1024" + rows="5" + class="w-100% mt-15px" + input-style="border-radius: 7px;" + placeholder="请输入提示词,让AI帮你完善" + show-word-limit + type="textarea" + /> + <el-button + class="!w-full mt-[15px]" + type="primary" + :loading="isGenerating" + @click="emits('submit', formData)" + > + 智能生成思维导图 + </el-button> + </div> + <div class="mt-[30px]"> + <el-text tag="b">使用已有内容生成?</el-text> + <el-input + v-model="generatedContent" + maxlength="1024" + rows="5" + class="w-100% mt-15px" + input-style="border-radius: 7px;" + placeholder="例如:童话里的小屋应该是什么样子?" + show-word-limit + type="textarea" + /> + <el-button + class="!w-full mt-[15px]" + type="primary" + @click="emits('directGenerate', generatedContent)" + :disabled="isGenerating" + > + 直接生成 + </el-button> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { MindMapContentExample } from '@/views/ai/utils/constants' + +const emits = defineEmits(['submit', 'directGenerate']) +defineProps<{ + isGenerating: boolean +}>() +// 提交的提示词字段 +const formData = reactive({ + prompt: '' +}) + +const generatedContent = ref(MindMapContentExample) // 已有的内容 + +defineExpose({ + setGeneratedContent(newContent: string) { + // 设置已有的内容,在生成结束的时候将结果赋值给该值 + generatedContent.value = newContent + } +}) +</script> + +<style lang="scss" scoped> +.title { + color: var(--el-color-primary); +} +</style> diff --git a/src/views/ai/mindmap/index/components/Right.vue b/src/views/ai/mindmap/index/components/Right.vue new file mode 100644 index 0000000..dfb258a --- /dev/null +++ b/src/views/ai/mindmap/index/components/Right.vue @@ -0,0 +1,163 @@ +<template> + <el-card class="my-card h-full flex-grow"> + <template #header> + <h3 class="m-0 px-7 shrink-0 flex items-center justify-between"> + <span>思维导图预览</span> + <!-- 展示在右上角 --> + <el-button type="primary" v-show="isEnd" @click="downloadImage" size="small"> + <template #icon> + <Icon icon="ph:copy-bold" /> + </template> + 下载图片 + </el-button> + </h3> + </template> + + <div ref="contentRef" class="hide-scroll-bar h-full box-border"> + <!--展示 markdown 的容器,最终生成的是 html 字符串,直接用 v-html 嵌入--> + <div v-if="isGenerating" ref="mdContainerRef" class="wh-full overflow-y-auto"> + <div class="flex flex-col items-center justify-center" v-html="html"></div> + </div> + + <div ref="mindMapRef" class="wh-full"> + <svg ref="svgRef" class="w-full" :style="{ height: `${contentAreaHeight}px` }" /> + <div ref="toolBarRef" class="absolute bottom-[10px] right-5"></div> + </div> + </div> + </el-card> +</template> + +<script setup lang="ts"> +import { Markmap } from 'markmap-view' +import { Transformer } from 'markmap-lib' +import { Toolbar } from 'markmap-toolbar' +import markdownit from 'markdown-it' +import download from '@/utils/download' + +const md = markdownit() +const message = useMessage() // 消息弹窗 + +const props = defineProps<{ + generatedContent: string // 生成结果 + isEnd: boolean // 是否结束 + isGenerating: boolean // 是否正在生成 + isStart: boolean // 开始状态,开始时需要清除 html +}>() +const contentRef = ref<HTMLDivElement>() // 右侧出来 header 以下的区域 +const mdContainerRef = ref<HTMLDivElement>() // markdown 的容器,用来滚动到底下的 +const mindMapRef = ref<HTMLDivElement>() // 思维导图的容器 +const svgRef = ref<SVGElement>() // 思维导图的渲染 svg +const toolBarRef = ref<HTMLDivElement>() // 思维导图右下角的工具栏,缩放等 +const html = ref('') // 生成过程中的文本 +const contentAreaHeight = ref(0) // 生成区域的高度,出去 header 部分 +let markMap: Markmap | null = null +const transformer = new Transformer() + +onMounted(() => { + contentAreaHeight.value = contentRef.value?.clientHeight || 0 // 获取区域高度 + /** 初始化思维导图 **/ + try { + markMap = Markmap.create(svgRef.value!) + const { el } = Toolbar.create(markMap) + toolBarRef.value?.append(el) + nextTick(update) + } catch (e) { + message.error('思维导图初始化失败') + } +}) + +watch(props, ({ generatedContent, isGenerating, isEnd, isStart }) => { + // 开始生成的时候清空一下 markdown 的内容 + if (isStart) { + html.value = '' + } + // 生成内容的时候使用 markdown 来渲染 + if (isGenerating) { + html.value = md.render(generatedContent) + } + // 生成结束时更新思维导图 + if (isEnd) { + update() + } +}) + +/** 更新思维导图的展示 */ +const update = () => { + try { + const { root } = transformer.transform(processContent(props.generatedContent)) + markMap?.setData(root) + markMap?.fit() + } catch (e) { + console.error(e) + } +} + +/** 处理内容 */ +const processContent = (text: string) => { + const arr: string[] = [] + const lines = text.split('\n') + for (let line of lines) { + if (line.indexOf('```') !== -1) { + continue + } + line = line.replace(/([*_~`>])|(\d+\.)\s/g, '') + arr.push(line) + } + return arr.join('\n') +} + +/** 下载图片:download SVG to png file */ +const downloadImage = () => { + const svgElement = mindMapRef.value + // 将 SVG 渲染到图片对象 + const serializer = new XMLSerializer() + const source = `<?xml version="1.0" standalone="no"?>\r\n${serializer.serializeToString(svgRef.value!)}` + const base64Url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}` + download.image({ + url: base64Url, + canvasWidth: svgElement?.offsetWidth, + canvasHeight: svgElement?.offsetHeight, + drawWithImageSize: false + }) +} + +defineExpose({ + scrollBottom() { + mdContainerRef.value?.scrollTo(0, mdContainerRef.value?.scrollHeight) + } +}) +</script> +<style lang="scss" scoped> +.hide-scroll-bar { + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + width: 0; + height: 0; + } +} +.my-card { + display: flex; + flex-direction: column; + + :deep(.el-card__body) { + box-sizing: border-box; + flex-grow: 1; + overflow-y: auto; + padding: 0; + @extend .hide-scroll-bar; + } +} +// markmap的tool样式覆盖 +:deep(.markmap) { + width: 100%; +} +:deep(.mm-toolbar-brand) { + display: none; +} +:deep(.mm-toolbar) { + display: flex; + flex-direction: row; +} +</style> diff --git a/src/views/ai/mindmap/index/index.vue b/src/views/ai/mindmap/index/index.vue new file mode 100644 index 0000000..60a6b85 --- /dev/null +++ b/src/views/ai/mindmap/index/index.vue @@ -0,0 +1,92 @@ +<template> + <div class="absolute top-0 left-0 right-0 bottom-0 flex"> + <!--表单区域--> + <Left + ref="leftRef" + @submit="submit" + @direct-generate="directGenerate" + :is-generating="isGenerating" + /> + <!--右边生成思维导图区域--> + <Right + ref="rightRef" + :generatedContent="generatedContent" + :isEnd="isEnd" + :isGenerating="isGenerating" + :isStart="isStart" + /> + </div> +</template> + +<script setup lang="ts"> +import Left from './components/Left.vue' +import Right from './components/Right.vue' +import { AiMindMapApi, AiMindMapGenerateReqVO } from '@/api/ai/mindmap' +import { MindMapContentExample } from '@/views/ai/utils/constants' + +defineOptions({ + name: 'AiMindMap' +}) +const ctrl = ref<AbortController>() // 请求控制 +const isGenerating = ref(false) // 是否正在生成思维导图 +const isStart = ref(false) // 开始生成,用来清空思维导图 +const isEnd = ref(true) // 用来判断结束的时候渲染思维导图 +const message = useMessage() // 消息提示 + +const generatedContent = ref('') // 生成思维导图结果 + +const leftRef = ref<InstanceType<typeof Left>>() // 左边组件 +const rightRef = ref<InstanceType<typeof Right>>() // 右边组件 + +/** 使用已有内容直接生成 **/ +const directGenerate = (existPrompt: string) => { + isEnd.value = false // 先设置为 false 再设置为 true,让子组建的 watch 能够监听到 + generatedContent.value = existPrompt + isEnd.value = true +} + +/** 停止 stream 生成 */ +const stopStream = () => { + isGenerating.value = false + isStart.value = false + ctrl.value?.abort() +} + +/** 提交生成 */ +const submit = (data: AiMindMapGenerateReqVO) => { + isGenerating.value = true + isStart.value = true + isEnd.value = false + ctrl.value = new AbortController() // 请求控制赋值 + generatedContent.value = '' // 清空生成数据 + AiMindMapApi.generateMindMap({ + data, + onMessage: async (res) => { + const { code, data, msg } = JSON.parse(res.data) + if (code !== 0) { + message.alert(`生成思维导图异常! ${msg}`) + stopStream() + return + } + generatedContent.value = generatedContent.value + data + await nextTick() + rightRef.value?.scrollBottom() + }, + onClose() { + isEnd.value = true + leftRef.value?.setGeneratedContent(generatedContent.value) + stopStream() + }, + onError(err) { + console.error('生成思维导图失败', err) + stopStream() + }, + ctrl: ctrl.value + }) +} + +/** 初始化 */ +onMounted(() => { + generatedContent.value = MindMapContentExample +}) +</script> diff --git a/src/views/ai/mindmap/manager/index.vue b/src/views/ai/mindmap/manager/index.vue new file mode 100644 index 0000000..008a2e9 --- /dev/null +++ b/src/views/ai/mindmap/manager/index.vue @@ -0,0 +1,189 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-select + v-model="queryParams.userId" + clearable + placeholder="请输入用户编号" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="提示词" prop="prompt"> + <el-input + v-model="queryParams.prompt" + placeholder="请输入提示词" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" width="180" fixed="left" /> + <el-table-column label="用户" align="center" prop="userId" width="180"> + <template #default="scope"> + <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span> + </template> + </el-table-column> + <el-table-column label="提示词" align="center" prop="prompt" width="180" /> + <el-table-column label="思维导图" align="center" prop="generatedContent" min-width="300" /> + <el-table-column label="模型" align="center" prop="model" width="180" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="错误信息" align="center" prop="errorMessage" /> + <el-table-column label="操作" align="center" width="120" fixed="right"> + <template #default="scope"> + <el-button link type="primary" @click="openPreview(scope.row)"> 预览 </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:mind-map:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 思维导图的预览 --> + <el-drawer v-model="previewVisible" :with-header="false" size="800px"> + <Right + v-if="previewVisible2" + :generatedContent="previewContent" + :isEnd="true" + :isGenerating="false" + :isStart="false" + /> + </el-drawer> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import { AiMindMapApi, MindMapVO } from '@/api/ai/mindmap' +import * as UserApi from '@/api/system/user' +import Right from '@/views/ai/mindmap/index/components/Right.vue' + +/** AI 思维导图 列表 */ +defineOptions({ name: 'AiMindMapManager' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<MindMapVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: undefined, + prompt: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await AiMindMapApi.getMindMapPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await AiMindMapApi.deleteMindMap(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 预览操作按钮 */ +const previewVisible = ref(false) // drawer 的显示隐藏 +const previewVisible2 = ref(false) // right 的显示隐藏 +const previewContent = ref('') +const openPreview = async (row: MindMapVO) => { + previewVisible2.value = false + previewVisible.value = true + // 在 drawer 渲染完后,再渲染 right 预览,不然会报错,需要保证 width 宽度先出来 + await nextTick() + previewVisible2.value = true + previewContent.value = row.generatedContent +} + +/** 初始化 **/ +onMounted(async () => { + getList() + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/ai/model/apiKey/ApiKeyForm.vue b/src/views/ai/model/apiKey/ApiKeyForm.vue new file mode 100644 index 0000000..a8fc012 --- /dev/null +++ b/src/views/ai/model/apiKey/ApiKeyForm.vue @@ -0,0 +1,132 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label="所属平台" prop="platform"> + <el-select v-model="formData.platform" placeholder="请输入平台" clearable> + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + <el-form-item label="密钥" prop="apiKey"> + <el-input v-model="formData.apiKey" placeholder="请输入密钥" /> + </el-form-item> + <el-form-item label="自定义 API URL" prop="url"> + <el-input v-model="formData.url" placeholder="请输入自定义 API URL" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE, getStrDictOptions } from '@/utils/dict' +import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey' +import { CommonStatusEnum } from '@/utils/constants' + +/** AI API 密钥 表单 */ +defineOptions({ name: 'ApiKeyForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + apiKey: undefined, + platform: undefined, + url: undefined, + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive({ + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }], + apiKey: [{ required: true, message: '密钥不能为空', trigger: 'blur' }], + platform: [{ required: true, message: '平台不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ApiKeyApi.getApiKey(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ApiKeyVO + if (formType.value === 'create') { + await ApiKeyApi.createApiKey(data) + message.success(t('common.createSuccess')) + } else { + await ApiKeyApi.updateApiKey(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + apiKey: undefined, + platform: undefined, + url: undefined, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/ai/model/apiKey/index.vue b/src/views/ai/model/apiKey/index.vue new file mode 100644 index 0000000..6daf6a7 --- /dev/null +++ b/src/views/ai/model/apiKey/index.vue @@ -0,0 +1,180 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="平台" prop="platform"> + <el-select + v-model="queryParams.platform" + placeholder="请输入平台" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['ai:api-key:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="所属平台" align="center" prop="platform"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" /> + </template> + </el-table-column> + <el-table-column label="名称" align="center" prop="name" /> + <el-table-column label="密钥" align="center" prop="apiKey" /> + <el-table-column label="自定义 API URL" align="center" prop="url" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['ai:api-key:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:api-key:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ApiKeyForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE, getStrDictOptions } from '@/utils/dict' +import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey' +import ApiKeyForm from './ApiKeyForm.vue' + +/** AI API 密钥 列表 */ +defineOptions({ name: 'AiApiKey' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ApiKeyVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + platform: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ApiKeyApi.getApiKeyPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ApiKeyApi.deleteApiKey(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/ai/model/chatModel/ChatModelForm.vue b/src/views/ai/model/chatModel/ChatModelForm.vue new file mode 100644 index 0000000..e3f785c --- /dev/null +++ b/src/views/ai/model/chatModel/ChatModelForm.vue @@ -0,0 +1,181 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label="所属平台" prop="platform"> + <el-select v-model="formData.platform" placeholder="请输入平台" clearable> + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="API 秘钥" prop="keyId"> + <el-select v-model="formData.keyId" placeholder="请选择 API 秘钥" clearable> + <el-option + v-for="apiKey in apiKeyList" + :key="apiKey.id" + :label="apiKey.name" + :value="apiKey.id" + /> + </el-select> + </el-form-item> + <el-form-item label="模型名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入模型名字" /> + </el-form-item> + <el-form-item label="模型标识" prop="model"> + <el-input v-model="formData.model" placeholder="请输入模型标识" /> + </el-form-item> + <el-form-item label="模型排序" prop="sort"> + <el-input-number v-model="formData.sort" placeholder="请输入模型排序" class="!w-1/1" /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="温度参数" prop="temperature"> + <el-input-number + v-model="formData.temperature" + placeholder="请输入温度参数" + :min="0" + :max="2" + :precision="2" + /> + </el-form-item> + <el-form-item label="回复数 Token 数" prop="maxTokens"> + <el-input-number + v-model="formData.maxTokens" + placeholder="请输入回复数 Token 数" + :min="0" + :max="4096" + /> + </el-form-item> + <el-form-item label="上下文数量" prop="maxContexts"> + <el-input-number + v-model="formData.maxContexts" + placeholder="请输入上下文数量" + :min="0" + :max="20" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel' +import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey' +import { CommonStatusEnum } from '@/utils/constants' +import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict' + +/** API 聊天模型 表单 */ +defineOptions({ name: 'ChatModelForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + keyId: undefined, + name: undefined, + model: undefined, + platform: undefined, + sort: undefined, + status: CommonStatusEnum.ENABLE, + temperature: undefined, + maxTokens: undefined, + maxContexts: undefined +}) +const formRules = reactive({ + keyId: [{ required: true, message: 'API 秘钥不能为空', trigger: 'blur' }], + name: [{ required: true, message: '模型名字不能为空', trigger: 'blur' }], + model: [{ required: true, message: '模型标识不能为空', trigger: 'blur' }], + platform: [{ required: true, message: '所属平台不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const apiKeyList = ref([] as ApiKeyVO[]) // API 密钥列表 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ChatModelApi.getChatModel(id) + } finally { + formLoading.value = false + } + } + // 获得下拉数据 + apiKeyList.value = await ApiKeyApi.getApiKeySimpleList(CommonStatusEnum.ENABLE) +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ChatModelVO + if (formType.value === 'create') { + await ChatModelApi.createChatModel(data) + message.success(t('common.createSuccess')) + } else { + await ChatModelApi.updateChatModel(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + keyId: undefined, + name: undefined, + model: undefined, + platform: undefined, + sort: undefined, + status: CommonStatusEnum.ENABLE, + temperature: undefined, + maxTokens: undefined, + maxContexts: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/ai/model/chatModel/index.vue b/src/views/ai/model/chatModel/index.vue new file mode 100644 index 0000000..c550674 --- /dev/null +++ b/src/views/ai/model/chatModel/index.vue @@ -0,0 +1,185 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="模型名字" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入模型名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="模型标识" prop="model"> + <el-input + v-model="queryParams.model" + placeholder="请输入模型标识" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="模型平台" prop="platform"> + <el-input + v-model="queryParams.platform" + placeholder="请输入模型平台" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['ai:chat-model:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="所属平台" align="center" prop="platform"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" /> + </template> + </el-table-column> + <el-table-column label="模型名字" align="center" prop="name" /> + <el-table-column label="模型标识" align="center" prop="model" /> + <el-table-column label="API 秘钥" align="center" prop="keyId" min-width="140"> + <template #default="scope"> + <span>{{ apiKeyList.find((item) => item.id === scope.row.keyId)?.name }}</span> + </template> + </el-table-column> + <el-table-column label="排序" align="center" prop="sort" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="温度参数" align="center" prop="temperature" /> + <el-table-column label="回复数 Token 数" align="center" prop="maxTokens" min-width="140" /> + <el-table-column label="上下文数量" align="center" prop="maxContexts" /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['ai:chat-model:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:chat-model:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ChatModelForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel' +import ChatModelForm from './ChatModelForm.vue' +import { DICT_TYPE } from '@/utils/dict' +import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey' + +/** API 聊天模型 列表 */ +defineOptions({ name: 'AiChatModel' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ChatModelVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + model: undefined, + platform: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const apiKeyList = ref([] as ApiKeyVO[]) // API 密钥列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ChatModelApi.getChatModelPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ChatModelApi.deleteChatModel(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + getList() + // 获得下拉数据 + apiKeyList.value = await ApiKeyApi.getApiKeySimpleList() +}) +</script> diff --git a/src/views/ai/model/chatRole/ChatRoleForm.vue b/src/views/ai/model/chatRole/ChatRoleForm.vue new file mode 100644 index 0000000..3c49e8e --- /dev/null +++ b/src/views/ai/model/chatRole/ChatRoleForm.vue @@ -0,0 +1,183 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="角色名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入角色名称" /> + </el-form-item> + <el-form-item label="角色头像" prop="avatar"> + <UploadImg v-model="formData.avatar" height="60px" width="60px" /> + </el-form-item> + <el-form-item label="绑定模型" prop="modelId" v-if="!isUser"> + <el-select v-model="formData.modelId" placeholder="请选择模型" clearable> + <el-option + v-for="chatModel in chatModelList" + :key="chatModel.id" + :label="chatModel.name" + :value="chatModel.id" + /> + </el-select> + </el-form-item> + <el-form-item label="角色类别" prop="category" v-if="!isUser"> + <el-input v-model="formData.category" placeholder="请输入角色类别" /> + </el-form-item> + <el-form-item label="角色描述" prop="description"> + <el-input type="textarea" v-model="formData.description" placeholder="请输入角色描述" /> + </el-form-item> + <el-form-item label="角色设定" prop="systemMessage"> + <el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" /> + </el-form-item> + <el-form-item label="是否公开" prop="publicStatus" v-if="!isUser"> + <el-radio-group v-model="formData.publicStatus"> + <el-radio + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="角色排序" prop="sort" v-if="!isUser"> + <el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1" /> + </el-form-item> + <el-form-item label="开启状态" prop="status" v-if="!isUser"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict' +import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole' +import { CommonStatusEnum } from '@/utils/constants' +import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel' +import {FormRules} from "element-plus"; + +/** AI 聊天角色 表单 */ +defineOptions({ name: 'ChatRoleForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + modelId: undefined, + name: undefined, + avatar: undefined, + category: undefined, + sort: undefined, + description: undefined, + systemMessage: undefined, + publicStatus: true, + status: CommonStatusEnum.ENABLE +}) +const formRef = ref() // 表单 Ref +const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表 + +/** 是否【我】自己创建,私有角色 */ +const isUser = computed(() => { + return formType.value === 'my-create' || formType.value === 'my-update' +}) + +const formRules = reactive<FormRules>({ + name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }], + avatar: [{ required: true, message: '角色头像不能为空', trigger: 'blur' }], + category: [{ required: true, message: '角色类别不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '角色排序不能为空', trigger: 'blur' }], + description: [{ required: true, message: '角色描述不能为空', trigger: 'blur' }], + systemMessage: [{ required: true, message: '角色设定不能为空', trigger: 'blur' }], + publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }] +}) + +/** 打开弹窗 */ +// TODO @fan:title 是不是收敛到 type 判断生成 title,会更合理 +const open = async (type: string, id?: number, title?: string) => { + dialogVisible.value = true + dialogTitle.value = title || t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ChatRoleApi.getChatRole(id) + } finally { + formLoading.value = false + } + } + // 获得下拉数据 + chatModelList.value = await ChatModelApi.getChatModelSimpleList(CommonStatusEnum.ENABLE) +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ChatRoleVO + // tip: my-create、my-update 是 chat 角色仓库调用 + // tip: create、else 是后台管理调用 + if (formType.value === 'my-create') { + await ChatRoleApi.createMy(data) + message.success(t('common.createSuccess')) + } else if (formType.value === 'my-update') { + await ChatRoleApi.updateMy(data) + message.success(t('common.updateSuccess')) + } else if (formType.value === 'create') { + await ChatRoleApi.createChatRole(data) + message.success(t('common.createSuccess')) + } else { + await ChatRoleApi.updateChatRole(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + modelId: undefined, + name: undefined, + avatar: undefined, + category: undefined, + sort: undefined, + description: undefined, + systemMessage: undefined, + publicStatus: true, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/ai/model/chatRole/index.vue b/src/views/ai/model/chatRole/index.vue new file mode 100644 index 0000000..e870a55 --- /dev/null +++ b/src/views/ai/model/chatRole/index.vue @@ -0,0 +1,187 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="角色名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入角色名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="角色类别" prop="category"> + <el-input + v-model="queryParams.category" + placeholder="请输入角色类别" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="是否公开" prop="publicStatus"> + <el-select + v-model="queryParams.publicStatus" + placeholder="请选择是否公开" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['ai:chat-role:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="角色名称" align="center" prop="name" /> + <el-table-column label="绑定模型" align="center" prop="modelName" /> + <el-table-column label="角色头像" align="center" prop="avatar"> + <template #default="scope"> + <el-image :src="scope?.row.avatar" class="w-32px h-32px" /> + </template> + </el-table-column> + <el-table-column label="角色类别" align="center" prop="category" /> + <el-table-column label="角色描述" align="center" prop="description" /> + <el-table-column label="角色设定" align="center" prop="systemMessage" /> + <el-table-column label="是否公开" align="center" prop="publicStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.publicStatus" /> + </template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="角色排序" align="center" prop="sort" /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['ai:chat-role:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:chat-role:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ChatRoleForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getBoolDictOptions, DICT_TYPE } from '@/utils/dict' +import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole' +import ChatRoleForm from './ChatRoleForm.vue' + +/** AI 聊天角色 列表 */ +defineOptions({ name: 'AiChatRole' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ChatRoleVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + category: undefined, + publicStatus: true +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ChatRoleApi.getChatRolePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ChatRoleApi.deleteChatRole(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/ai/music/index/index.vue b/src/views/ai/music/index/index.vue new file mode 100644 index 0000000..413792a --- /dev/null +++ b/src/views/ai/music/index/index.vue @@ -0,0 +1,26 @@ +<template> +<div class="flex h-full items-stretch"> + <!-- 模式 --> + <Mode class="flex-none" @generate-music="generateMusic"/> + <!-- 音频列表 --> + <List ref="listRef" class="flex-auto"/> + </div> +</template> + +<script lang="ts" setup> +import Mode from './mode/index.vue' +import List from './list/index.vue' + +defineOptions({ name: 'Index' }) + +const listRef = ref<Nullable<{generateMusic: (...args) => void}>>(null) + +/* + *@Description: 拿到左侧配置信息调用右侧音乐生成的方法 + *@MethodAuthor: xiaohong + *@Date: 2024-07-19 11:13:38 +*/ +function generateMusic (args: {formData: Recordable}) { + unref(listRef)?.generateMusic(args.formData) +} +</script> diff --git a/src/views/ai/music/index/list/audioBar/index.vue b/src/views/ai/music/index/list/audioBar/index.vue new file mode 100644 index 0000000..db7f767 --- /dev/null +++ b/src/views/ai/music/index/list/audioBar/index.vue @@ -0,0 +1,70 @@ +<template> + <div class="flex items-center justify-between px-2 h-72px bg-[var(--el-bg-color-overlay)] b-solid b-1 b-[var(--el-border-color)] b-l-none"> + <!-- 歌曲信息 --> + <div class="flex gap-[10px]"> + <el-image src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png" class="w-[45px]"/> + <div> + <div>{{currentSong.name}}</div> + <div class="text-[12px] text-gray-400">{{currentSong.singer}}</div> + </div> + </div> + + <!-- 音频controls --> + <div class="flex gap-[12px] items-center"> + <Icon icon="majesticons:back-circle" :size="20" class="text-gray-300 cursor-pointer"/> + <Icon :icon="audioProps.paused ? 'mdi:arrow-right-drop-circle' : 'solar:pause-circle-bold'" :size="30" class=" cursor-pointer" @click="toggleStatus('paused')"/> + <Icon icon="majesticons:next-circle" :size="20" class="text-gray-300 cursor-pointer"/> + <div class="flex gap-[16px] items-center"> + <span>{{audioProps.currentTime}}</span> + <el-slider v-model="audioProps.duration" color="#409eff" class="w-[160px!important] "/> + <span>{{ audioProps.duration }}</span> + </div> + <!-- 音频 --> + <audio v-bind="audioProps" ref="audioRef" controls v-show="!audioProps" @timeupdate="audioTimeUpdate"> + <source :src="audioUrl"/> + </audio> + </div> + + <!-- 音量控制器 --> + <div class="flex gap-[16px] items-center"> + <Icon :icon="audioProps.muted ? 'tabler:volume-off' : 'tabler:volume'" :size="20" class="cursor-pointer" @click="toggleStatus('muted')"/> + <el-slider v-model="audioProps.volume" color="#409eff" class="w-[160px!important] "/> + </div> + </div> +</template> + +<script lang="ts" setup> +import { formatPast } from '@/utils/formatTime' +import audioUrl from '@/assets/audio/response.mp3' + +defineOptions({ name: 'Index' }) + +const currentSong = inject('currentSong', {}) + +const audioRef = ref<Nullable<HTMLElement>>(null) + // 音频相关属性https://www.runoob.com/tags/ref-av-dom.html +const audioProps = reactive({ + autoplay: true, + paused: false, + currentTime: '00:00', + duration: '00:00', + muted: false, + volume: 50, +}) + +function toggleStatus (type: string) { + audioProps[type] = !audioProps[type] + if (type === 'paused' && audioRef.value) { + if (audioProps[type]) { + audioRef.value.pause() + } else { + audioRef.value.play() + } + } +} + +// 更新播放位置 +function audioTimeUpdate (args) { + audioProps.currentTime = formatPast(new Date(args.timeStamp), 'mm:ss') +} +</script> diff --git a/src/views/ai/music/index/list/index.vue b/src/views/ai/music/index/list/index.vue new file mode 100644 index 0000000..6c33f56 --- /dev/null +++ b/src/views/ai/music/index/list/index.vue @@ -0,0 +1,108 @@ +<template> + <div class="flex flex-col"> + <div class="flex-auto flex overflow-hidden"> + <el-tabs v-model="currentType" class="flex-auto px-[var(--app-content-padding)]"> + <!-- 我的创作 --> + <el-tab-pane v-loading="loading" label="我的创作" name="mine"> + <el-row v-if="mySongList.length" :gutter="12"> + <el-col v-for="song in mySongList" :key="song.id" :span="24"> + <songCard :songInfo="song" @play="setCurrentSong(song)"/> + </el-col> + </el-row> + <el-empty v-else description="暂无音乐"/> + </el-tab-pane> + + <!-- 试听广场 --> + <el-tab-pane v-loading="loading" label="试听广场" name="square"> + <el-row v-if="squareSongList.length" v-loading="loading" :gutter="12"> + <el-col v-for="song in squareSongList" :key="song.id" :span="24"> + <songCard :songInfo="song" @play="setCurrentSong(song)"/> + </el-col> + </el-row> + <el-empty v-else description="暂无音乐"/> + </el-tab-pane> + </el-tabs> + <!-- songInfo --> + <songInfo class="flex-none"/> + </div> + <audioBar class="flex-none"/> + </div> +</template> + +<script lang="ts" setup> +import songCard from './songCard/index.vue' +import songInfo from './songInfo/index.vue' +import audioBar from './audioBar/index.vue' + +defineOptions({ name: 'Index' }) + + +const currentType = ref('mine') +// loading 状态 +const loading = ref(false) +// 当前音乐 +const currentSong = ref({}) + +const mySongList = ref<Recordable[]>([]) +const squareSongList = ref<Recordable[]>([]) + +provide('currentSong', currentSong) + +/* + *@Description: 调接口生成音乐列表 + *@MethodAuthor: xiaohong + *@Date: 2024-06-27 17:06:44 +*/ +function generateMusic (formData: Recordable) { + console.log(formData); + loading.value = true + setTimeout(() => { + mySongList.value = Array.from({ length: 20 }, (_, index) => { + return { + id: index, + audioUrl: '', + videoUrl: '', + title: '我走后' + index, + imageUrl: 'https://www.carsmp3.com/data/attachment/forum/201909/19/091020q5kgre20fidreqyt.jpg', + desc: 'Metal, symphony, film soundtrack, grand, majesticMetal, dtrack, grand, majestic', + date: '2024年04月30日 14:02:57', + lyric: `<div class="_words_17xen_66"><div>大江东去,浪淘尽,千古风流人物。 + </div><div>故垒西边,人道是,三国周郎赤壁。 + </div><div>乱石穿空,惊涛拍岸,卷起千堆雪。 + </div><div>江山如画,一时多少豪杰。 + </div><div> + </div><div>遥想公瑾当年,小乔初嫁了,雄姿英发。 + </div><div>羽扇纶巾,谈笑间,樯橹灰飞烟灭。 + </div><div>故国神游,多情应笑我,早生华发。 + </div><div>人生如梦,一尊还酹江月。</div></div>` + } + }) + loading.value = false + }, 3000) +} + +/* + *@Description: 设置当前播放的音乐 + *@MethodAuthor: xiaohong + *@Date: 2024-07-19 11:22:33 +*/ +function setCurrentSong (music: Recordable) { + currentSong.value = music +} + +defineExpose({ + generateMusic +}) +</script> + + +<style lang="scss" scoped> +:deep(.el-tabs) { + display: flex; + flex-direction: column; + .el-tabs__content { + padding: 0 7px; + overflow: auto; + } +} +</style> diff --git a/src/views/ai/music/index/list/songCard/index.vue b/src/views/ai/music/index/list/songCard/index.vue new file mode 100644 index 0000000..0534251 --- /dev/null +++ b/src/views/ai/music/index/list/songCard/index.vue @@ -0,0 +1,36 @@ +<template> + <div class="flex bg-[var(--el-bg-color-overlay)] p-12px mb-12px rounded-1"> + <div class="relative" @click="playSong"> + <el-image :src="songInfo.imageUrl" class="flex-none w-80px"/> + <div class="bg-black bg-op-40 absolute top-0 left-0 w-full h-full flex items-center justify-center cursor-pointer"> + <Icon :icon="currentSong.id === songInfo.id ? 'solar:pause-circle-bold':'mdi:arrow-right-drop-circle'" :size="30" /> + </div> + </div> + <div class="ml-8px"> + <div>{{ songInfo.title }}</div> + <div class="mt-8px text-12px text-[var(--el-text-color-secondary)] line-clamp-2"> + {{ songInfo.desc }} + </div> + </div> + </div> +</template> + +<script lang="ts" setup> + +defineOptions({ name: 'Index' }) + +defineProps({ + songInfo: { + type: Object, + default: () => ({}) + } +}) + +const emits = defineEmits(['play']) + +const currentSong = inject('currentSong', {}) + +function playSong () { + emits('play') +} +</script> diff --git a/src/views/ai/music/index/list/songInfo/index.vue b/src/views/ai/music/index/list/songInfo/index.vue new file mode 100644 index 0000000..8d67c4d --- /dev/null +++ b/src/views/ai/music/index/list/songInfo/index.vue @@ -0,0 +1,22 @@ +<template> + <ContentWrap class="w-300px mb-[0!important] line-height-24px"> + <el-image :src="currentSong.imageUrl"/> + <div class="">{{ currentSong.title }}</div> + <div class="text-[var(--el-text-color-secondary)] text-12px line-clamp-1"> + {{ currentSong.desc }} + </div> + <div class="text-[var(--el-text-color-secondary)] text-12px"> + {{ currentSong.date }} + </div> + <el-button size="small" round class="my-6px">信息复用</el-button> + <div class="text-[var(--el-text-color-secondary)] text-12px" v-html="currentSong.lyric"></div> + </ContentWrap> +</template> + +<script lang="ts" setup> + +defineOptions({ name: 'Index' }) + +const currentSong = inject('currentSong', {}) + +</script> diff --git a/src/views/ai/music/index/mode/desc.vue b/src/views/ai/music/index/mode/desc.vue new file mode 100644 index 0000000..4488461 --- /dev/null +++ b/src/views/ai/music/index/mode/desc.vue @@ -0,0 +1,55 @@ +<template> + <div> + <Title title="音乐/歌词说明" desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲"> + <el-input + v-model="formData.desc" + :autosize="{ minRows: 6, maxRows: 6}" + resize="none" + type="textarea" + maxlength="1200" + show-word-limit + placeholder="一首关于糟糕分手的欢快歌曲" + /> + </Title> + + <Title title="纯音乐" desc="创建一首没有歌词的歌曲"> + <template #extra> + <el-switch v-model="formData.pure" size="small"/> + </template> + </Title> + + <Title title="版本" desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲"> + <el-select v-model="formData.version" placeholder="请选择"> + <el-option + v-for="item in [{ + value: '3', + label: 'V3' + }, { + value: '2', + label: 'V2' + }]" + :key="item.value" + :label="item.label" + :value="item.value" + /> + </el-select> + </Title> + </div> +</template> + +<script lang="ts" setup> +import Title from '../title/index.vue' + +defineOptions({ name: 'Desc' }) + +const formData = reactive({ + desc: '', + pure: false, + version: '3' +}) + +defineExpose({ + formData +}) + +</script> diff --git a/src/views/ai/music/index/mode/index.vue b/src/views/ai/music/index/mode/index.vue new file mode 100644 index 0000000..85ef893 --- /dev/null +++ b/src/views/ai/music/index/mode/index.vue @@ -0,0 +1,41 @@ +<template> + <ContentWrap class="w-300px h-full mb-[0!important]"> + <el-radio-group v-model="generateMode" class="mb-15px"> + <el-radio-button label="desc"> + 描述模式 + </el-radio-button> + <el-radio-button label="lyric"> + 歌词模式 + </el-radio-button> + </el-radio-group> + + <!-- 描述模式/歌词模式 切换 --> + <component :is="generateMode === 'desc' ? desc : lyric" ref="modeRef"/> + + <el-button type="primary" round class="w-full" @click="generateMusic"> + 创作音乐 + </el-button> + </ContentWrap> +</template> + +<script lang="ts" setup> +import desc from './desc.vue' +import lyric from './lyric.vue' + +defineOptions({ name: 'Index' }) + +const emits = defineEmits(['generate-music']) + +const generateMode = ref('lyric') + +const modeRef = ref<Nullable<{ formData: Recordable }>>(null) + +/* + *@Description: 根据信息生成音乐 + *@MethodAuthor: xiaohong + *@Date: 2024-06-27 16:40:16 +*/ +function generateMusic () { + emits('generate-music', {formData: unref(modeRef)?.formData}) +} +</script> diff --git a/src/views/ai/music/index/mode/lyric.vue b/src/views/ai/music/index/mode/lyric.vue new file mode 100644 index 0000000..f774003 --- /dev/null +++ b/src/views/ai/music/index/mode/lyric.vue @@ -0,0 +1,83 @@ +<template> + <div class=""> + <Title title="歌词" desc="自己编写歌词或使用Ai生成歌词,两节/8行效果最佳"> + <el-input + v-model="formData.lyric" + :autosize="{ minRows: 6, maxRows: 6}" + resize="none" + type="textarea" + maxlength="1200" + show-word-limit + placeholder="请输入您自己的歌词" + /> + </Title> + + <Title title="音乐风格"> + <el-space class="flex-wrap"> + <el-tag v-for="tag in tags" :key="tag" round class="mb-8px">{{tag}}</el-tag> + </el-space> + + <el-button + :type="showCustom ? 'primary': 'default'" + round + size="small" + class="mb-6px" + @click="showCustom = !showCustom" + >自定义风格 + </el-button> + </Title> + + <Title v-show="showCustom" desc="描述您想要的音乐风格,Suno无法识别艺术家的名字,但可以理解流派和氛围" class="-mt-12px"> + <el-input + v-model="formData.style" + :autosize="{ minRows: 4, maxRows: 4}" + resize="none" + type="textarea" + maxlength="256" + show-word-limit + placeholder="输入音乐风格(英文)" + /> + </Title> + + <Title title="音乐/歌曲名称"> + <el-input v-model="formData.name" placeholder="请输入音乐/歌曲名称"/> + </Title> + + <Title title="版本"> + <el-select v-model="formData.version" placeholder="请选择"> + <el-option + v-for="item in [{ + value: '3', + label: 'V3' + }, { + value: '2', + label: 'V2' + }]" + :key="item.value" + :label="item.label" + :value="item.value" + /> + </el-select> + </Title> + </div> +</template> + +<script lang="ts" setup> +import Title from '../title/index.vue' +defineOptions({ name: 'Lyric' }) + +const tags = ['rock', 'punk', 'jazz', 'soul', 'country', 'kidsmusic', 'pop'] + +const showCustom = ref(false) + +const formData = reactive({ + lyric: '', + style: '', + name: '', + version: '' +}) + +defineExpose({ + formData +}) +</script> diff --git a/src/views/ai/music/index/title/index.vue b/src/views/ai/music/index/title/index.vue new file mode 100644 index 0000000..a065802 --- /dev/null +++ b/src/views/ai/music/index/title/index.vue @@ -0,0 +1,25 @@ +<template> + <div class="mb-12px"> + <div class="flex text-[var(--el-text-color-primary)] justify-between items-center"> + <span>{{title}}</span> + <slot name="extra"></slot> + </div> + <div class="text-[var(--el-text-color-secondary)] text-12px my-8px"> + {{desc}} + </div> + <slot></slot> + </div> +</template> + +<script lang="ts" setup> +defineOptions({ name: 'Index' }) + +defineProps({ + title: { + type: String + }, + desc: { + type: String + } +}) +</script> diff --git a/src/views/ai/music/manager/index.vue b/src/views/ai/music/manager/index.vue new file mode 100644 index 0000000..462a88d --- /dev/null +++ b/src/views/ai/music/manager/index.vue @@ -0,0 +1,292 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-select + v-model="queryParams.userId" + clearable + placeholder="请输入用户编号" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="音乐名称" prop="title"> + <el-input + v-model="queryParams.title" + placeholder="请输入音乐名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="音乐状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择音乐状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.AI_MUSIC_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="生成模式" prop="generateMode"> + <el-select + v-model="queryParams.generateMode" + placeholder="请选择生成模式" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.AI_GENERATE_MODE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="是否发布" prop="publicStatus"> + <el-select + v-model="queryParams.publicStatus" + placeholder="请选择是否发布" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" width="180" fixed="left" /> + <el-table-column label="音乐名称" align="center" prop="title" width="180px" fixed="left" /> + <el-table-column label="用户" align="center" prop="userId" width="180"> + <template #default="scope"> + <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span> + </template> + </el-table-column> + <el-table-column label="音乐状态" align="center" prop="status" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_MUSIC_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="模型" align="center" prop="model" width="180" /> + <el-table-column label="内容" align="center" width="180"> + <template #default="{ row }"> + <el-link + v-if="row.audioUrl?.length > 0" + type="primary" + :href="row.audioUrl" + target="_blank" + > + 音乐 + </el-link> + <el-link + v-if="row.videoUrl?.length > 0" + type="primary" + :href="row.videoUrl" + target="_blank" + class="!pl-5px" + > + 视频 + </el-link> + <el-link + v-if="row.imageUrl?.length > 0" + type="primary" + :href="row.imageUrl" + target="_blank" + class="!pl-5px" + > + 封面 + </el-link> + </template> + </el-table-column> + <el-table-column label="时长(秒)" align="center" prop="duration" width="100" /> + <el-table-column label="提示词" align="center" prop="prompt" width="180" /> + <el-table-column label="歌词" align="center" prop="lyric" width="180" /> + <el-table-column label="描述" align="center" prop="gptDescriptionPrompt" width="180" /> + <el-table-column label="生成模式" align="center" prop="generateMode" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_GENERATE_MODE" :value="scope.row.generateMode" /> + </template> + </el-table-column> + <el-table-column label="风格标签" align="center" prop="tags" width="180"> + <template #default="scope"> + <el-tag v-for="tag in scope.row.tags" :key="tag" round class="ml-2px"> + {{ tag }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="是否发布" align="center" prop="publicStatus"> + <template #default="scope"> + <el-switch + v-model="scope.row.publicStatus" + :active-value="true" + :inactive-value="false" + @change="handleUpdatePublicStatusChange(scope.row)" + :disabled="scope.row.status !== AiMusicStatusEnum.SUCCESS" + /> + </template> + </el-table-column> + <el-table-column label="任务编号" align="center" prop="taskId" width="180" /> + <el-table-column label="错误信息" align="center" prop="errorMessage" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="100" fixed="right"> + <template #default="scope"> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:music:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { MusicApi, MusicVO } from '@/api/ai/music' +import * as UserApi from '@/api/system/user' +import { AiMusicStatusEnum } from '@/views/ai/utils/constants' + +/** AI 音乐 列表 */ +defineOptions({ name: 'AiMusicManager' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<MusicVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: undefined, + title: undefined, + status: undefined, + generateMode: undefined, + createTime: [], + publicStatus: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await MusicApi.getMusicPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await MusicApi.deleteMusic(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 修改是否发布 */ +const handleUpdatePublicStatusChange = async (row: MusicVO) => { + try { + // 修改状态的二次确认 + const text = row.publicStatus ? '公开' : '私有' + await message.confirm('确认要"' + text + '"该音乐吗?') + // 发起修改状态 + await MusicApi.updateMusic({ + id: row.id, + publicStatus: row.publicStatus + }) + await getList() + } catch { + row.publicStatus = !row.publicStatus + } +} + +/** 初始化 **/ +onMounted(async () => { + getList() + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/ai/utils/constants.ts b/src/views/ai/utils/constants.ts new file mode 100644 index 0000000..be4da7b --- /dev/null +++ b/src/views/ai/utils/constants.ts @@ -0,0 +1,481 @@ +/** + * Created by 芋道源码 + * + * AI 枚举类 + * + * 问题:为什么不放在 src/utils/constants.ts 呢? + * 回答:主要 AI 是可选模块,考虑到独立、解耦,所以放在了 /views/ai/utils/constants.ts + */ + +/** + * AI 平台的枚举 + */ +export const AiPlatformEnum = { + TONG_YI: 'TongYi', // 阿里 + YI_YAN: 'YiYan', // 百度 + DEEP_SEEK: 'DeepSeek', // DeepSeek + ZHI_PU: 'ZhiPu', // 智谱 AI + XING_HUO: 'XingHuo', // 讯飞 + OPENAI: 'OpenAI', + Ollama: 'Ollama', + STABLE_DIFFUSION: 'StableDiffusion', // Stability AI + MIDJOURNEY: 'Midjourney', // Midjourney + SUNO: 'Suno' // Suno AI +} + +export const OtherPlatformEnum: ImageModelVO[] = [ + { + key: AiPlatformEnum.TONG_YI, + name: '通义万相' + }, + { + key: AiPlatformEnum.YI_YAN, + name: '百度千帆' + }, + { + key: AiPlatformEnum.ZHI_PU, + name: '智谱 AI' + } +] + +/** + * AI 图像生成状态的枚举 + */ +export const AiImageStatusEnum = { + IN_PROGRESS: 10, // 进行中 + SUCCESS: 20, // 已完成 + FAIL: 30 // 已失败 +} + +/** + * AI 音乐生成状态的枚举 + */ +export const AiMusicStatusEnum = { + IN_PROGRESS: 10, // 进行中 + SUCCESS: 20, // 已完成 + FAIL: 30 // 已失败 +} + +/** + * AI 写作类型的枚举 + */ +export enum AiWriteTypeEnum { + WRITING = 1, // 撰写 + REPLY // 回复 +} + +// 表格展示对照map +export const AiWriteTypeTableRender = { + [AiWriteTypeEnum.WRITING]: '撰写', + [AiWriteTypeEnum.REPLY]: '回复' +} + +// ========== 【图片 UI】相关的枚举 ========== + +export const ImageHotWords = [ + '中国旗袍', + '古装美女', + '卡通头像', + '机甲战士', + '童话小屋', + '中国长城' +] // 图片热词 + +export const ImageHotEnglishWords = [ + 'Chinese Cheongsam', + 'Ancient Beauty', + 'Cartoon Avatar', + 'Mech Warrior', + 'Fairy Tale Cottage', + 'The Great Wall of China' +] // 图片热词(英文) + +export interface ImageModelVO { + key: string + name: string + image?: string +} + +export const StableDiffusionSamplers: ImageModelVO[] = [ + { + key: 'DDIM', + name: 'DDIM' + }, + { + key: 'DDPM', + name: 'DDPM' + }, + { + key: 'K_DPMPP_2M', + name: 'K_DPMPP_2M' + }, + { + key: 'K_DPMPP_2S_ANCESTRAL', + name: 'K_DPMPP_2S_ANCESTRAL' + }, + { + key: 'K_DPM_2', + name: 'K_DPM_2' + }, + { + key: 'K_DPM_2_ANCESTRAL', + name: 'K_DPM_2_ANCESTRAL' + }, + { + key: 'K_EULER', + name: 'K_EULER' + }, + { + key: 'K_EULER_ANCESTRAL', + name: 'K_EULER_ANCESTRAL' + }, + { + key: 'K_HEUN', + name: 'K_HEUN' + }, + { + key: 'K_LMS', + name: 'K_LMS' + } +] + +export const StableDiffusionStylePresets: ImageModelVO[] = [ + { + key: '3d-model', + name: '3d-model' + }, + { + key: 'analog-film', + name: 'analog-film' + }, + { + key: 'anime', + name: 'anime' + }, + { + key: 'cinematic', + name: 'cinematic' + }, + { + key: 'comic-book', + name: 'comic-book' + }, + { + key: 'digital-art', + name: 'digital-art' + }, + { + key: 'enhance', + name: 'enhance' + }, + { + key: 'fantasy-art', + name: 'fantasy-art' + }, + { + key: 'isometric', + name: 'isometric' + }, + { + key: 'line-art', + name: 'line-art' + }, + { + key: 'low-poly', + name: 'low-poly' + }, + { + key: 'modeling-compound', + name: 'modeling-compound' + }, + // neon-punk origami photographic pixel-art tile-texture + { + key: 'neon-punk', + name: 'neon-punk' + }, + { + key: 'origami', + name: 'origami' + }, + { + key: 'photographic', + name: 'photographic' + }, + { + key: 'pixel-art', + name: 'pixel-art' + }, + { + key: 'tile-texture', + name: 'tile-texture' + } +] + +export const TongYiWanXiangModels: ImageModelVO[] = [ + { + key: 'wanx-v1', + name: 'wanx-v1' + }, + { + key: 'wanx-sketch-to-image-v1', + name: 'wanx-sketch-to-image-v1' + } +] + +export const QianFanModels: ImageModelVO[] = [ + { + key: 'sd_xl', + name: 'sd_xl' + } +] + +export const ChatGlmModels: ImageModelVO[] = [ + { + key: 'cogview-3', + name: 'cogview-3' + } +] + +export const StableDiffusionClipGuidancePresets: ImageModelVO[] = [ + { + key: 'NONE', + name: 'NONE' + }, + { + key: 'FAST_BLUE', + name: 'FAST_BLUE' + }, + { + key: 'FAST_GREEN', + name: 'FAST_GREEN' + }, + { + key: 'SIMPLE', + name: 'SIMPLE' + }, + { + key: 'SLOW', + name: 'SLOW' + }, + { + key: 'SLOWER', + name: 'SLOWER' + }, + { + key: 'SLOWEST', + name: 'SLOWEST' + } +] + +export const Dall3Models: ImageModelVO[] = [ + { + key: 'dall-e-3', + name: 'DALL·E 3', + image: `/src/assets/ai/dall2.jpg` + }, + { + key: 'dall-e-2', + name: 'DALL·E 2', + image: `/src/assets/ai/dall3.jpg` + } +] + +export const Dall3StyleList: ImageModelVO[] = [ + { + key: 'vivid', + name: '清晰', + image: `/src/assets/ai/qingxi.jpg` + }, + { + key: 'natural', + name: '自然', + image: `/src/assets/ai/ziran.jpg` + } +] + +export interface ImageSizeVO { + key: string + name?: string + style: string + width: string + height: string +} + +export const Dall3SizeList: ImageSizeVO[] = [ + { + key: '1024x1024', + name: '1:1', + width: '1024', + height: '1024', + style: 'width: 30px; height: 30px;background-color: #dcdcdc;' + }, + { + key: '1024x1792', + name: '3:5', + width: '1024', + height: '1792', + style: 'width: 30px; height: 50px;background-color: #dcdcdc;' + }, + { + key: '1792x1024', + name: '5:3', + width: '1792', + height: '1024', + style: 'width: 50px; height: 30px;background-color: #dcdcdc;' + } +] + +export const MidjourneyModels: ImageModelVO[] = [ + { + key: 'midjourney', + name: 'MJ', + image: 'https://bigpt8.com/pc/_nuxt/mj.34a61377.png' + }, + { + key: 'niji', + name: 'NIJI', + image: 'https://bigpt8.com/pc/_nuxt/nj.ca79b143.png' + } +] + +export const MidjourneySizeList: ImageSizeVO[] = [ + { + key: '1:1', + width: '1', + height: '1', + style: 'width: 30px; height: 30px;background-color: #dcdcdc;' + }, + { + key: '3:4', + width: '3', + height: '4', + style: 'width: 30px; height: 40px;background-color: #dcdcdc;' + }, + { + key: '4:3', + width: '4', + height: '3', + style: 'width: 40px; height: 30px;background-color: #dcdcdc;' + }, + { + key: '9:16', + width: '9', + height: '16', + style: 'width: 30px; height: 50px;background-color: #dcdcdc;' + }, + { + key: '16:9', + width: '16', + height: '9', + style: 'width: 50px; height: 30px;background-color: #dcdcdc;' + } +] + +export const MidjourneyVersions = [ + { + value: '6.0', + label: 'v6.0' + }, + { + value: '5.2', + label: 'v5.2' + }, + { + value: '5.1', + label: 'v5.1' + }, + { + value: '5.0', + label: 'v5.0' + }, + { + value: '4.0', + label: 'v4.0' + } +] + +export const NijiVersionList = [ + { + value: '5', + label: 'v5' + } +] + +// ========== 【写作 UI】相关的枚举 ========== + +/** 写作点击示例时的数据 **/ +export const WriteExample = { + write: { + prompt: 'vue', + data: 'Vue.js 是一种用于构建用户界面的渐进式 JavaScript 框架。它的核心库只关注视图层,易于上手,同时也便于与其他库或已有项目整合。\n\nVue.js 的特点包括:\n- 响应式的数据绑定:Vue.js 会自动将数据与 DOM 同步,使得状态管理变得更加简单。\n- 组件化:Vue.js 允许开发者通过小型、独立和通常可复用的组件构建大型应用。\n- 虚拟 DOM:Vue.js 使用虚拟 DOM 实现快速渲染,提高了性能。\n\n在 Vue.js 中,一个典型的应用结构可能包括:\n1. 根实例:每个 Vue 应用都需要一个根实例作为入口点。\n2. 组件系统:可以创建自定义的可复用组件。\n3. 指令:特殊的带有前缀 v- 的属性,为 DOM 元素提供特殊的行为。\n4. 插值:用于文本内容,将数据动态地插入到 HTML。\n5. 计算属性和侦听器:用于处理数据的复杂逻辑和响应数据变化。\n6. 条件渲染:根据条件决定元素的渲染。\n7. 列表渲染:用于显示列表数据。\n8. 事件处理:响应用户交互。\n9. 表单输入绑定:处理表单输入和验证。\n10. 组件生命周期钩子:在组件的不同阶段执行特定的函数。\n\nVue.js 还提供了官方的路由器 Vue Router 和状态管理库 Vuex,以支持构建复杂的单页应用(SPA)。\n\n在开发过程中,开发者通常会使用 Vue CLI,这是一个强大的命令行工具,用于快速生成 Vue 项目脚手架,集成了诸如 Babel、Webpack 等现代前端工具,以及热重载、代码检测等开发体验优化功能。\n\nVue.js 的生态系统还包括大量的第三方库和插件,如 Vuetify(UI 组件库)、Vue Test Utils(测试工具)等,这些都极大地丰富了 Vue.js 的开发生态。\n\n总的来说,Vue.js 是一个灵活、高效的前端框架,适合从小型项目到大型企业级应用的开发。它的易用性、灵活性和强大的社区支持使其成为许多开发者的首选框架之一。' + }, + reply: { + originalContent: '领导,我想请假', + prompt: '不批', + data: '您的请假申请已收悉,经核实和考虑,暂时无法批准您的请假申请。\n\n如有特殊情况或紧急事务,请及时与我联系。\n\n祝工作顺利。\n\n谢谢。' + } +} + +// ========== 【思维导图 UI】相关的枚举 ========== + +/** 思维导图已有内容生成示例 **/ +export const MindMapContentExample = `# Java 技术栈 + +## 核心技术 +### Java SE +### Java EE + +## 框架 +### Spring +#### Spring Boot +#### Spring MVC +#### Spring Data +### Hibernate +### MyBatis + +## 构建工具 +### Maven +### Gradle + +## 版本控制 +### Git +### SVN + +## 测试工具 +### JUnit +### Mockito +### Selenium + +## 应用服务器 +### Tomcat +### Jetty +### WildFly + +## 数据库 +### MySQL +### PostgreSQL +### Oracle +### MongoDB + +## 消息队列 +### Kafka +### RabbitMQ +### ActiveMQ + +## 微服务 +### Spring Cloud +### Dubbo + +## 容器化 +### Docker +### Kubernetes + +## 云服务 +### AWS +### Azure +### Google Cloud + +## 开发工具 +### IntelliJ IDEA +### Eclipse +### Visual Studio Code` diff --git a/src/views/ai/utils/utils.ts b/src/views/ai/utils/utils.ts new file mode 100644 index 0000000..ab45ae1 --- /dev/null +++ b/src/views/ai/utils/utils.ts @@ -0,0 +1,13 @@ +/** + * Created by 芋道源码 + * + * AI 枚举类 + * + * 问题:为什么不放在 src/utils/common-utils.ts 呢? + * 回答:主要 AI 是可选模块,考虑到独立、解耦,所以放在了 /views/ai/utils/common-utils.ts + */ + +/** 判断字符串是否包含中文 */ +export const hasChinese = (str: string) => { + return /[\u4e00-\u9fa5]/.test(str) +} diff --git a/src/views/ai/write/index/components/Left.vue b/src/views/ai/write/index/components/Left.vue new file mode 100644 index 0000000..992e6fe --- /dev/null +++ b/src/views/ai/write/index/components/Left.vue @@ -0,0 +1,213 @@ +<template> + <!-- 定义 tab 组件:撰写/回复等 --> + <DefineTab v-slot="{ active, text, itemClick }"> + <span + class="inline-block w-1/2 rounded-full cursor-pointer text-center leading-[30px] relative z-1 text-[5C6370] hover:text-black" + :class="active ? 'text-black shadow-md' : 'hover:bg-[#DDDFE3]'" + @click="itemClick" + > + {{ text }} + </span> + </DefineTab> + <!-- 定义 label 组件:长度/格式/语气/语言等 --> + <DefineLabel v-slot="{ label, hint, hintClick }"> + <h3 class="mt-5 mb-3 flex items-center justify-between text-[14px]"> + <span>{{ label }}</span> + <span + @click="hintClick" + v-if="hint" + class="flex items-center text-[12px] text-[#846af7] cursor-pointer select-none" + > + <Icon icon="ep:question-filled" /> + {{ hint }} + </span> + </h3> + </DefineLabel> + + <div class="flex flex-col" v-bind="$attrs"> + <!-- tab --> + <div class="w-full pt-2 bg-[#f5f7f9] flex justify-center"> + <div class="w-[303px] rounded-full bg-[#DDDFE3] p-1 z-10"> + <div + class="flex items-center relative after:content-[''] after:block after:bg-white after:h-[30px] after:w-1/2 after:absolute after:top-0 after:left-0 after:transition-transform after:rounded-full" + :class=" + selectedTab === AiWriteTypeEnum.REPLY && 'after:transform after:translate-x-[100%]' + " + > + <ReuseTab + v-for="tab in tabs" + :key="tab.value" + :text="tab.text" + :active="tab.value === selectedTab" + :itemClick="() => switchTab(tab.value)" + /> + </div> + </div> + </div> + <div + class="px-7 pb-2 flex-grow overflow-y-auto lg:block w-[380px] box-border bg-[#f5f7f9] h-full" + > + <div> + <template v-if="selectedTab === 1"> + <ReuseLabel label="写作内容" hint="示例" :hint-click="() => example('write')" /> + <el-input + type="textarea" + :rows="5" + :maxlength="500" + v-model="formData.prompt" + placeholder="请输入写作内容" + showWordLimit + /> + </template> + + <template v-else> + <ReuseLabel label="原文" hint="示例" :hint-click="() => example('reply')" /> + <el-input + type="textarea" + :rows="5" + :maxlength="500" + v-model="formData.originalContent" + placeholder="请输入原文" + showWordLimit + /> + + <ReuseLabel label="回复内容" /> + <el-input + type="textarea" + :rows="5" + :maxlength="500" + v-model="formData.prompt" + placeholder="请输入回复内容" + showWordLimit + /> + </template> + + <ReuseLabel label="长度" /> + <Tag v-model="formData.length" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_LENGTH)" /> + <ReuseLabel label="格式" /> + <Tag v-model="formData.format" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_FORMAT)" /> + <ReuseLabel label="语气" /> + <Tag v-model="formData.tone" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_TONE)" /> + <ReuseLabel label="语言" /> + <Tag v-model="formData.language" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_LANGUAGE)" /> + + <div class="flex items-center justify-center mt-3"> + <el-button :disabled="isWriting" @click="reset">重置</el-button> + <el-button :loading="isWriting" @click="submit" color="#846af7">生成</el-button> + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { createReusableTemplate } from '@vueuse/core' +import { ref } from 'vue' +import Tag from './Tag.vue' +import { WriteVO } from '@/api/ai/write' +import { omit } from 'lodash-es' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { AiWriteTypeEnum, WriteExample } from '@/views/ai/utils/constants' + +type TabType = WriteVO['type'] + +const message = useMessage() // 消息弹窗 + +defineProps<{ + isWriting: boolean +}>() + +const emits = defineEmits<{ + (e: 'submit', params: Partial<WriteVO>) + (e: 'example', param: 'write' | 'reply') + (e: 'reset') +}>() + +/** 点击示例的时候,将定义好的文章作为示例展示出来 **/ +const example = (type: 'write' | 'reply') => { + formData.value = { + ...initData, + ...omit(WriteExample[type], ['data']) + } + emits('example', type) +} + +/** 重置,将表单值作为初选值 **/ +const reset = () => { + formData.value = { ...initData } + emits('reset') +} + +const selectedTab = ref<TabType>(AiWriteTypeEnum.WRITING) +const tabs: { + text: string + value: TabType +}[] = [ + { text: '撰写', value: AiWriteTypeEnum.WRITING }, + { text: '回复', value: AiWriteTypeEnum.REPLY } +] +const [DefineTab, ReuseTab] = createReusableTemplate<{ + active?: boolean + text: string + itemClick: () => void +}>() + +/** + * 可以在 template 里边定义可复用的组件,DefineLabel,ReuseLabel 是采用的解构赋值,都是 Vue 组件 + * + * 直接通过组件的形式使用,<DefineLabel v-slot="{ label, hint, hintClick }"> 中间是需要复用的组件代码 <DefineLabel />,通过 <ReuseLabel /> 来使用定义的组件 + * DefineLabel 里边的 v-slot="{ label, hint, hintClick }"相当于是解构了组件的 prop,需要注意的是 boolean 类型,需要显式的赋值比如 <ReuseLabel :flag="true" /> + * 事件也得以 prop 形式传入,不能是 @event的形式,比如下面的 hintClick 需要<ReuseLabel :hintClick="() => { doSomething }"/> + * + * @see https://vueuse.org/createReusableTemplate + */ +const [DefineLabel, ReuseLabel] = createReusableTemplate<{ + label: string + class?: string + hint?: string + hintClick?: () => void +}>() + +const initData: WriteVO = { + type: 1, + prompt: '', + originalContent: '', + tone: 1, + language: 1, + length: 1, + format: 1 +} +const formData = ref<WriteVO>({ ...initData }) + +/** 用来记录切换之前所填写的数据,切换的时候给赋值回来 **/ +const recordFormData = {} as Record<AiWriteTypeEnum, WriteVO> + +/** 切换tab **/ +const switchTab = (value: TabType) => { + if (value !== selectedTab.value) { + // 保存之前的久数据 + recordFormData[selectedTab.value] = formData.value + selectedTab.value = value + // 将之前的旧数据赋值回来 + formData.value = { ...initData, ...recordFormData[value] } + } +} + +/** 提交写作 */ +const submit = () => { + if (selectedTab.value === 2 && !formData.value.originalContent) { + message.warning('请输入原文') + return + } + if (!formData.value.prompt) { + message.warning(`请输入${selectedTab.value === 1 ? '写作' : '回复'}内容`) + return + } + emits('submit', { + /** 撰写的时候没有 originalContent 字段**/ + ...(selectedTab.value === 1 ? omit(formData.value, ['originalContent']) : formData.value), + /** 使用选中 tab 值覆盖当前的 type 类型 **/ + type: selectedTab.value + }) +} +</script> diff --git a/src/views/ai/write/index/components/Right.vue b/src/views/ai/write/index/components/Right.vue new file mode 100644 index 0000000..d0aada5 --- /dev/null +++ b/src/views/ai/write/index/components/Right.vue @@ -0,0 +1,120 @@ +<template> + <el-card class="my-card h-full"> + <template #header> + <h3 class="m-0 px-7 shrink-0 flex items-center justify-between"> + <span>预览</span> + <!-- 展示在右上角 --> + <el-button color="#846af7" v-show="showCopy" @click="copyContent" size="small"> + <template #icon> + <Icon icon="ph:copy-bold" /> + </template> + 复制 + </el-button> + </h3> + </template> + + <div ref="contentRef" class="hide-scroll-bar h-full box-border overflow-y-auto"> + <div class="w-full min-h-full relative flex-grow bg-white box-border p-3 sm:p-7"> + <!-- 终止生成内容的按钮 --> + <el-button + v-show="isWriting" + class="absolute bottom-2 sm:bottom-5 left-1/2 -translate-x-1/2 z-36" + @click="emits('stopStream')" + size="small" + > + <template #icon> + <Icon icon="material-symbols:stop" /> + </template> + 终止生成 + </el-button> + <el-input + id="inputId" + type="textarea" + v-model="compContent" + autosize + :input-style="{ boxShadow: 'none' }" + resize="none" + placeholder="生成的内容……" + /> + </div> + </div> + </el-card> +</template> + +<script setup lang="ts"> +import { useClipboard } from '@vueuse/core' + +const message = useMessage() // 消息弹窗 +const { copied, copy } = useClipboard() // 粘贴板 + +const props = defineProps({ + content: { + // 生成的结果 + type: String, + default: '' + }, + isWriting: { + // 是否正在生成文章 + type: Boolean, + default: false + } +}) + +const emits = defineEmits(['update:content', 'stopStream']) + +/** 通过计算属性,双向绑定,更改生成的内容,考虑到用户想要更改生成文章的情况 */ +const compContent = computed({ + get() { + return props.content + }, + set(val) { + emits('update:content', val) + } +}) + +/** 滚动 */ +const contentRef = ref<HTMLDivElement>() +defineExpose({ + scrollToBottom() { + contentRef.value?.scrollTo(0, contentRef.value?.scrollHeight) + } +}) + +/** 点击复制的时候复制内容 */ +const showCopy = computed(() => props.content && !props.isWriting) // 是否展示复制按钮,在生成内容完成的时候展示 +const copyContent = () => { + copy(props.content) +} + +/** 复制成功的时候 copied.value 为 true */ +watch(copied, (val) => { + if (val) { + message.success('复制成功') + } +}) +</script> + +<style lang="scss" scoped> +.hide-scroll-bar { + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + width: 0; + height: 0; + } +} + +.my-card { + display: flex; + flex-direction: column; + + :deep(.el-card__body) { + box-sizing: border-box; + flex-grow: 1; + overflow-y: auto; + padding: 0; + @extend .hide-scroll-bar; + } +} +</style> diff --git a/src/views/ai/write/index/components/Tag.vue b/src/views/ai/write/index/components/Tag.vue new file mode 100644 index 0000000..3d616be --- /dev/null +++ b/src/views/ai/write/index/components/Tag.vue @@ -0,0 +1,32 @@ +<!-- 标签选项 --> +<template> + <div class="flex flex-wrap gap-[8px]"> + <span + v-for="tag in props.tags" + :key="tag.value" + class="tag mb-2 border-[2px] border-solid border-[#DDDFE3] px-2 leading-6 text-[12px] bg-[#DDDFE3] rounded-[4px] cursor-pointer" + :class="modelValue === tag.value && '!border-[#846af7] text-[#846af7]'" + @click="emits('update:modelValue', tag.value)" + > + {{ tag.label }} + </span> + </div> +</template> + +<script setup lang="ts"> +const props = withDefaults( + defineProps<{ + tags: { label: string; value: string }[] + modelValue: string + [k: string]: any + }>(), + { + tags: () => [] + } +) + +const emits = defineEmits<{ + (e: 'update:modelValue', value: string): void +}>() +</script> +<style scoped></style> diff --git a/src/views/ai/write/index/index.vue b/src/views/ai/write/index/index.vue new file mode 100644 index 0000000..0dfda74 --- /dev/null +++ b/src/views/ai/write/index/index.vue @@ -0,0 +1,76 @@ +<template> + <div class="absolute top-0 left-0 right-0 bottom-0 flex"> + <Left + :is-writing="isWriting" + class="h-full" + @submit="submit" + @reset="reset" + @example="handleExampleClick" + /> + <Right + :is-writing="isWriting" + @stop-stream="stopStream" + ref="rightRef" + class="flex-grow" + v-model:content="writeResult" + /> + </div> +</template> + +<script setup lang="ts"> +import Left from './components/Left.vue' +import Right from './components/Right.vue' +import { WriteApi, WriteVO } from '@/api/ai/write' +import { WriteExample } from '@/views/ai/utils/constants' + +const message = useMessage() + +const writeResult = ref('') // 写作结果 +const isWriting = ref(false) // 是否正在写作中 +const abortController = ref<AbortController>() // // 写作进行中 abort 控制器(控制 stream 写作) + +/** 停止 stream 生成 */ +const stopStream = () => { + abortController.value?.abort() + isWriting.value = false +} + +/** 执行写作 */ +const rightRef = ref<InstanceType<typeof Right>>() +const submit = (data: WriteVO) => { + abortController.value = new AbortController() + writeResult.value = '' + isWriting.value = true + WriteApi.writeStream({ + data, + onMessage: async (res) => { + const { code, data, msg } = JSON.parse(res.data) + if (code !== 0) { + message.alert(`写作异常! ${msg}`) + stopStream() + return + } + writeResult.value = writeResult.value + data + // 滚动到底部 + await nextTick() + rightRef.value?.scrollToBottom() + }, + ctrl: abortController.value, + onClose: stopStream, + onError: (...err) => { + console.error('写作异常', ...err) + stopStream() + } + }) +} + +/** 点击示例触发 */ +const handleExampleClick = (type: keyof typeof WriteExample) => { + writeResult.value = WriteExample[type].data +} + +/** 点击重置的时候清空写作的结果**/ +const reset = () => { + writeResult.value = '' +} +</script> diff --git a/src/views/ai/write/manager/index.vue b/src/views/ai/write/manager/index.vue new file mode 100644 index 0000000..ddd1400 --- /dev/null +++ b/src/views/ai/write/manager/index.vue @@ -0,0 +1,256 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-select + v-model="queryParams.userId" + clearable + placeholder="请输入用户编号" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="写作类型" prop="type"> + <el-select + v-model="queryParams.type" + placeholder="请选择写作类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.AI_WRITE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="平台" prop="platform"> + <el-select v-model="queryParams.platform" placeholder="请选择平台" clearable class="!w-240px"> + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['ai:write:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <!-- TODO @YunaiV 目前没有导出接口,需要导出吗 --> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['ai:write:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" width="120" fixed="left" /> + <el-table-column label="用户" align="center" prop="userId" width="180"> + <template #default="scope"> + <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span> + </template> + </el-table-column> + <el-table-column label="写作类型" align="center" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_WRITE_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column label="平台" align="center" prop="platform" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" /> + </template> + </el-table-column> + <el-table-column label="模型" align="center" prop="model" width="180" /> + <el-table-column + label="生成内容提示" + align="center" + prop="prompt" + width="180" + show-overflow-tooltip + /> + <el-table-column label="生成的内容" align="center" prop="generatedContent" width="180" /> + <el-table-column label="原文" align="center" prop="originalContent" width="180" /> + <el-table-column label="长度" align="center" prop="length"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_WRITE_LENGTH" :value="scope.row.length" /> + </template> + </el-table-column> + <el-table-column label="格式" align="center" prop="format"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_WRITE_FORMAT" :value="scope.row.format" /> + </template> + </el-table-column> + <el-table-column label="语气" align="center" prop="tone"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_WRITE_TONE" :value="scope.row.tone" /> + </template> + </el-table-column> + <el-table-column label="语言" align="center" prop="language"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_WRITE_LANGUAGE" :value="scope.row.language" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="错误信息" align="center" prop="errorMessage" /> + <el-table-column label="操作" align="center"> + <template #default="scope"> +<!-- TODO @YunaiV 目前没有修改接口,写作要可以更改吗--> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['ai:write:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:write:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { useRouter } from 'vue-router' +import { WriteApi, AiWritePageReqVO, AiWriteRespVo } from '@/api/ai/write' +import * as UserApi from '@/api/system/user' + +/** AI 写作列表 */ +defineOptions({ name: 'AiWriteManager' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const router = useRouter() // 路由 + +const loading = ref(true) // 列表的加载中 +const list = ref<AiWriteRespVo[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive<AiWritePageReqVO>({ + pageNo: 1, + pageSize: 10, + userId: undefined, + type: undefined, + platform: undefined, + createTime: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await WriteApi.getWritePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 新增方法,跳转到写作页面 **/ +const openForm = (type: string, id?: number) => { + switch (type) { + case 'create': + router.push('/ai/write') + break + } +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await WriteApi.deleteWrite(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + getList() + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/bpm/category/CategoryForm.vue b/src/views/bpm/category/CategoryForm.vue new file mode 100644 index 0000000..5b77153 --- /dev/null +++ b/src/views/bpm/category/CategoryForm.vue @@ -0,0 +1,124 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="分类名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入分类名" /> + </el-form-item> + <el-form-item label="分类标志" prop="code"> + <el-input v-model="formData.code" placeholder="请输入分类标志" /> + </el-form-item> + <el-form-item label="分类状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="分类排序" prop="sort"> + <el-input-number + v-model="formData.sort" + placeholder="请输入分类排序" + class="!w-1/1" + :precision="0" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { CategoryApi, CategoryVO } from '@/api/bpm/category' + +/** BPM 流程分类 表单 */ +defineOptions({ name: 'CategoryForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + code: undefined, + status: undefined, + sort: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '分类名不能为空', trigger: 'blur' }], + code: [{ required: true, message: '分类标志不能为空', trigger: 'blur' }], + status: [{ required: true, message: '分类状态不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '分类排序不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await CategoryApi.getCategory(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as CategoryVO + if (formType.value === 'create') { + await CategoryApi.createCategory(data) + message.success(t('common.createSuccess')) + } else { + await CategoryApi.updateCategory(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + code: undefined, + status: undefined, + sort: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/bpm/category/index.vue b/src/views/bpm/category/index.vue new file mode 100644 index 0000000..085b371 --- /dev/null +++ b/src/views/bpm/category/index.vue @@ -0,0 +1,199 @@ +<template> + <doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="分类名" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入分类名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="分类标志" prop="code"> + <el-input + v-model="queryParams.code" + placeholder="请输入分类标志" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="分类状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择分类状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['bpm:category:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="分类编号" align="center" prop="id" /> + <el-table-column label="分类名" align="center" prop="name" /> + <el-table-column label="分类标志" align="center" prop="code" /> + <el-table-column label="分类描述" align="center" prop="description" /> + <el-table-column label="分类状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="分类排序" align="center" prop="sort" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['bpm:category:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['bpm:category:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <CategoryForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { CategoryApi, CategoryVO } from '@/api/bpm/category' +import CategoryForm from './CategoryForm.vue' + +/** BPM 流程分类 列表 */ +defineOptions({ name: 'BpmCategory' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<CategoryVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + code: undefined, + status: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CategoryApi.getCategoryPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await CategoryApi.deleteCategory(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/definition/index.vue b/src/views/bpm/definition/index.vue new file mode 100644 index 0000000..1e7794b --- /dev/null +++ b/src/views/bpm/definition/index.vue @@ -0,0 +1,149 @@ +<template> + <doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" /> + + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="定义编号" align="center" prop="id" width="400" /> + <el-table-column label="流程名称" align="center" prop="name" width="200"> + <template #default="scope"> + <el-button type="primary" link @click="handleBpmnDetail(scope.row)"> + <span>{{ scope.row.name }}</span> + </el-button> + </template> + </el-table-column> + <el-table-column label="定义分类" align="center" prop="categoryName" width="100" /> + <el-table-column label="表单信息" align="center" prop="formType" width="200"> + <template #default="scope"> + <el-button + v-if="scope.row.formType === 10" + type="primary" + link + @click="handleFormDetail(scope.row)" + > + <span>{{ scope.row.formName }}</span> + </el-button> + <el-button v-else type="primary" link @click="handleFormDetail(scope.row)"> + <span>{{ scope.row.formCustomCreatePath }}</span> + </el-button> + </template> + </el-table-column> + <el-table-column label="流程版本" align="center" prop="processDefinition.version" width="80"> + <template #default="scope"> + <el-tag v-if="scope.row">v{{ scope.row.version }}</el-tag> + <el-tag type="warning" v-else>未部署</el-tag> + </template> + </el-table-column> + <el-table-column label="状态" align="center" prop="version" width="80"> + <template #default="scope"> + <el-tag type="success" v-if="scope.row.suspensionState === 1">激活</el-tag> + <el-tag type="warning" v-if="scope.row.suspensionState === 2">挂起</el-tag> + </template> + </el-table-column> + <el-table-column + label="部署时间" + align="center" + prop="deploymentTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column + label="定义描述" + align="center" + prop="description" + width="300" + show-overflow-tooltip + /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 弹窗:表单详情 --> + <Dialog title="表单详情" v-model="formDetailVisible" width="800"> + <form-create :rule="formDetailPreview.rule" :option="formDetailPreview.option" /> + </Dialog> + + <!-- 弹窗:流程模型图的预览 --> + <Dialog title="流程图" v-model="bpmnDetailVisible" width="800"> + <MyProcessViewer + key="designer" + v-model="bpmnXml" + :value="bpmnXml as any" + v-bind="bpmnControlForm" + :prefix="bpmnControlForm.prefix" + /> + </Dialog> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import { MyProcessViewer } from '@/components/bpmnProcessDesigner/package' +import * as DefinitionApi from '@/api/bpm/definition' +import { setConfAndFields2 } from '@/utils/formCreate' + +defineOptions({ name: 'BpmProcessDefinition' }) + +const { push } = useRouter() // 路由 +const { query } = useRoute() // 查询参数 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + key: query.key +}) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DefinitionApi.getProcessDefinitionPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 流程表单的详情按钮操作 */ +const formDetailVisible = ref(false) +const formDetailPreview = ref({ + rule: [], + option: {} +}) +const handleFormDetail = async (row) => { + if (row.formType == 10) { + // 设置表单 + setConfAndFields2(formDetailPreview, row.formConf, row.formFields) + // 弹窗打开 + formDetailVisible.value = true + } else { + await push({ + path: row.formCustomCreatePath + }) + } +} + +/** 流程图的详情按钮操作 */ +const bpmnDetailVisible = ref(false) +const bpmnXml = ref(null) +const bpmnControlForm = ref({ + prefix: 'flowable' +}) +const handleBpmnDetail = async (row) => { + bpmnXml.value = (await DefinitionApi.getProcessDefinition(row.id))?.bpmnXml + bpmnDetailVisible.value = true +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/form/editor/index.vue b/src/views/bpm/form/editor/index.vue new file mode 100644 index 0000000..0d1230c --- /dev/null +++ b/src/views/bpm/form/editor/index.vue @@ -0,0 +1,121 @@ +<template> + <ContentWrap> + <!-- 表单设计器 --> + <FcDesigner ref="designer" height="780px"> + <template #handle> + <el-button round size="small" type="primary" @click="handleSave"> + <Icon class="mr-5px" icon="ep:plus" /> + 保存 + </el-button> + </template> + </FcDesigner> + </ContentWrap> + + <!-- 表单保存的弹窗 --> + <Dialog v-model="dialogVisible" title="保存表单" width="600"> + <el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px"> + <el-form-item label="表单名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入表单名" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import * as FormApi from '@/api/bpm/form' +import FcDesigner from '@form-create/designer' +import { encodeConf, encodeFields, setConfAndFields } from '@/utils/formCreate' +import { useTagsViewStore } from '@/store/modules/tagsView' +import { useFormCreateDesigner } from '@/components/FormCreate' + +defineOptions({ name: 'BpmFormEditor' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息 +const { push, currentRoute } = useRouter() // 路由 +const { query } = useRoute() // 路由信息 +const { delView } = useTagsViewStore() // 视图操作 + +const designer = ref() // 表单设计器 +useFormCreateDesigner(designer) // 表单设计器增强 +const dialogVisible = ref(false) // 弹窗是否展示 +const formLoading = ref(false) // 表单的加载中:提交的按钮禁用 +const formData = ref({ + name: '', + status: CommonStatusEnum.ENABLE, + remark: '' +}) +const formRules = reactive({ + name: [{ required: true, message: '表单名不能为空', trigger: 'blur' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 处理保存按钮 */ +const handleSave = () => { + dialogVisible.value = true +} + +/** 提交表单 */ +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as FormApi.FormVO + data.conf = encodeConf(designer) // 表单配置 + data.fields = encodeFields(designer) // 表单字段 + if (!data.id) { + await FormApi.createForm(data) + message.success(t('common.createSuccess')) + } else { + await FormApi.updateForm(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + close() + } finally { + formLoading.value = false + } +} +/** 关闭按钮 */ +const close = () => { + delView(unref(currentRoute)) + push('/bpm/manager/form') +} + +/** 初始化 **/ +onMounted(async () => { + // 场景一:新增表单 + const id = query.id as unknown as number + if (!id) { + return + } + // 场景二:修改表单 + const data = await FormApi.getForm(id) + formData.value = data + setConfAndFields(designer, data.conf, data.fields) +}) +</script> diff --git a/src/views/bpm/form/index.vue b/src/views/bpm/form/index.vue new file mode 100644 index 0000000..3d542c8 --- /dev/null +++ b/src/views/bpm/form/index.vue @@ -0,0 +1,195 @@ +<template> + <doc-alert title="审批接入(流程表单)" url="https://doc.iocoder.cn/bpm/use-bpm-form/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="表单名" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入表单名" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button v-hasPermi="['bpm:form:create']" plain type="primary" @click="openForm"> + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="编号" prop="id" /> + <el-table-column align="center" label="表单名" prop="name" /> + <el-table-column align="center" label="状态" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column align="center" label="备注" prop="remark" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['bpm:form:update']" + link + type="primary" + @click="openForm(scope.row.id)" + > + 编辑 + </el-button> + <el-button v-hasPermi="['bpm:form:query']" link @click="openDetail(scope.row.id)"> + 详情 + </el-button> + <el-button + v-hasPermi="['bpm:form:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单详情的弹窗 --> + <Dialog v-model="detailVisible" title="表单详情" width="800"> + <form-create :option="detailData.option" :rule="detailData.rule" /> + </Dialog> +</template> + +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as FormApi from '@/api/bpm/form' +import { setConfAndFields2 } from '@/utils/formCreate' + +defineOptions({ name: 'BpmForm' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const { currentRoute, push } = useRouter() // 路由 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await FormApi.getFormPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const openForm = (id?: number) => { + const toRouter: { name: string; query?: { id: number } } = { + name: 'BpmFormEditor' + } + // 表单新建的时候id传的是event需要排除 + if (typeof id === 'number') { + toRouter.query = { + id + } + } + push(toRouter) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await FormApi.deleteForm(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 详情操作 */ +const detailVisible = ref(false) +const detailData = ref({ + rule: [], + option: {} +}) +const openDetail = async (rowId: number) => { + // 设置表单 + const data = await FormApi.getForm(rowId) + setConfAndFields2(detailData, data.conf, data.fields) + // 弹窗打开 + detailVisible.value = true +} +/**表单保存返回后重新加载列表 */ +watch( + () => currentRoute.value, + () => { + getList() + }, + { + immediate: true + } +) +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/group/UserGroupForm.vue b/src/views/bpm/group/UserGroupForm.vue new file mode 100644 index 0000000..ac0cfcb --- /dev/null +++ b/src/views/bpm/group/UserGroupForm.vue @@ -0,0 +1,132 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item label="组名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入组名" /> + </el-form-item> + <el-form-item label="描述"> + <el-input v-model="formData.description" placeholder="请输入描述" type="textarea" /> + </el-form-item> + <el-form-item label="成员" prop="userIds"> + <el-select v-model="formData.userIds" multiple placeholder="请选择成员"> + <el-option + v-for="user in userList" + :key="user.id" + :label="user.nickname" + :value="user.id" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import * as UserGroupApi from '@/api/bpm/userGroup' +import * as UserApi from '@/api/system/user' + +defineOptions({ name: 'UserGroupForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + description: undefined, + userIds: undefined, + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive({ + name: [{ required: true, message: '组名不能为空', trigger: 'blur' }], + description: [{ required: true, message: '描述不能为空', trigger: 'blur' }], + userIds: [{ required: true, message: '成员不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const userList = ref<any[]>([]) // 用户列表 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await UserGroupApi.getUserGroup(id) + } finally { + formLoading.value = false + } + } + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as UserGroupApi.UserGroupVO + if (formType.value === 'create') { + await UserGroupApi.createUserGroup(data) + message.success(t('common.createSuccess')) + } else { + await UserGroupApi.updateUserGroup(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + description: undefined, + userIds: undefined, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/bpm/group/index.vue b/src/views/bpm/group/index.vue new file mode 100644 index 0000000..62785a9 --- /dev/null +++ b/src/views/bpm/group/index.vue @@ -0,0 +1,191 @@ +<template> + <doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="组名" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入组名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['bpm:user-group:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="组名" align="center" prop="name" /> + <el-table-column label="描述" align="center" prop="description" /> + <el-table-column label="成员" align="center"> + <template #default="scope"> + <span v-for="userId in scope.row.userIds" :key="userId" class="pr-5px"> + {{ userList.find((user) => user.id === userId)?.nickname }} + </span> + </template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['bpm:user-group:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['bpm:user-group:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <UserGroupForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as UserGroupApi from '@/api/bpm/userGroup' +import * as UserApi from '@/api/system/user' +import UserGroupForm from './UserGroupForm.vue' +import { UserVO } from '@/api/system/user' + +defineOptions({ name: 'BpmUserGroup' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + status: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const userList = ref<UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await UserGroupApi.getUserGroupPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await UserGroupApi.deleteUserGroup(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/bpm/model/ModelForm.vue b/src/views/bpm/model/ModelForm.vue new file mode 100644 index 0000000..ce60edc --- /dev/null +++ b/src/views/bpm/model/ModelForm.vue @@ -0,0 +1,239 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="600"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="110px" + > + <el-form-item label="流程标识" prop="key"> + <el-input + v-model="formData.key" + :disabled="!!formData.id" + placeholder="请输入流标标识" + style="width: 330px" + /> + <el-tooltip + v-if="!formData.id" + class="item" + content="新建后,流程标识不可修改!" + effect="light" + placement="top" + > + <i class="el-icon-question" style="padding-left: 5px"></i> + </el-tooltip> + <el-tooltip v-else class="item" content="流程标识不可修改!" effect="light" placement="top"> + <i class="el-icon-question" style="padding-left: 5px"></i> + </el-tooltip> + </el-form-item> + <el-form-item label="流程名称" prop="name"> + <el-input + v-model="formData.name" + :disabled="!!formData.id" + clearable + placeholder="请输入流程名称" + /> + </el-form-item> + <el-form-item v-if="formData.id" label="流程分类" prop="category"> + <el-select + v-model="formData.category" + clearable + placeholder="请选择流程分类" + style="width: 100%" + > + <el-option + v-for="category in categoryList" + :key="category.code" + :label="category.name" + :value="category.code" + /> + </el-select> + </el-form-item> + <el-form-item v-if="formData.id" label="流程图标" prop="icon"> + <UploadImg v-model="formData.icon" :limit="1" height="128px" width="128px" /> + </el-form-item> + <el-form-item label="流程描述" prop="description"> + <el-input v-model="formData.description" clearable type="textarea" /> + </el-form-item> + <div v-if="formData.id"> + <el-form-item label="表单类型" prop="formType"> + <el-radio-group v-model="formData.formType"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.BPM_MODEL_FORM_TYPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item v-if="formData.formType === 10" label="流程表单" prop="formId"> + <el-select v-model="formData.formId" clearable style="width: 100%"> + <el-option + v-for="form in formList" + :key="form.id" + :label="form.name" + :value="form.id" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="formData.formType === 20" + label="表单提交路由" + prop="formCustomCreatePath" + > + <el-input + v-model="formData.formCustomCreatePath" + placeholder="请输入表单提交路由" + style="width: 330px" + /> + <el-tooltip + class="item" + content="自定义表单的提交路径,使用 Vue 的路由地址,例如说:bpm/oa/leave/create" + effect="light" + placement="top" + > + <i class="el-icon-question" style="padding-left: 5px"></i> + </el-tooltip> + </el-form-item> + <el-form-item + v-if="formData.formType === 20" + label="表单查看地址" + prop="formCustomViewPath" + > + <el-input + v-model="formData.formCustomViewPath" + placeholder="请输入表单查看的组件地址" + style="width: 330px" + /> + <el-tooltip + class="item" + content="自定义表单的查看组件地址,使用 Vue 的组件地址,例如说:bpm/oa/leave/detail" + effect="light" + placement="top" + > + <i class="el-icon-question" style="padding-left: 5px"></i> + </el-tooltip> + </el-form-item> + </div> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { ElMessageBox } from 'element-plus' +import * as ModelApi from '@/api/bpm/model' +import * as FormApi from '@/api/bpm/form' +import { CategoryApi } from '@/api/bpm/category' + +defineOptions({ name: 'ModelForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + formType: 10, + name: '', + category: undefined, + icon: undefined, + description: '', + formId: '', + formCustomCreatePath: '', + formCustomViewPath: '' +}) +const formRules = reactive({ + name: [{ required: true, message: '参数名称不能为空', trigger: 'blur' }], + key: [{ required: true, message: '参数键名不能为空', trigger: 'blur' }], + category: [{ required: true, message: '参数分类不能为空', trigger: 'blur' }], + icon: [{ required: true, message: '参数图标不能为空', trigger: 'blur' }], + value: [{ required: true, message: '参数键值不能为空', trigger: 'blur' }], + visible: [{ required: true, message: '是否可见不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const formList = ref([]) // 流程表单的下拉框的数据 +const categoryList = ref([]) // 流程分类列表 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ModelApi.getModel(id) + } finally { + formLoading.value = false + } + } + // 获得流程表单的下拉框的数据 + formList.value = await FormApi.getFormSimpleList() + // 查询流程分类列表 + categoryList.value = await CategoryApi.getCategorySimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ModelApi.ModelVO + if (formType.value === 'create') { + await ModelApi.createModel(data) + // 提示,引导用户做后续的操作 + await ElMessageBox.alert( + '<strong>新建模型成功!</strong>后续需要执行如下 3 个步骤:' + + '<div>1. 点击【修改流程】按钮,配置流程的分类、表单信息</div>' + + '<div>2. 点击【设计流程】按钮,绘制流程图</div>' + + '<div>3. 点击【发布流程】按钮,完成流程的最终发布</div>' + + '另外,每次流程修改后,都需要点击【发布流程】按钮,才能正式生效!!!', + '重要提示', + { + dangerouslyUseHTMLString: true, + type: 'success' + } + ) + } else { + await ModelApi.updateModel(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + formType: 10, + name: '', + category: undefined, + icon: '', + description: '', + formId: '', + formCustomCreatePath: '', + formCustomViewPath: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/bpm/model/ModelImportForm.vue b/src/views/bpm/model/ModelImportForm.vue new file mode 100644 index 0000000..9a91e1d --- /dev/null +++ b/src/views/bpm/model/ModelImportForm.vue @@ -0,0 +1,141 @@ +<template> + <Dialog v-model="dialogVisible" title="导入流程" width="400"> + <div> + <el-upload + ref="uploadRef" + v-model:file-list="fileList" + :action="importUrl" + :auto-upload="false" + :data="formData" + :disabled="formLoading" + :headers="uploadHeaders" + :limit="1" + :on-error="submitFormError" + :on-exceed="handleExceed" + :on-success="submitFormSuccess" + accept=".bpmn, .xml" + drag + name="bpmnFile" + > + <Icon class="el-icon--upload" icon="ep:upload-filled" /> + <div class="el-upload__text"> 将文件拖到此处,或 <em>点击上传</em></div> + <template #tip> + <div class="el-upload__tip" style="color: red"> + 提示:仅允许导入“bpm”或“xml”格式文件! + </div> + <div> + <el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px"> + <el-form-item label="流程标识" prop="key"> + <el-input + v-model="formData.key" + placeholder="请输入流标标识" + style="width: 250px" + /> + </el-form-item> + <el-form-item label="流程名称" prop="name"> + <el-input v-model="formData.name" clearable placeholder="请输入流程名称" /> + </el-form-item> + <el-form-item label="流程描述" prop="description"> + <el-input v-model="formData.description" clearable type="textarea" /> + </el-form-item> + </el-form> + </div> + </template> + </el-upload> + </div> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { getAccessToken, getTenantId } from '@/utils/auth' + +defineOptions({ name: 'ModelImportForm' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const formData = ref({ + key: '', + name: '', + description: '' +}) +const formRules = reactive({ + key: [{ required: true, message: '流程标识不能为空', trigger: 'blur' }], + name: [{ required: true, message: '流程名称不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const uploadRef = ref() // 上传 Ref +const importUrl = import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/bpm/model/import' +const uploadHeaders = ref() // 上传 Header 头 +const fileList = ref([]) // 文件列表 + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + resetForm() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + if (fileList.value.length == 0) { + message.error('请上传文件') + return + } + // 提交请求 + uploadHeaders.value = { + Authorization: 'Bearer ' + getAccessToken(), + 'tenant-id': getTenantId() + } + formLoading.value = true + uploadRef.value!.submit() +} + +/** 文件上传成功 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitFormSuccess = async (response: any) => { + if (response.code !== 0) { + message.error(response.msg) + formLoading.value = false + return + } + // 提示成功 + message.success('导入流程成功!请点击【设计流程】按钮,进行编辑保存后,才可以进行【发布流程】') + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') +} + +/** 上传错误提示 */ +const submitFormError = (): void => { + message.error('导入流程失败,请您重新上传!') + formLoading.value = false +} + +/** 重置表单 */ +const resetForm = () => { + // 重置上传状态和文件 + formLoading.value = false + uploadRef.value?.clearFiles() + // 重置表单 + formData.value = { + key: '', + name: '', + description: '' + } + formRef.value?.resetFields() +} + +/** 文件数超出提示 */ +const handleExceed = (): void => { + message.error('最多只能上传一个文件!') +} +</script> diff --git a/src/views/bpm/model/editor/index.vue b/src/views/bpm/model/editor/index.vue new file mode 100644 index 0000000..29bca71 --- /dev/null +++ b/src/views/bpm/model/editor/index.vue @@ -0,0 +1,115 @@ +<template> + <ContentWrap> + <!-- 流程设计器,负责绘制流程等 --> + <MyProcessDesigner + key="designer" + v-if="xmlString !== undefined" + v-model="xmlString" + :value="xmlString" + v-bind="controlForm" + keyboard + ref="processDesigner" + @init-finished="initModeler" + :additionalModel="controlForm.additionalModel" + @save="save" + /> + <!-- 流程属性器,负责编辑每个流程节点的属性 --> + <MyProcessPenal + key="penal" + :bpmnModeler="modeler as any" + :prefix="controlForm.prefix" + class="process-panel" + :model="model" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { MyProcessDesigner, MyProcessPenal } from '@/components/bpmnProcessDesigner/package' +// 自定义元素选中时的弹出菜单(修改 默认任务 为 用户任务) +import CustomContentPadProvider from '@/components/bpmnProcessDesigner/package/designer/plugins/content-pad' +// 自定义左侧菜单(修改 默认任务 为 用户任务) +import CustomPaletteProvider from '@/components/bpmnProcessDesigner/package/designer/plugins/palette' +import * as ModelApi from '@/api/bpm/model' + +defineOptions({ name: 'BpmModelEditor' }) + +const router = useRouter() // 路由 +const { query } = useRoute() // 路由的查询 +const message = useMessage() // 国际化 + +const xmlString = ref(undefined) // BPMN XML +const modeler = ref(null) // BPMN Modeler +const controlForm = ref({ + simulation: true, + labelEditing: false, + labelVisible: false, + prefix: 'flowable', + headerButtonSize: 'mini', + additionalModel: [CustomContentPadProvider, CustomPaletteProvider] +}) +const model = ref<ModelApi.ModelVO>() // 流程模型的信息 + +/** 初始化 modeler */ +const initModeler = (item) => { + setTimeout(() => { + modeler.value = item + }, 10) +} + +/** 添加/修改模型 */ +const save = async (bpmnXml) => { + const data = { + ...model.value, + bpmnXml: bpmnXml // bpmnXml 只是初始化流程图,后续修改无法通过它获得 + } as unknown as ModelApi.ModelVO + // 提交 + if (data.id) { + await ModelApi.updateModel(data) + message.success('修改成功') + } else { + await ModelApi.createModel(data) + message.success('新增成功') + } + // 跳转回去 + close() +} + +/** 关闭按钮 */ +const close = () => { + router.push({ path: '/bpm/manager/model' }) +} + +/** 初始化 */ +onMounted(async () => { + const modelId = query.modelId as unknown as number + if (!modelId) { + message.error('缺少模型 modelId 编号') + return + } + // 查询模型 + const data = await ModelApi.getModel(modelId) + if (!data.bpmnXml) { + // 首次创建的 Model 模型,它是没有 bpmnXml,此时需要给它一个默认的 + data.bpmnXml = ` <?xml version="1.0" encoding="UTF-8"?> +<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:xsd="http://www.w3.org/2001/XMLSchema" targetNamespace="http://www.activiti.org/processdef"> + <process id="${data.key}" name="${data.name}" isExecutable="true" /> + <bpmndi:BPMNDiagram id="BPMNDiagram"> + <bpmndi:BPMNPlane id="${data.key}_di" bpmnElement="${data.key}" /> + </bpmndi:BPMNDiagram> +</definitions>` + } + model.value = { + ...data, + bpmnXml: undefined // 清空 bpmnXml 属性 + } + xmlString.value = data.bpmnXml +}) +</script> +<style lang="scss"> +.process-panel__container { + position: absolute; + top: 90px; + right: 60px; +} +</style> diff --git a/src/views/bpm/model/index.vue b/src/views/bpm/model/index.vue new file mode 100644 index 0000000..e4ba6d4 --- /dev/null +++ b/src/views/bpm/model/index.vue @@ -0,0 +1,415 @@ +<template> + <doc-alert title="流程设计器(BPMN)" url="https://doc.iocoder.cn/bpm/model-designer-dingding/" /> + <doc-alert + title="流程设计器(钉钉、飞书)" + url="https://doc.iocoder.cn/bpm/model-designer-bpmn/" + /> + <doc-alert title="选择审批人、发起人自选" url="https://doc.iocoder.cn/bpm/assignee/" /> + <doc-alert title="会签、或签、依次审批" url="https://doc.iocoder.cn/bpm/multi-instance/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="流程标识" prop="key"> + <el-input + v-model="queryParams.key" + placeholder="请输入流程标识" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="流程名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入流程名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="流程分类" prop="category"> + <el-select + v-model="queryParams.category" + placeholder="请选择流程分类" + clearable + class="!w-240px" + > + <el-option + v-for="category in categoryList" + :key="category.code" + :label="category.name" + :value="category.code" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['bpm:model:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新建流程 + </el-button> + <el-button type="success" plain @click="openImportForm" v-hasPermi="['bpm:model:import']"> + <Icon icon="ep:upload" class="mr-5px" /> 导入流程 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="流程标识" align="center" prop="key" width="200" /> + <el-table-column label="流程名称" align="center" prop="name" width="200"> + <template #default="scope"> + <el-button type="primary" link @click="handleBpmnDetail(scope.row)"> + <span>{{ scope.row.name }}</span> + </el-button> + </template> + </el-table-column> + <el-table-column label="流程图标" align="center" prop="icon" width="100"> + <template #default="scope"> + <el-image :src="scope.row.icon" class="w-32px h-32px" /> + </template> + </el-table-column> + <el-table-column label="流程分类" align="center" prop="categoryName" width="100" /> + <el-table-column label="表单信息" align="center" prop="formType" width="200"> + <template #default="scope"> + <el-button + v-if="scope.row.formType === 10" + type="primary" + link + @click="handleFormDetail(scope.row)" + > + <span>{{ scope.row.formName }}</span> + </el-button> + <el-button + v-else-if="scope.row.formType === 20" + type="primary" + link + @click="handleFormDetail(scope.row)" + > + <span>{{ scope.row.formCustomCreatePath }}</span> + </el-button> + <label v-else>暂无表单</label> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="最新部署的流程定义" align="center"> + <el-table-column + label="流程版本" + align="center" + prop="processDefinition.version" + width="100" + > + <template #default="scope"> + <el-tag v-if="scope.row.processDefinition"> + v{{ scope.row.processDefinition.version }} + </el-tag> + <el-tag v-else type="warning">未部署</el-tag> + </template> + </el-table-column> + <el-table-column + label="激活状态" + align="center" + prop="processDefinition.version" + width="85" + > + <template #default="scope"> + <el-switch + v-if="scope.row.processDefinition" + v-model="scope.row.processDefinition.suspensionState" + :active-value="1" + :inactive-value="2" + @change="handleChangeState(scope.row)" + /> + </template> + </el-table-column> + <el-table-column label="部署时间" align="center" prop="deploymentTime" width="180"> + <template #default="scope"> + <span v-if="scope.row.processDefinition"> + {{ formatDate(scope.row.processDefinition.deploymentTime) }} + </span> + </template> + </el-table-column> + </el-table-column> + <el-table-column label="操作" align="center" width="240" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['bpm:model:update']" + > + 修改流程 + </el-button> + <el-button + link + type="primary" + @click="handleDesign(scope.row)" + v-hasPermi="['bpm:model:update']" + > + 设计流程 + </el-button> + <el-button + link + type="primary" + @click="handleSimpleDesign(scope.row.id)" + v-hasPermi="['bpm:model:update']" + > + 仿钉钉设计流程 + </el-button> + <el-button + link + type="primary" + @click="handleDeploy(scope.row)" + v-hasPermi="['bpm:model:deploy']" + > + 发布流程 + </el-button> + <el-button + link + type="primary" + v-hasPermi="['bpm:process-definition:query']" + @click="handleDefinitionList(scope.row)" + > + 流程定义 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['bpm:model:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改流程 --> + <ModelForm ref="formRef" @success="getList" /> + + <!-- 表单弹窗:导入流程 --> + <ModelImportForm ref="importFormRef" @success="getList" /> + + <!-- 弹窗:表单详情 --> + <Dialog title="表单详情" v-model="formDetailVisible" width="800"> + <form-create :rule="formDetailPreview.rule" :option="formDetailPreview.option" /> + </Dialog> + + <!-- 弹窗:流程模型图的预览 --> + <Dialog title="流程图" v-model="bpmnDetailVisible" width="800"> + <MyProcessViewer + key="designer" + v-model="bpmnXML" + :value="bpmnXML as any" + v-bind="bpmnControlForm" + :prefix="bpmnControlForm.prefix" + /> + </Dialog> +</template> + +<script lang="ts" setup> +import { dateFormatter, formatDate } from '@/utils/formatTime' +import { MyProcessViewer } from '@/components/bpmnProcessDesigner/package' +import * as ModelApi from '@/api/bpm/model' +import * as FormApi from '@/api/bpm/form' +import ModelForm from './ModelForm.vue' +import ModelImportForm from '@/views/bpm/model/ModelImportForm.vue' +import { setConfAndFields2 } from '@/utils/formCreate' +import { CategoryApi } from '@/api/bpm/category' + +defineOptions({ name: 'BpmModel' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const { push } = useRouter() // 路由 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + key: undefined, + name: undefined, + category: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const categoryList = ref([]) // 流程分类列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ModelApi.getModelPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 添加/修改操作 */ +const importFormRef = ref() +const openImportForm = () => { + importFormRef.value.open() +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ModelApi.deleteModel(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 更新状态操作 */ +const handleChangeState = async (row) => { + const state = row.processDefinition.suspensionState + try { + // 修改状态的二次确认 + const id = row.id + const statusState = state === 1 ? '激活' : '挂起' + const content = '是否确认' + statusState + '流程名字为"' + row.name + '"的数据项?' + await message.confirm(content) + // 发起修改状态 + await ModelApi.updateModelState(id, state) + // 刷新列表 + await getList() + } catch { + // 取消后,进行恢复按钮 + row.processDefinition.suspensionState = state === 1 ? 2 : 1 + } +} + +/** 设计流程 */ +const handleDesign = (row) => { + push({ + name: 'BpmModelEditor', + query: { + modelId: row.id + } + }) +} + +const handleSimpleDesign = (row) => { + push({ + name: 'SimpleWorkflowDesignEditor', + query: { + modelId: row.id + } + }) +} + +/** 发布流程 */ +const handleDeploy = async (row) => { + try { + // 删除的二次确认 + await message.confirm('是否部署该流程!!') + // 发起部署 + await ModelApi.deployModel(row.id) + message.success(t('部署成功')) + // 刷新列表 + await getList() + } catch {} +} + +/** 跳转到指定流程定义列表 */ +const handleDefinitionList = (row) => { + push({ + name: 'BpmProcessDefinition', + query: { + key: row.key + } + }) +} + +/** 流程表单的详情按钮操作 */ +const formDetailVisible = ref(false) +const formDetailPreview = ref({ + rule: [], + option: {} +}) +const handleFormDetail = async (row) => { + if (row.formType == 10) { + // 设置表单 + const data = await FormApi.getForm(row.formId) + setConfAndFields2(formDetailPreview, data.conf, data.fields) + // 弹窗打开 + formDetailVisible.value = true + } else { + await push({ + path: row.formCustomCreatePath + }) + } +} + +/** 流程图的详情按钮操作 */ +const bpmnDetailVisible = ref(false) +const bpmnXML = ref(null) +const bpmnControlForm = ref({ + prefix: 'flowable' +}) +const handleBpmnDetail = async (row) => { + const data = await ModelApi.getModel(row.id) + bpmnXML.value = data.bpmnXml || '' + bpmnDetailVisible.value = true +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 查询流程分类列表 + categoryList.value = await CategoryApi.getCategorySimpleList() +}) +</script> diff --git a/src/views/bpm/oa/leave/create.vue b/src/views/bpm/oa/leave/create.vue new file mode 100644 index 0000000..28a15af --- /dev/null +++ b/src/views/bpm/oa/leave/create.vue @@ -0,0 +1,164 @@ +<template> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="请假类型" prop="type"> + <el-select v-model="formData.type" clearable placeholder="请选择请假类型"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="开始时间" prop="startTime"> + <el-date-picker + v-model="formData.startTime" + clearable + placeholder="请选择开始时间" + type="datetime" + value-format="x" + /> + </el-form-item> + <el-form-item label="结束时间" prop="endTime"> + <el-date-picker + v-model="formData.endTime" + clearable + placeholder="请选择结束时间" + type="datetime" + value-format="x" + /> + </el-form-item> + <el-form-item label="原因" prop="reason"> + <el-input v-model="formData.reason" placeholder="请输请假原因" type="textarea" /> + </el-form-item> + <el-col v-if="startUserSelectTasks.length > 0"> + <el-card class="mb-10px"> + <template #header>指定审批人</template> + <el-form + :model="startUserSelectAssignees" + :rules="startUserSelectAssigneesFormRules" + ref="startUserSelectAssigneesFormRef" + > + <el-form-item + v-for="userTask in startUserSelectTasks" + :key="userTask.id" + :label="`任务【${userTask.name}】`" + :prop="userTask.id" + > + <el-select + v-model="startUserSelectAssignees[userTask.id]" + multiple + placeholder="请选择审批人" + > + <el-option + v-for="user in userList" + :key="user.id" + :label="user.nickname" + :value="user.id" + /> + </el-select> + </el-form-item> + </el-form> + </el-card> + </el-col> + <el-form-item> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + </el-form-item> + </el-form> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as LeaveApi from '@/api/bpm/leave' +import { useTagsViewStore } from '@/store/modules/tagsView' +import * as DefinitionApi from '@/api/bpm/definition' +import * as UserApi from '@/api/system/user' + +defineOptions({ name: 'BpmOALeaveCreate' }) + +const message = useMessage() // 消息弹窗 +const { delView } = useTagsViewStore() // 视图操作 +const { push, currentRoute } = useRouter() // 路由 + +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + type: undefined, + reason: undefined, + startTime: undefined, + endTime: undefined +}) +const formRules = reactive({ + type: [{ required: true, message: '请假类型不能为空', trigger: 'blur' }], + reason: [{ required: true, message: '请假原因不能为空', trigger: 'change' }], + startTime: [{ required: true, message: '请假开始时间不能为空', trigger: 'change' }], + endTime: [{ required: true, message: '请假结束时间不能为空', trigger: 'change' }] +}) +const formRef = ref() // 表单 Ref + +// 指定审批人 +const processDefineKey = 'oa_leave' // 流程定义 Key +const startUserSelectTasks = ref([]) // 发起人需要选择审批人的用户任务列表 +const startUserSelectAssignees = ref({}) // 发起人选择审批人的数据 +const startUserSelectAssigneesFormRef = ref() // 发起人选择审批人的表单 Ref +const startUserSelectAssigneesFormRules = ref({}) // 发起人选择审批人的表单 Rules +const userList = ref<any[]>([]) // 用户列表 + +/** 提交表单 */ +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 校验指定审批人 + if (startUserSelectTasks.value?.length > 0) { + await startUserSelectAssigneesFormRef.value.validate() + } + + // 提交请求 + formLoading.value = true + try { + const data = { ...formData.value } as unknown as LeaveApi.LeaveVO + // 设置指定审批人 + if (startUserSelectTasks.value?.length > 0) { + data.startUserSelectAssignees = startUserSelectAssignees.value + } + await LeaveApi.createLeave(data) + message.success('发起成功') + // 关闭当前 Tab + delView(unref(currentRoute)) + await push({ name: 'BpmOALeave' }) + } finally { + formLoading.value = false + } +} + +/** 初始化 */ +onMounted(async () => { + const processDefinitionDetail = await DefinitionApi.getProcessDefinition( + undefined, + processDefineKey + ) + if (!processDefinitionDetail) { + message.error('OA 请假的流程模型未配置,请检查!') + return + } + startUserSelectTasks.value = processDefinitionDetail.startUserSelectTasks + // 设置指定审批人 + if (startUserSelectTasks.value?.length > 0) { + // 设置校验规则 + for (const userTask of startUserSelectTasks.value) { + startUserSelectAssignees.value[userTask.id] = [] + startUserSelectAssigneesFormRules.value[userTask.id] = [ + { required: true, message: '请选择审批人', trigger: 'blur' } + ] + } + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() + } +}) +</script> diff --git a/src/views/bpm/oa/leave/detail.vue b/src/views/bpm/oa/leave/detail.vue new file mode 100644 index 0000000..87036d8 --- /dev/null +++ b/src/views/bpm/oa/leave/detail.vue @@ -0,0 +1,51 @@ +<template> + <ContentWrap> + <el-descriptions :column="1" border> + <el-descriptions-item label="请假类型"> + <dict-tag :type="DICT_TYPE.BPM_OA_LEAVE_TYPE" :value="detailData.type" /> + </el-descriptions-item> + <el-descriptions-item label="开始时间"> + {{ formatDate(detailData.startTime, 'YYYY-MM-DD') }} + </el-descriptions-item> + <el-descriptions-item label="结束时间"> + {{ formatDate(detailData.endTime, 'YYYY-MM-DD') }} + </el-descriptions-item> + <el-descriptions-item label="原因"> + {{ detailData.reason }} + </el-descriptions-item> + </el-descriptions> + </ContentWrap> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import { propTypes } from '@/utils/propTypes' +import * as LeaveApi from '@/api/bpm/leave' + +defineOptions({ name: 'BpmOALeaveDetail' }) + +const { query } = useRoute() // 查询参数 + +const props = defineProps({ + id: propTypes.number.def(undefined) +}) +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref<any>({}) // 详情数据 +const queryId = query.id as unknown as number // 从 URL 传递过来的 id 编号 + +/** 获得数据 */ +const getInfo = async () => { + detailLoading.value = true + try { + detailData.value = await LeaveApi.getLeave(props.id || queryId) + } finally { + detailLoading.value = false + } +} +defineExpose({ open: getInfo }) // 提供 open 方法,用于打开弹窗 + +/** 初始化 **/ +onMounted(() => { + getInfo() +}) +</script> diff --git a/src/views/bpm/oa/leave/index.vue b/src/views/bpm/oa/leave/index.vue new file mode 100644 index 0000000..27dbc19 --- /dev/null +++ b/src/views/bpm/oa/leave/index.vue @@ -0,0 +1,257 @@ +<template> + <doc-alert title="审批接入(业务表单)" url="https://doc.iocoder.cn/bpm/use-business-form/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="请假类型" prop="type"> + <el-select + v-model="queryParams.type" + class="!w-240px" + clearable + placeholder="请选择请假类型" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BPM_OA_LEAVE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="申请时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item label="审批结果" prop="status"> + <el-select + v-model="queryParams.status" + class="!w-240px" + clearable + placeholder="请选择审批结果" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="原因" prop="reason"> + <el-input + v-model="queryParams.reason" + class="!w-240px" + clearable + placeholder="请输入原因" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button plain type="primary" @click="handleCreate()"> + <Icon class="mr-5px" icon="ep:plus" /> + 发起请假 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="申请编号" prop="id" /> + <el-table-column align="center" label="状态" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="开始时间" + prop="startTime" + width="180" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="结束时间" + prop="endTime" + width="180" + /> + <el-table-column align="center" label="请假类型" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BPM_OA_LEAVE_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column align="center" label="原因" prop="reason" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="申请时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" label="操作" width="200"> + <template #default="scope"> + <el-button + v-hasPermi="['bpm:oa-leave:query']" + link + type="primary" + @click="handleDetail(scope.row)" + > + 详情 + </el-button> + <el-button + v-hasPermi="['bpm:oa-leave:query']" + link + type="primary" + @click="handleProcessDetail(scope.row)" + > + 进度 + </el-button> + <el-button + v-if="scope.row.result === 1" + v-hasPermi="['bpm:oa-leave:create']" + link + type="danger" + @click="cancelLeave(scope.row)" + > + 取消 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as LeaveApi from '@/api/bpm/leave' +import * as ProcessInstanceApi from '@/api/bpm/processInstance' + +defineOptions({ name: 'BpmOALeave' }) + +const message = useMessage() // 消息弹窗 +const router = useRouter() // 路由 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + type: undefined, + status: undefined, + reason: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await LeaveApi.getLeavePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加操作 */ +const handleCreate = () => { + router.push({ name: 'OALeaveCreate' }) +} + +/** 详情操作 */ +const handleDetail = (row: LeaveApi.LeaveVO) => { + router.push({ + name: 'OALeaveDetail', + query: { + id: row.id + } + }) +} + +/** 取消请假操作 */ +const cancelLeave = async (row) => { + // 二次确认 + const { value } = await ElMessageBox.prompt('请输入取消原因', '取消流程', { + confirmButtonText: t('common.ok'), + cancelButtonText: t('common.cancel'), + inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格 + inputErrorMessage: '取消原因不能为空' + }) + // 发起取消 + await ProcessInstanceApi.cancelProcessInstanceByStartUser(row.id, value) + message.success('取消成功') + // 刷新列表 + await getList() +} + +/** 审批进度 */ +const handleProcessDetail = (row) => { + router.push({ + name: 'BpmProcessInstanceDetail', + query: { + id: row.processInstanceId + } + }) +} + +// fix: 列表不刷新的问题。 +watch( + () => router.currentRoute.value, + () => { + getList() + } +) + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/processExpression/ProcessExpressionForm.vue b/src/views/bpm/processExpression/ProcessExpressionForm.vue new file mode 100644 index 0000000..acf0667 --- /dev/null +++ b/src/views/bpm/processExpression/ProcessExpressionForm.vue @@ -0,0 +1,114 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="表达式" prop="expression"> + <el-input type="textarea" v-model="formData.expression" placeholder="请输入表达式" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { ProcessExpressionApi, ProcessExpressionVO } from '@/api/bpm/processExpression' +import { CommonStatusEnum } from '@/utils/constants' + +/** BPM 流程 表单 */ +defineOptions({ name: 'ProcessExpressionForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + status: undefined, + expression: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }], + expression: [{ required: true, message: '表达式不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ProcessExpressionApi.getProcessExpression(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ProcessExpressionVO + if (formType.value === 'create') { + await ProcessExpressionApi.createProcessExpression(data) + message.success(t('common.createSuccess')) + } else { + await ProcessExpressionApi.updateProcessExpression(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + status: CommonStatusEnum.ENABLE, + expression: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/bpm/processExpression/index.vue b/src/views/bpm/processExpression/index.vue new file mode 100644 index 0000000..ec2de5a --- /dev/null +++ b/src/views/bpm/processExpression/index.vue @@ -0,0 +1,182 @@ +<template> + <doc-alert title="流程表达式" url="https://doc.iocoder.cn/bpm/expression/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名字" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['bpm:process-expression:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="表达式" align="center" prop="expression" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['bpm:process-expression:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['bpm:process-expression:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ProcessExpressionForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { ProcessExpressionApi, ProcessExpressionVO } from '@/api/bpm/processExpression' +import ProcessExpressionForm from './ProcessExpressionForm.vue' + +/** BPM 流程表达式列表 */ +defineOptions({ name: 'BpmProcessExpression' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ProcessExpressionVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProcessExpressionApi.getProcessExpressionPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ProcessExpressionApi.deleteProcessExpression(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/processInstance/create/index.vue b/src/views/bpm/processInstance/create/index.vue new file mode 100644 index 0000000..cc58888 --- /dev/null +++ b/src/views/bpm/processInstance/create/index.vue @@ -0,0 +1,257 @@ +<template> + <doc-alert title="流程发起、取消、重新发起" url="https://doc.iocoder.cn/bpm/process-instance/" /> + + <!-- 第一步,通过流程定义的列表,选择对应的流程 --> + <ContentWrap v-if="!selectProcessDefinition" v-loading="loading"> + <el-tabs tab-position="left" v-model="categoryActive"> + <el-tab-pane + :label="category.name" + :name="category.code" + :key="category.code" + v-for="category in categoryList" + > + <el-row :gutter="20"> + <el-col + :lg="6" + :sm="12" + :xs="24" + v-for="definition in categoryProcessDefinitionList" + :key="definition.id" + > + <el-card + shadow="hover" + class="mb-20px cursor-pointer" + @click="handleSelect(definition)" + > + <template #default> + <div class="flex"> + <el-image :src="definition.icon" class="w-32px h-32px" /> + <el-text class="!ml-10px" size="large">{{ definition.name }}</el-text> + </div> + </template> + </el-card> + </el-col> + </el-row> + </el-tab-pane> + </el-tabs> + </ContentWrap> + + <!-- 第二步,填写表单,进行流程的提交 --> + <ContentWrap v-else> + <el-card class="box-card"> + <div class="clearfix"> + <span class="el-icon-document">申请信息【{{ selectProcessDefinition.name }}】</span> + <el-button style="float: right" type="primary" @click="selectProcessDefinition = undefined"> + <Icon icon="ep:delete" /> 选择其它流程 + </el-button> + </div> + <el-col :span="16" :offset="6" style="margin-top: 20px"> + <form-create + :rule="detailForm.rule" + v-model:api="fApi" + v-model="detailForm.value" + :option="detailForm.option" + @submit="submitForm" + > + <template #type-startUserSelect> + <el-col :span="24"> + <el-card class="mb-10px"> + <template #header>指定审批人</template> + <el-form + :model="startUserSelectAssignees" + :rules="startUserSelectAssigneesFormRules" + ref="startUserSelectAssigneesFormRef" + > + <el-form-item + v-for="userTask in startUserSelectTasks" + :key="userTask.id" + :label="`任务【${userTask.name}】`" + :prop="userTask.id" + > + <el-select + v-model="startUserSelectAssignees[userTask.id]" + multiple + placeholder="请选择审批人" + > + <el-option + v-for="user in userList" + :key="user.id" + :label="user.nickname" + :value="user.id" + /> + </el-select> + </el-form-item> + </el-form> + </el-card> + </el-col> + </template> + </form-create> + </el-col> + </el-card> + <!-- 流程图预览 --> + <ProcessInstanceBpmnViewer :bpmn-xml="bpmnXML as any" /> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as DefinitionApi from '@/api/bpm/definition' +import * as ProcessInstanceApi from '@/api/bpm/processInstance' +import { setConfAndFields2 } from '@/utils/formCreate' +import type { ApiAttrs } from '@form-create/element-ui/types/config' +import ProcessInstanceBpmnViewer from '../detail/ProcessInstanceBpmnViewer.vue' +import { CategoryApi } from '@/api/bpm/category' +import { useTagsViewStore } from '@/store/modules/tagsView' +import * as UserApi from '@/api/system/user' + +defineOptions({ name: 'BpmProcessInstanceCreate' }) + +const route = useRoute() // 路由 +const { push, currentRoute } = useRouter() // 路由 +const message = useMessage() // 消息 +const { delView } = useTagsViewStore() // 视图操作 + +const processInstanceId = route.query.processInstanceId +const loading = ref(true) // 加载中 +const categoryList = ref([]) // 分类的列表 +const categoryActive = ref('') // 选中的分类 +const processDefinitionList = ref([]) // 流程定义的列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + // 流程分类 + categoryList.value = await CategoryApi.getCategorySimpleList() + if (categoryList.value.length > 0) { + categoryActive.value = categoryList.value[0].code + } + // 流程定义 + processDefinitionList.value = await DefinitionApi.getProcessDefinitionList({ + suspensionState: 1 + }) + + // 如果 processInstanceId 非空,说明是重新发起 + if (processInstanceId?.length > 0) { + const processInstance = await ProcessInstanceApi.getProcessInstance(processInstanceId) + if (!processInstance) { + message.error('重新发起流程失败,原因:流程实例不存在') + return + } + const processDefinition = processDefinitionList.value.find( + (item) => item.key == processInstance.processDefinition?.key + ) + if (!processDefinition) { + message.error('重新发起流程失败,原因:流程定义不存在') + return + } + await handleSelect(processDefinition, processInstance.formVariables) + } + } finally { + loading.value = false + } +} + +/** 选中分类对应的流程定义列表 */ +const categoryProcessDefinitionList = computed(() => { + return processDefinitionList.value.filter((item) => item.category == categoryActive.value) +}) + +// ========== 表单相关 ========== +const fApi = ref<ApiAttrs>() +const detailForm = ref({ + rule: [], + option: {}, + value: {} +}) // 流程表单详情 +const selectProcessDefinition = ref() // 选择的流程定义 + +// 指定审批人 +const bpmnXML = ref(null) // BPMN 数据 +const startUserSelectTasks = ref([]) // 发起人需要选择审批人的用户任务列表 +const startUserSelectAssignees = ref({}) // 发起人选择审批人的数据 +const startUserSelectAssigneesFormRef = ref() // 发起人选择审批人的表单 Ref +const startUserSelectAssigneesFormRules = ref({}) // 发起人选择审批人的表单 Rules +const userList = ref<any[]>([]) // 用户列表 + +/** 处理选择流程的按钮操作 **/ +const handleSelect = async (row, formVariables) => { + // 设置选择的流程 + selectProcessDefinition.value = row + + // 重置指定审批人 + startUserSelectTasks.value = [] + startUserSelectAssignees.value = {} + startUserSelectAssigneesFormRules.value = {} + + // 情况一:流程表单 + if (row.formType == 10) { + // 设置表单 + setConfAndFields2(detailForm, row.formConf, row.formFields, formVariables) + // 加载流程图 + const processDefinitionDetail = await DefinitionApi.getProcessDefinition(row.id) + if (processDefinitionDetail) { + bpmnXML.value = processDefinitionDetail.bpmnXml + startUserSelectTasks.value = processDefinitionDetail.startUserSelectTasks + + // 设置指定审批人 + if (startUserSelectTasks.value?.length > 0) { + detailForm.value.rule.push({ + type: 'startUserSelect', + props: { + title: '指定审批人' + } + }) + // 设置校验规则 + for (const userTask of startUserSelectTasks.value) { + startUserSelectAssignees.value[userTask.id] = [] + startUserSelectAssigneesFormRules.value[userTask.id] = [ + { required: true, message: '请选择审批人', trigger: 'blur' } + ] + } + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() + } + } + // 情况二:业务表单 + } else if (row.formCustomCreatePath) { + await push({ + path: row.formCustomCreatePath + }) + // 这里暂时无需加载流程图,因为跳出到另外个 Tab; + } +} + +/** 提交按钮 */ +const submitForm = async (formData) => { + if (!fApi.value || !selectProcessDefinition.value) { + return + } + // 如果有指定审批人,需要校验 + if (startUserSelectTasks.value?.length > 0) { + await startUserSelectAssigneesFormRef.value.validate() + } + + // 提交请求 + fApi.value.btn.loading(true) + try { + await ProcessInstanceApi.createProcessInstance({ + processDefinitionId: selectProcessDefinition.value.id, + variables: formData, + startUserSelectAssignees: startUserSelectAssignees.value + }) + // 提示 + message.success('发起流程成功') + // 跳转回去 + delView(unref(currentRoute)) + await push({ + name: 'BpmProcessInstanceMy' + }) + } finally { + fApi.value.btn.loading(false) + } +} + +/** 初始化 */ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/processInstance/detail/ProcessInstanceBpmnViewer.vue b/src/views/bpm/processInstance/detail/ProcessInstanceBpmnViewer.vue new file mode 100644 index 0000000..8912593 --- /dev/null +++ b/src/views/bpm/processInstance/detail/ProcessInstanceBpmnViewer.vue @@ -0,0 +1,54 @@ +<template> + <el-card v-loading="loading" class="box-card"> + <template #header> + <span class="el-icon-picture-outline">流程图</span> + </template> + <MyProcessViewer + key="designer" + :activityData="activityList" + :prefix="bpmnControlForm.prefix" + :processInstanceData="processInstance" + :taskData="tasks" + :value="bpmnXml" + v-bind="bpmnControlForm" + /> + </el-card> +</template> +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { MyProcessViewer } from '@/components/bpmnProcessDesigner/package' +import * as ActivityApi from '@/api/bpm/activity' + +defineOptions({ name: 'BpmProcessInstanceBpmnViewer' }) + +const props = defineProps({ + loading: propTypes.bool, // 是否加载中 + id: propTypes.string, // 流程实例的编号 + processInstance: propTypes.any, // 流程实例的信息 + tasks: propTypes.array, // 流程任务的数组 + bpmnXml: propTypes.string // BPMN XML +}) + +const bpmnControlForm = ref({ + prefix: 'flowable' +}) +const activityList = ref([]) // 任务列表 + +/** 只有 loading 完成时,才去加载流程列表 */ +watch( + () => props.loading, + async (value) => { + if (value && props.id) { + activityList.value = await ActivityApi.getActivityList({ + processInstanceId: props.id + }) + } + } +) +</script> +<style> +.box-card { + width: 100%; + margin-bottom: 20px; +} +</style> diff --git a/src/views/bpm/processInstance/detail/ProcessInstanceTaskList.vue b/src/views/bpm/processInstance/detail/ProcessInstanceTaskList.vue new file mode 100644 index 0000000..f82e800 --- /dev/null +++ b/src/views/bpm/processInstance/detail/ProcessInstanceTaskList.vue @@ -0,0 +1,175 @@ +<template> + <el-card v-loading="loading" class="box-card"> + <template #header> + <span class="el-icon-picture-outline">审批记录</span> + </template> + <el-col :offset="3" :span="17"> + <div class="block"> + <el-timeline> + <el-timeline-item + v-if="processInstance.endTime" + :type="getProcessInstanceTimelineItemType(processInstance)" + > + <p style="font-weight: 700"> + 结束流程:在 {{ formatDate(processInstance?.endTime) }} 结束 + <dict-tag + :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" + :value="processInstance.status" + /> + </p> + </el-timeline-item> + <el-timeline-item + v-for="(item, index) in tasks" + :key="index" + :type="getTaskTimelineItemType(item)" + > + <p style="font-weight: 700"> + 审批任务:{{ item.name }} + <dict-tag :type="DICT_TYPE.BPM_TASK_STATUS" :value="item.status" /> + <el-button + class="ml-10px" + v-if="!isEmpty(item.children)" + @click="openChildrenTask(item)" + size="small" + > + <Icon icon="ep:memo" /> 子任务 + </el-button> + <el-button + class="ml-10px" + size="small" + v-if="item.formId > 0" + @click="handleFormDetail(item)" + > + <Icon icon="ep:document" /> 查看表单 + </el-button> + </p> + <el-card :body-style="{ padding: '10px' }"> + <label v-if="item.assigneeUser" style="margin-right: 30px; font-weight: normal"> + 审批人:{{ item.assigneeUser.nickname }} + <el-tag size="small" type="info">{{ item.assigneeUser.deptName }}</el-tag> + </label> + <label v-if="item.createTime" style="font-weight: normal">创建时间:</label> + <label style="font-weight: normal; color: #8a909c"> + {{ formatDate(item?.createTime) }} + </label> + <label v-if="item.endTime" style="margin-left: 30px; font-weight: normal"> + 审批时间: + </label> + <label v-if="item.endTime" style="font-weight: normal; color: #8a909c"> + {{ formatDate(item?.endTime) }} + </label> + <label v-if="item.durationInMillis" style="margin-left: 30px; font-weight: normal"> + 耗时: + </label> + <label v-if="item.durationInMillis" style="font-weight: normal; color: #8a909c"> + {{ formatPast2(item?.durationInMillis) }} + </label> + <p v-if="item.reason"> 审批建议:{{ item.reason }} </p> + </el-card> + </el-timeline-item> + <el-timeline-item type="success"> + <p style="font-weight: 700"> + 发起流程:【{{ processInstance.startUser?.nickname }}】在 + {{ formatDate(processInstance?.startTime) }} 发起【 {{ processInstance.name }} 】流程 + </p> + </el-timeline-item> + </el-timeline> + </div> + </el-col> + </el-card> + + <!-- 弹窗:子任务 --> + <TaskSignList ref="taskSignListRef" @success="refresh" /> + <!-- 弹窗:表单 --> + <Dialog title="表单详情" v-model="taskFormVisible" width="600"> + <form-create + ref="fApi" + v-model="taskForm.value" + :option="taskForm.option" + :rule="taskForm.rule" + /> + </Dialog> +</template> +<script lang="ts" setup> +import { formatDate, formatPast2 } from '@/utils/formatTime' +import { propTypes } from '@/utils/propTypes' +import { DICT_TYPE } from '@/utils/dict' +import { isEmpty } from '@/utils/is' +import TaskSignList from './dialog/TaskSignList.vue' +import type { ApiAttrs } from '@form-create/element-ui/types/config' +import { setConfAndFields2 } from '@/utils/formCreate' + +defineOptions({ name: 'BpmProcessInstanceTaskList' }) + +defineProps({ + loading: propTypes.bool, // 是否加载中 + processInstance: propTypes.object, // 流程实例 + tasks: propTypes.arrayOf(propTypes.object) // 流程任务的数组 +}) + +/** 获得流程实例对应的颜色 */ +const getProcessInstanceTimelineItemType = (item: any) => { + if (item.status === 2) { + return 'success' + } + if (item.status === 3) { + return 'danger' + } + if (item.status === 4) { + return 'warning' + } + return '' +} + +/** 获得任务对应的颜色 */ +const getTaskTimelineItemType = (item: any) => { + if ([0, 1, 6, 7].includes(item.status)) { + return 'primary' + } + if (item.status === 2) { + return 'success' + } + if (item.status === 3) { + return 'danger' + } + if (item.status === 4) { + return 'info' + } + if (item.status === 5) { + return 'warning' + } + return '' +} + +/** 子任务 */ +const taskSignListRef = ref() +const openChildrenTask = (item: any) => { + taskSignListRef.value.open(item) +} + +/** 查看表单 */ +const fApi = ref<ApiAttrs>() // form-create 的 API 操作类 +const taskForm = ref({ + rule: [], + option: {}, + value: {} +}) // 流程任务的表单详情 +const taskFormVisible = ref(false) +const handleFormDetail = async (row) => { + // 设置表单 + setConfAndFields2(taskForm, row.formConf, row.formFields, row.formVariables) + // 弹窗打开 + taskFormVisible.value = true + // 隐藏提交、重置按钮,设置禁用只读 + await nextTick() + fApi.value.fapi.btn.show(false) + fApi.value?.fapi?.resetBtn.show(false) + fApi.value?.fapi?.disabled(true) +} + +/** 刷新数据 */ +const emit = defineEmits(['refresh']) // 定义 success 事件,用于操作成功后的回调 +const refresh = () => { + emit('refresh') +} +</script> diff --git a/src/views/bpm/processInstance/detail/dialog/TaskDelegateForm.vue b/src/views/bpm/processInstance/detail/dialog/TaskDelegateForm.vue new file mode 100644 index 0000000..178b1b9 --- /dev/null +++ b/src/views/bpm/processInstance/detail/dialog/TaskDelegateForm.vue @@ -0,0 +1,89 @@ +<template> + <Dialog v-model="dialogVisible" title="委派任务" width="500"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="110px" + > + <el-form-item label="接收人" prop="delegateUserId"> + <el-select v-model="formData.delegateUserId" clearable style="width: 100%"> + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="委派理由" prop="reason"> + <el-input v-model="formData.reason" clearable placeholder="请输入委派理由" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as TaskApi from '@/api/bpm/task' +import * as UserApi from '@/api/system/user' + +defineOptions({ name: 'BpmTaskDelegateForm' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const formData = ref({ + id: '', + delegateUserId: undefined, + reason: '' +}) +const formRules = ref({ + delegateUserId: [{ required: true, message: '接收人不能为空', trigger: 'change' }], + reason: [{ required: true, message: '委派理由不能为空', trigger: 'blur' }] +}) + +const formRef = ref() // 表单 Ref +const userList = ref<any[]>([]) // 用户列表 + +/** 打开弹窗 */ +const open = async (id: string) => { + dialogVisible.value = true + resetForm() + formData.value.id = id + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() +} +defineExpose({ open }) // 提供 openModal 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await TaskApi.delegateTask(formData.value) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: '', + delegateUserId: undefined, + reason: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/bpm/processInstance/detail/dialog/TaskReturnForm.vue b/src/views/bpm/processInstance/detail/dialog/TaskReturnForm.vue new file mode 100644 index 0000000..a139169 --- /dev/null +++ b/src/views/bpm/processInstance/detail/dialog/TaskReturnForm.vue @@ -0,0 +1,90 @@ +<template> + <Dialog v-model="dialogVisible" title="回退任务" width="500"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="110px" + > + <el-form-item label="退回节点" prop="targetTaskDefinitionKey"> + <el-select v-model="formData.targetTaskDefinitionKey" clearable style="width: 100%"> + <el-option + v-for="item in returnList" + :key="item.taskDefinitionKey" + :label="item.name" + :value="item.taskDefinitionKey" + /> + </el-select> + </el-form-item> + <el-form-item label="回退理由" prop="reason"> + <el-input v-model="formData.reason" clearable placeholder="请输入回退理由" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" name="TaskRollbackDialogForm" setup> +import * as TaskApi from '@/api/bpm/task' + +const message = useMessage() // 消息弹窗 +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const formData = ref({ + id: '', + targetTaskDefinitionKey: undefined, + reason: '' +}) +const formRules = ref({ + targetTaskDefinitionKey: [{ required: true, message: '必须选择回退节点', trigger: 'change' }], + reason: [{ required: true, message: '回退理由不能为空', trigger: 'blur' }] +}) + +const formRef = ref() // 表单 Ref +const returnList = ref([] as any) +/** 打开弹窗 */ +const open = async (id: string) => { + returnList.value = await TaskApi.getTaskListByReturn(id) + if (returnList.value.length === 0) { + message.warning('当前没有可回退的节点') + return false + } + dialogVisible.value = true + resetForm() + formData.value.id = id +} +defineExpose({ open }) // 提供 openModal 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await TaskApi.returnTask(formData.value) + message.success('回退成功') + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: '', + targetTaskDefinitionKey: undefined, + reason: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/bpm/processInstance/detail/dialog/TaskSignCreateForm.vue b/src/views/bpm/processInstance/detail/dialog/TaskSignCreateForm.vue new file mode 100644 index 0000000..9e4998c --- /dev/null +++ b/src/views/bpm/processInstance/detail/dialog/TaskSignCreateForm.vue @@ -0,0 +1,99 @@ +<template> + <Dialog v-model="dialogVisible" title="加签" width="500"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="110px" + > + <el-form-item label="加签处理人" prop="userIds"> + <el-select v-model="formData.userIds" multiple clearable style="width: 100%"> + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="加签理由" prop="reason"> + <el-input v-model="formData.reason" clearable placeholder="请输入加签理由" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm('before')"> + 向前加签 + </el-button> + <el-button :disabled="formLoading" type="primary" @click="submitForm('after')"> + 向后加签 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as TaskApi from '@/api/bpm/task' +import * as UserApi from '@/api/system/user' + +defineOptions({ name: 'TaskSignCreateForm' }) + +const message = useMessage() // 消息弹窗 +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const formData = ref({ + id: '', + userIds: [], + type: '', + reason: '' +}) +const formRules = ref({ + userIds: [{ required: true, message: '加签处理人不能为空', trigger: 'change' }], + reason: [{ required: true, message: '加签理由不能为空', trigger: 'change' }] +}) + +const formRef = ref() // 表单 Ref +const userList = ref<any[]>([]) // 用户列表 + +/** 打开弹窗 */ +const open = async (id: string) => { + dialogVisible.value = true + resetForm() + formData.value.id = id + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() +} +defineExpose({ open }) // 提供 openModal 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async (type: string) => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + formData.value.type = type + try { + await TaskApi.signCreateTask(formData.value) + message.success('加签成功') + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: '', + userIds: [], + type: '', + reason: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/bpm/processInstance/detail/dialog/TaskSignDeleteForm.vue b/src/views/bpm/processInstance/detail/dialog/TaskSignDeleteForm.vue new file mode 100644 index 0000000..19bb2dc --- /dev/null +++ b/src/views/bpm/processInstance/detail/dialog/TaskSignDeleteForm.vue @@ -0,0 +1,89 @@ +<template> + <Dialog v-model="dialogVisible" title="减签" width="500"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="110px" + > + <el-form-item label="减签任务" prop="id"> + <el-radio-group v-model="formData.id"> + <el-radio-button v-for="item in childrenTaskList" :key="item.id" :label="item.id"> + {{ item.name }} + ({{ item.assigneeUser?.deptName || item.ownerUser?.deptName }} - + {{ item.assigneeUser?.nickname || item.ownerUser?.nickname }}) + </el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item label="减签理由" prop="reason"> + <el-input v-model="formData.reason" clearable placeholder="请输入减签理由" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as TaskApi from '@/api/bpm/task' +import { isEmpty } from '@/utils/is' + +defineOptions({ name: 'TaskSignDeleteForm' }) + +const message = useMessage() // 消息弹窗 +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const formData = ref({ + id: '', + reason: '' +}) +const formRules = ref({ + id: [{ required: true, message: '必须选择减签任务', trigger: 'change' }], + reason: [{ required: true, message: '减签理由不能为空', trigger: 'blur' }] +}) + +const formRef = ref() // 表单 Ref +const childrenTaskList = ref([]) +/** 打开弹窗 */ +const open = async (id: string) => { + childrenTaskList.value = await TaskApi.getChildrenTaskList(id) + if (isEmpty(childrenTaskList.value)) { + message.warning('当前没有可减签的任务') + return false + } + dialogVisible.value = true + resetForm() +} +defineExpose({ open }) // 提供 openModal 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await TaskApi.signDeleteTask(formData.value) + message.success('减签成功') + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: '', + reason: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/bpm/processInstance/detail/dialog/TaskSignList.vue b/src/views/bpm/processInstance/detail/dialog/TaskSignList.vue new file mode 100644 index 0000000..648e86b --- /dev/null +++ b/src/views/bpm/processInstance/detail/dialog/TaskSignList.vue @@ -0,0 +1,106 @@ +<template> + <el-drawer v-model="drawerVisible" title="子任务" size="880px"> + <!-- 当前任务 --> + <template #header> + <h4>【{{ parentTask.name }} 】审批人:{{ parentTask?.assigneeUser?.nickname }}</h4> + <el-button + style="margin-left: 5px" + v-if="isSignDeleteButtonVisible(parentTask)" + type="danger" + plain + @click="handleSignDelete(parentTask)" + > + <Icon icon="ep:remove" /> 减签 + </el-button> + </template> + <!-- 子任务列表 --> + <el-table :data="parentTask.children" style="width: 100%" row-key="id" border> + <el-table-column prop="assigneeUser.nickname" label="审批人" min-width="100"> + <template #default="scope"> + {{ scope.row.assigneeUser?.nickname || scope.row.ownerUser?.nickname }} + </template> + </el-table-column> + <el-table-column prop="assigneeUser.deptName" label="所在部门" min-width="100"> + <template #default="scope"> + {{ scope.row.assigneeUser?.deptName || scope.row.ownerUser?.deptName }} + </template> + </el-table-column> + <el-table-column label="审批状态" prop="status" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BPM_TASK_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="提交时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column + label="结束时间" + align="center" + prop="endTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" prop="operation" width="90"> + <template #default="scope"> + <el-button + v-if="isSignDeleteButtonVisible(scope.row)" + type="danger" + plain + size="small" + @click="handleSignDelete(scope.row)" + > + <Icon icon="ep:remove" /> 减签 + </el-button> + </template> + </el-table-column> + </el-table> + + <!-- 减签 --> + <TaskSignDeleteForm ref="taskSignDeleteFormRef" @success="handleSignDeleteSuccess" /> + </el-drawer> +</template> +<script lang="ts" setup> +import { isEmpty } from '@/utils/is' +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import TaskSignDeleteForm from './TaskSignDeleteForm.vue' + +defineOptions({ name: 'TaskSignList' }) + +const message = useMessage() // 消息弹窗 +const drawerVisible = ref(false) // 抽屉的是否展示 +const parentTask = ref({} as any) + +/** 打开弹窗 */ +const open = async (task: any) => { + if (isEmpty(task.children)) { + message.warning('该任务没有子任务') + return + } + parentTask.value = task + // 展开抽屉 + drawerVisible.value = true +} +defineExpose({ open }) // 提供 openModal 方法,用于打开弹窗 + +/** 发起减签 */ +const taskSignDeleteFormRef = ref() +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const handleSignDelete = (item: any) => { + taskSignDeleteFormRef.value.open(item.id) +} +const handleSignDeleteSuccess = () => { + emit('success') + // 关闭抽屉 + drawerVisible.value = false +} + +/** 是否显示减签按钮 */ +const isSignDeleteButtonVisible = (task: any) => { + return task && task.children && !isEmpty(task.children) +} +</script> diff --git a/src/views/bpm/processInstance/detail/dialog/TaskTransferForm.vue b/src/views/bpm/processInstance/detail/dialog/TaskTransferForm.vue new file mode 100644 index 0000000..c1012ac --- /dev/null +++ b/src/views/bpm/processInstance/detail/dialog/TaskTransferForm.vue @@ -0,0 +1,89 @@ +<template> + <Dialog v-model="dialogVisible" title="转派任务" width="500"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="110px" + > + <el-form-item label="新审批人" prop="assigneeUserId"> + <el-select v-model="formData.assigneeUserId" clearable style="width: 100%"> + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="转派理由" prop="reason"> + <el-input v-model="formData.reason" clearable placeholder="请输入转派理由" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as TaskApi from '@/api/bpm/task' +import * as UserApi from '@/api/system/user' + +defineOptions({ name: 'TaskTransferForm' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const formData = ref({ + id: '', + assigneeUserId: undefined, + reason: '' +}) +const formRules = ref({ + assigneeUserId: [{ required: true, message: '新审批人不能为空', trigger: 'change' }], + reason: [{ required: true, message: '转派理由不能为空', trigger: 'blur' }] +}) + +const formRef = ref() // 表单 Ref +const userList = ref<any[]>([]) // 用户列表 + +/** 打开弹窗 */ +const open = async (id: string) => { + dialogVisible.value = true + resetForm() + formData.value.id = id + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() +} +defineExpose({ open }) // 提供 openModal 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await TaskApi.transferTask(formData.value) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: '', + assigneeUserId: undefined, + reason: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/bpm/processInstance/detail/index.vue b/src/views/bpm/processInstance/detail/index.vue new file mode 100644 index 0000000..eafb7ea --- /dev/null +++ b/src/views/bpm/processInstance/detail/index.vue @@ -0,0 +1,381 @@ +<template> + <ContentWrap> + <!-- 审批信息 --> + <el-card + v-for="(item, index) in runningTasks" + :key="index" + v-loading="processInstanceLoading" + class="box-card" + > + <template #header> + <span class="el-icon-picture-outline">审批任务【{{ item.name }}】</span> + </template> + <el-col :offset="6" :span="16"> + <el-form + :ref="'form' + index" + :model="auditForms[index]" + :rules="auditRule" + label-width="100px" + > + <el-form-item v-if="processInstance && processInstance.name" label="流程名"> + {{ processInstance.name }} + </el-form-item> + <el-form-item v-if="processInstance && processInstance.startUser" label="流程发起人"> + {{ processInstance?.startUser.nickname }} + <el-tag size="small" type="info">{{ processInstance?.startUser.deptName }}</el-tag> + </el-form-item> + <el-card v-if="runningTasks[index].formId > 0" class="mb-15px !-mt-10px"> + <template #header> + <span class="el-icon-picture-outline"> + 填写表单【{{ runningTasks[index]?.formName }}】 + </span> + </template> + <form-create + v-model="approveForms[index].value" + v-model:api="approveFormFApis[index]" + :option="approveForms[index].option" + :rule="approveForms[index].rule" + /> + </el-card> + <el-form-item label="审批建议" prop="reason"> + <el-input + v-model="auditForms[index].reason" + placeholder="请输入审批建议" + type="textarea" + /> + </el-form-item> + <el-form-item label="抄送人" prop="copyUserIds"> + <el-select v-model="auditForms[index].copyUserIds" multiple placeholder="请选择抄送人"> + <el-option + v-for="itemx in userOptions" + :key="itemx.id" + :label="itemx.nickname" + :value="itemx.id" + /> + </el-select> + </el-form-item> + </el-form> + <div style="margin-bottom: 20px; margin-left: 10%; font-size: 14px"> + <el-button type="success" @click="handleAudit(item, true)"> + <Icon icon="ep:select" /> + 通过 + </el-button> + <el-button type="danger" @click="handleAudit(item, false)"> + <Icon icon="ep:close" /> + 不通过 + </el-button> + <el-button type="primary" @click="openTaskUpdateAssigneeForm(item.id)"> + <Icon icon="ep:edit" /> + 转办 + </el-button> + <el-button type="primary" @click="handleDelegate(item)"> + <Icon icon="ep:position" /> + 委派 + </el-button> + <el-button type="primary" @click="handleSign(item)"> + <Icon icon="ep:plus" /> + 加签 + </el-button> + <el-button type="warning" @click="handleBack(item)"> + <Icon icon="ep:back" /> + 回退 + </el-button> + </div> + </el-col> + </el-card> + + <!-- 申请信息 --> + <el-card v-loading="processInstanceLoading" class="box-card"> + <template #header> + <span class="el-icon-document">申请信息【{{ processInstance.name }}】</span> + </template> + <!-- 情况一:流程表单 --> + <el-col v-if="processInstance?.processDefinition?.formType === 10" :offset="6" :span="16"> + <form-create + v-model="detailForm.value" + v-model:api="fApi" + :option="detailForm.option" + :rule="detailForm.rule" + /> + </el-col> + <!-- 情况二:业务表单 --> + <div v-if="processInstance?.processDefinition?.formType === 20"> + <BusinessFormComponent :id="processInstance.businessKey" /> + </div> + </el-card> + + <!-- 审批记录 --> + <ProcessInstanceTaskList + :loading="tasksLoad" + :process-instance="processInstance" + :tasks="tasks" + @refresh="getTaskList" + /> + + <!-- 高亮流程图 --> + <ProcessInstanceBpmnViewer + :id="`${id}`" + :bpmn-xml="bpmnXml" + :loading="processInstanceLoading" + :process-instance="processInstance" + :tasks="tasks" + /> + + <!-- 弹窗:转派审批人 --> + <TaskTransferForm ref="taskTransferFormRef" @success="getDetail" /> + <!-- 弹窗:回退节点 --> + <TaskReturnForm ref="taskReturnFormRef" @success="getDetail" /> + <!-- 弹窗:委派,将任务委派给别人处理,处理完成后,会重新回到原审批人手中--> + <TaskDelegateForm ref="taskDelegateForm" @success="getDetail" /> + <!-- 弹窗:加签,当前任务审批人为A,向前加签选了一个C,则需要C先审批,然后再是A审批,向后加签B,A审批完,需要B再审批完,才算完成这个任务节点 --> + <TaskSignCreateForm ref="taskSignCreateFormRef" @success="getDetail" /> + </ContentWrap> +</template> +<script lang="ts" setup> +import { useUserStore } from '@/store/modules/user' +import { setConfAndFields2 } from '@/utils/formCreate' +import type { ApiAttrs } from '@form-create/element-ui/types/config' +import * as DefinitionApi from '@/api/bpm/definition' +import * as ProcessInstanceApi from '@/api/bpm/processInstance' +import * as TaskApi from '@/api/bpm/task' +import ProcessInstanceBpmnViewer from './ProcessInstanceBpmnViewer.vue' +import ProcessInstanceTaskList from './ProcessInstanceTaskList.vue' +import TaskReturnForm from './dialog/TaskReturnForm.vue' +import TaskDelegateForm from './dialog/TaskDelegateForm.vue' +import TaskTransferForm from './dialog/TaskTransferForm.vue' +import TaskSignCreateForm from './dialog/TaskSignCreateForm.vue' +import { registerComponent } from '@/utils/routerHelper' +import { isEmpty } from '@/utils/is' +import * as UserApi from '@/api/system/user' + +defineOptions({ name: 'BpmProcessInstanceDetail' }) + +const { query } = useRoute() // 查询参数 +const message = useMessage() // 消息弹窗 +const { proxy } = getCurrentInstance() as any + +const userId = useUserStore().getUser.id // 当前登录的编号 +const id = query.id as unknown as string // 流程实例的编号 +const processInstanceLoading = ref(false) // 流程实例的加载中 +const processInstance = ref<any>({}) // 流程实例 +const bpmnXml = ref('') // BPMN XML +const tasksLoad = ref(true) // 任务的加载中 +const tasks = ref<any[]>([]) // 任务列表 +// ========== 审批信息 ========== +const runningTasks = ref<any[]>([]) // 运行中的任务 +const auditForms = ref<any[]>([]) // 审批任务的表单 +const auditRule = reactive({ + reason: [{ required: true, message: '审批建议不能为空', trigger: 'blur' }] +}) +const approveForms = ref<any[]>([]) // 审批通过时,额外的补充信息 +const approveFormFApis = ref<ApiAttrs[]>([]) // approveForms 的 fAPi + +// ========== 申请信息 ========== +const fApi = ref<ApiAttrs>() // +const detailForm = ref({ + rule: [], + option: {}, + value: {} +}) // 流程实例的表单详情 + +/** 监听 approveFormFApis,实现它对应的 form-create 初始化后,隐藏掉对应的表单提交按钮 */ +watch( + () => approveFormFApis.value, + (value) => { + value?.forEach((api) => { + api.btn.show(false) + api.resetBtn.show(false) + }) + }, + { + deep: true + } +) + +/** 处理审批通过和不通过的操作 */ +const handleAudit = async (task, pass) => { + // 1.1 获得对应表单 + const index = runningTasks.value.indexOf(task) + const auditFormRef = proxy.$refs['form' + index][0] + // 1.2 校验表单 + const elForm = unref(auditFormRef) + if (!elForm) return + const valid = await elForm.validate() + if (!valid) return + + // 2.1 提交审批 + const data = { + id: task.id, + reason: auditForms.value[index].reason, + copyUserIds: auditForms.value[index].copyUserIds + } + if (pass) { + // 审批通过,并且有额外的 approveForm 表单,需要校验 + 拼接到 data 表单里提交 + const formCreateApi = approveFormFApis.value[index] + if (formCreateApi) { + await formCreateApi.validate() + data.variables = approveForms.value[index].value + } + await TaskApi.approveTask(data) + message.success('审批通过成功') + } else { + await TaskApi.rejectTask(data) + message.success('审批不通过成功') + } + // 2.2 加载最新数据 + getDetail() +} + +/** 转派审批人 */ +const taskTransferFormRef = ref() +const openTaskUpdateAssigneeForm = (id: string) => { + taskTransferFormRef.value.open(id) +} + +/** 处理审批退回的操作 */ +const taskDelegateForm = ref() +const handleDelegate = async (task) => { + taskDelegateForm.value.open(task.id) +} + +/** 处理审批退回的操作 */ +const taskReturnFormRef = ref() +const handleBack = async (task: any) => { + taskReturnFormRef.value.open(task.id) +} + +/** 处理审批加签的操作 */ +const taskSignCreateFormRef = ref() +const handleSign = async (task: any) => { + taskSignCreateFormRef.value.open(task.id) +} + +/** 获得详情 */ +const getDetail = () => { + // 1. 获得流程实例相关 + getProcessInstance() + // 2. 获得流程任务列表(审批记录) + getTaskList() +} + +/** 加载流程实例 */ +const BusinessFormComponent = ref(null) // 异步组件 +const getProcessInstance = async () => { + try { + processInstanceLoading.value = true + const data = await ProcessInstanceApi.getProcessInstance(id) + if (!data) { + message.error('查询不到流程信息!') + return + } + processInstance.value = data + + // 设置表单信息 + const processDefinition = data.processDefinition + if (processDefinition.formType === 10) { + setConfAndFields2( + detailForm, + processDefinition.formConf, + processDefinition.formFields, + data.formVariables + ) + nextTick().then(() => { + fApi.value?.btn.show(false) + fApi.value?.resetBtn.show(false) + fApi.value?.disabled(true) + }) + } else { + // 注意:data.processDefinition.formCustomViewPath 是组件的全路径,例如说:/crm/contract/detail/index.vue + BusinessFormComponent.value = registerComponent(data.processDefinition.formCustomViewPath) + } + + // 加载流程图 + bpmnXml.value = ( + await DefinitionApi.getProcessDefinition(processDefinition.id as number) + )?.bpmnXml + } finally { + processInstanceLoading.value = false + } +} + +/** 加载任务列表 */ +const getTaskList = async () => { + runningTasks.value = [] + auditForms.value = [] + approveForms.value = [] + approveFormFApis.value = [] + try { + // 获得未取消的任务 + tasksLoad.value = true + const data = await TaskApi.getTaskListByProcessInstanceId(id) + tasks.value = [] + // 1.1 移除已取消的审批 + data.forEach((task) => { + if (task.status !== 4) { + tasks.value.push(task) + } + }) + // 1.2 排序,将未完成的排在前面,已完成的排在后面; + tasks.value.sort((a, b) => { + // 有已完成的情况,按照完成时间倒序 + if (a.endTime && b.endTime) { + return b.endTime - a.endTime + } else if (a.endTime) { + return 1 + } else if (b.endTime) { + return -1 + // 都是未完成,按照创建时间倒序 + } else { + return b.createTime - a.createTime + } + }) + + // 获得需要自己审批的任务 + loadRunningTask(tasks.value) + } finally { + tasksLoad.value = false + } +} + +/** + * 设置 runningTasks 中的任务 + */ +const loadRunningTask = (tasks) => { + tasks.forEach((task) => { + if (!isEmpty(task.children)) { + loadRunningTask(task.children) + } + // 2.1 只有待处理才需要 + if (task.status !== 1 && task.status !== 6) { + return + } + // 2.2 自己不是处理人 + if (!task.assigneeUser || task.assigneeUser.id !== userId) { + return + } + // 2.3 添加到处理任务 + runningTasks.value.push({ ...task }) + auditForms.value.push({ + reason: '', + copyUserIds: [] + }) + + // 2.4 处理 approve 表单 + if (task.formId && task.formConf) { + const approveForm = {} + setConfAndFields2(approveForm, task.formConf, task.formFields, task.formVariables) + approveForms.value.push(approveForm) + } else { + approveForms.value.push({}) // 占位,避免为空 + } + }) +} + +/** 初始化 */ +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +onMounted(async () => { + getDetail() + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/bpm/processInstance/index.vue b/src/views/bpm/processInstance/index.vue new file mode 100644 index 0000000..f1d6ca7 --- /dev/null +++ b/src/views/bpm/processInstance/index.vue @@ -0,0 +1,274 @@ +<template> + <doc-alert title="流程发起、取消、重新发起" url="https://doc.iocoder.cn/bpm/process-instance/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="流程名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入流程名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="所属流程" prop="processDefinitionId"> + <el-input + v-model="queryParams.processDefinitionId" + placeholder="请输入流程定义的编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="流程分类" prop="category"> + <el-select + v-model="queryParams.category" + placeholder="请选择流程分类" + clearable + class="!w-240px" + > + <el-option + v-for="category in categoryList" + :key="category.code" + :label="category.name" + :value="category.code" + /> + </el-select> + </el-form-item> + <el-form-item label="流程状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择流程状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="发起时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + v-hasPermi="['bpm:process-instance:query']" + @click="handleCreate(undefined)" + > + <Icon icon="ep:plus" class="mr-5px" /> 发起流程 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="流程名称" align="center" prop="name" min-width="200px" fixed="left" /> + <el-table-column + label="流程分类" + align="center" + prop="categoryName" + min-width="100" + fixed="left" + /> + <el-table-column label="流程状态" prop="status" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="发起时间" + align="center" + prop="startTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column + label="结束时间" + align="center" + prop="endTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column align="center" label="耗时" prop="durationInMillis" width="160"> + <template #default="scope"> + {{ scope.row.durationInMillis > 0 ? formatPast2(scope.row.durationInMillis) : '-' }} + </template> + </el-table-column> + <el-table-column label="当前审批任务" align="center" prop="tasks" min-width="120px"> + <template #default="scope"> + <el-button type="primary" v-for="task in scope.row.tasks" :key="task.id" link> + <span>{{ task.name }}</span> + </el-button> + </template> + </el-table-column> + <el-table-column label="流程编号" align="center" prop="id" min-width="320px" /> + <el-table-column label="操作" align="center" fixed="right" width="180"> + <template #default="scope"> + <el-button + link + type="primary" + v-hasPermi="['bpm:process-instance:cancel']" + @click="handleDetail(scope.row)" + > + 详情 + </el-button> + <el-button + link + type="primary" + v-if="scope.row.status === 1" + v-hasPermi="['bpm:process-instance:query']" + @click="handleCancel(scope.row)" + > + 取消 + </el-button> + <el-button link type="primary" v-else @click="handleCreate(scope.row)"> + 重新发起 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter, formatPast2 } from '@/utils/formatTime' +import { ElMessageBox } from 'element-plus' +import * as ProcessInstanceApi from '@/api/bpm/processInstance' +import { CategoryApi } from '@/api/bpm/category' +import { ProcessInstanceVO } from '@/api/bpm/processInstance' +import * as DefinitionApi from '@/api/bpm/definition' + +defineOptions({ name: 'BpmProcessInstanceMy' }) + +const router = useRouter() // 路由 +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: '', + processDefinitionId: undefined, + category: undefined, + status: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const categoryList = ref([]) // 流程分类列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProcessInstanceApi.getProcessInstanceMyPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 发起流程操作 **/ +const handleCreate = async (row?: ProcessInstanceVO) => { + // 如果是【业务表单】,不支持重新发起 + if (row?.id) { + const processDefinitionDetail = await DefinitionApi.getProcessDefinition( + row.processDefinitionId + ) + debugger + if (processDefinitionDetail.formType === 20) { + message.error('重新发起流程失败,原因:该流程使用业务表单,不支持重新发起') + return + } + } + // 跳转发起流程界面 + await router.push({ + name: 'BpmProcessInstanceCreate', + query: { processInstanceId: row?.id } + }) +} + +/** 查看详情 */ +const handleDetail = (row) => { + router.push({ + name: 'BpmProcessInstanceDetail', + query: { + id: row.id + } + }) +} + +/** 取消按钮操作 */ +const handleCancel = async (row) => { + // 二次确认 + const { value } = await ElMessageBox.prompt('请输入取消原因', '取消流程', { + confirmButtonText: t('common.ok'), + cancelButtonText: t('common.cancel'), + inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格 + inputErrorMessage: '取消原因不能为空' + }) + // 发起取消 + await ProcessInstanceApi.cancelProcessInstanceByStartUser(row.id, value) + message.success('取消成功') + // 刷新列表 + await getList() +} + +/** 激活时 **/ +onActivated(() => { + getList() +}) + +/** 初始化 **/ +onMounted(async () => { + await getList() + categoryList.value = await CategoryApi.getCategorySimpleList() +}) +</script> diff --git a/src/views/bpm/processInstance/manager/index.vue b/src/views/bpm/processInstance/manager/index.vue new file mode 100644 index 0000000..ab8da9c --- /dev/null +++ b/src/views/bpm/processInstance/manager/index.vue @@ -0,0 +1,255 @@ +<template> + <doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="发起人" prop="startUserId"> + <el-select v-model="queryParams.startUserId" placeholder="请选择发起人" class="!w-240px"> + <el-option + v-for="user in userList" + :key="user.id" + :label="user.nickname" + :value="user.id" + /> + </el-select> + </el-form-item> + <el-form-item label="流程名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入流程名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="所属流程" prop="processDefinitionId"> + <el-input + v-model="queryParams.processDefinitionId" + placeholder="请输入流程定义的编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="流程分类" prop="category"> + <el-select + v-model="queryParams.category" + placeholder="请选择流程分类" + clearable + class="!w-240px" + > + <el-option + v-for="category in categoryList" + :key="category.code" + :label="category.name" + :value="category.code" + /> + </el-select> + </el-form-item> + <el-form-item label="流程状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择流程状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="发起时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="流程名称" align="center" prop="name" min-width="200px" fixed="left" /> + <el-table-column + label="流程分类" + align="center" + prop="categoryName" + min-width="100" + fixed="left" + /> + <el-table-column label="流程发起人" align="center" prop="startUser.nickname" width="120" /> + <el-table-column label="发起部门" align="center" prop="startUser.deptName" width="120" /> + <el-table-column label="流程状态" prop="status" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="发起时间" + align="center" + prop="startTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column + label="结束时间" + align="center" + prop="endTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column align="center" label="耗时" prop="durationInMillis" width="169"> + <template #default="scope"> + {{ scope.row.durationInMillis > 0 ? formatPast2(scope.row.durationInMillis) : '-' }} + </template> + </el-table-column> + <el-table-column label="当前审批任务" align="center" prop="tasks" min-width="120px"> + <template #default="scope"> + <el-button type="primary" v-for="task in scope.row.tasks" :key="task.id" link> + <span>{{ task.name }}</span> + </el-button> + </template> + </el-table-column> + <el-table-column label="流程编号" align="center" prop="id" min-width="320px" /> + <el-table-column label="操作" align="center" fixed="right" width="180"> + <template #default="scope"> + <el-button + link + type="primary" + v-hasPermi="['bpm:process-instance:cancel']" + @click="handleDetail(scope.row)" + > + 详情 + </el-button> + <el-button + link + type="primary" + v-if="scope.row.status === 1" + v-hasPermi="['bpm:process-instance:query']" + @click="handleCancel(scope.row)" + > + 取消 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter, formatPast2 } from '@/utils/formatTime' +import { ElMessageBox } from 'element-plus' +import * as ProcessInstanceApi from '@/api/bpm/processInstance' +import { CategoryApi } from '@/api/bpm/category' +import * as UserApi from '@/api/system/user' +import { cancelProcessInstanceByAdmin } from '@/api/bpm/processInstance' + +// 它和【我的流程】的差异是,该菜单可以看全部的流程实例 +defineOptions({ name: 'BpmProcessInstanceManager' }) + +const router = useRouter() // 路由 +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + startUserId: undefined, + name: '', + processDefinitionId: undefined, + category: undefined, + status: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const categoryList = ref([]) // 流程分类列表 +const userList = ref<any[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProcessInstanceApi.getProcessInstanceManagerPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 查看详情 */ +const handleDetail = (row) => { + router.push({ + name: 'BpmProcessInstanceDetail', + query: { + id: row.id + } + }) +} + +/** 取消按钮操作 */ +const handleCancel = async (row) => { + // 二次确认 + const { value } = await ElMessageBox.prompt('请输入取消原因', '取消流程', { + confirmButtonText: t('common.ok'), + cancelButtonText: t('common.cancel'), + inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格 + inputErrorMessage: '取消原因不能为空' + }) + // 发起取消 + await ProcessInstanceApi.cancelProcessInstanceByAdmin(row.id, value) + message.success('取消成功') + // 刷新列表 + await getList() +} + +/** 激活时 **/ +onActivated(() => { + getList() +}) + +/** 初始化 **/ +onMounted(async () => { + await getList() + categoryList.value = await CategoryApi.getCategorySimpleList() + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/bpm/processListener/ProcessListenerForm.vue b/src/views/bpm/processListener/ProcessListenerForm.vue new file mode 100644 index 0000000..8d4e979 --- /dev/null +++ b/src/views/bpm/processListener/ProcessListenerForm.vue @@ -0,0 +1,162 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="110px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="类型" prop="type"> + <el-select + v-model="formData.type" + placeholder="请选择类型" + @change="formData.event = undefined" + > + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.BPM_PROCESS_LISTENER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="事件" prop="event"> + <el-select v-model="formData.event" placeholder="请选择事件"> + <el-option + v-for="event in formData.type == 'execution' + ? ['start', 'end'] + : ['create', 'assignment', 'complete', 'delete', 'update', 'timeout']" + :label="event" + :value="event" + :key="event" + /> + </el-select> + </el-form-item> + <el-form-item label="值类型" prop="valueType"> + <el-select v-model="formData.valueType" placeholder="请选择值类型"> + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="类路径" prop="value" v-if="formData.type == 'class'"> + <el-input v-model="formData.value" placeholder="请输入类路径" /> + </el-form-item> + <el-form-item label="表达式" prop="value" v-else> + <el-input v-model="formData.value" placeholder="请输入表达式" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, getStrDictOptions, DICT_TYPE } from '@/utils/dict' +import { ProcessListenerApi, ProcessListenerVO } from '@/api/bpm/processListener' +import { CommonStatusEnum } from '@/utils/constants' + +/** BPM 流程 表单 */ +defineOptions({ name: 'ProcessListenerForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + type: undefined, + status: undefined, + event: undefined, + valueType: undefined, + value: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + type: [{ required: true, message: '类型不能为空', trigger: 'change' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }], + event: [{ required: true, message: '监听事件不能为空', trigger: 'blur' }], + valueType: [{ required: true, message: '值类型不能为空', trigger: 'change' }], + value: [{ required: true, message: '值不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ProcessListenerApi.getProcessListener(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ProcessListenerVO + if (formType.value === 'create') { + await ProcessListenerApi.createProcessListener(data) + message.success(t('common.createSuccess')) + } else { + await ProcessListenerApi.updateProcessListener(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + type: undefined, + status: CommonStatusEnum.ENABLE, + event: undefined, + valueType: undefined, + value: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/bpm/processListener/index.vue b/src/views/bpm/processListener/index.vue new file mode 100644 index 0000000..8b5c36e --- /dev/null +++ b/src/views/bpm/processListener/index.vue @@ -0,0 +1,185 @@ +<template> + <doc-alert title="执行监听器、任务监听器" url="https://doc.iocoder.cn/bpm/listener/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="85px" + > + <el-form-item label="名字" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="类型" prop="type"> + <el-select v-model="queryParams.type" placeholder="请选择类型" clearable class="!w-240px"> + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.BPM_PROCESS_LISTENER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['bpm:process-listener:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column label="类型" align="center" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BPM_PROCESS_LISTENER_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="事件" align="center" prop="event" /> + <el-table-column label="值类型" align="center" prop="valueType"> + <template #default="scope"> + <dict-tag + :type="DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE" + :value="scope.row.valueType" + /> + </template> + </el-table-column> + <el-table-column label="值" align="center" prop="value" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['bpm:process-listener:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['bpm:process-listener:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ProcessListenerForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getStrDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { ProcessListenerApi, ProcessListenerVO } from '@/api/bpm/processListener' +import ProcessListenerForm from './ProcessListenerForm.vue' + +/** BPM 流程 列表 */ +defineOptions({ name: 'BpmProcessListener' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ProcessListenerVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + type: undefined, + event: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProcessListenerApi.getProcessListenerPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ProcessListenerApi.deleteProcessListener(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/simpleWorkflow/index.vue b/src/views/bpm/simpleWorkflow/index.vue new file mode 100644 index 0000000..144615e --- /dev/null +++ b/src/views/bpm/simpleWorkflow/index.vue @@ -0,0 +1,28 @@ +<template> + <div> + <section class="dingflow-design"> + <div class="box-scale"> + <nodeWrap v-model:nodeConfig="nodeConfig" /> + <div class="end-node"> + <div class="end-node-circle"></div> + <div class="end-node-text">流程结束</div> + </div> + </div> + </section> + </div> +</template> +<script lang="ts" setup> +import nodeWrap from '@/components/SimpleProcessDesigner/src/nodeWrap.vue' +defineOptions({ name: 'SimpleWorkflowDesignEditor' }) +let nodeConfig = ref({ + nodeName: '发起人', + type: 0, + id: 'root', + formPerms: {}, + nodeUserList: [], + childNode: {} +}) +</script> +<style> +@import url('@/components/SimpleProcessDesigner/theme/workflow.css'); +</style> \ No newline at end of file diff --git a/src/views/bpm/task/copy/index.vue b/src/views/bpm/task/copy/index.vue new file mode 100644 index 0000000..adc1fe3 --- /dev/null +++ b/src/views/bpm/task/copy/index.vue @@ -0,0 +1,137 @@ +<!-- 工作流 - 抄送我的流程 --> +<template> + <doc-alert + title="审批转办、委派、抄送" + url="https://doc.iocoder.cn/bpm/task-delegation-and-cc/" + /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form ref="queryFormRef" :inline="true" class="-mb-15px" label-width="68px"> + <el-form-item label="流程名称" prop="name"> + <el-input + v-model="queryParams.processInstanceName" + class="!w-240px" + clearable + placeholder="请输入流程名称" + /> + </el-form-item> + <el-form-item label="抄送时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="流程名" prop="processInstanceName" min-width="180" /> + <el-table-column align="center" label="流程发起人" prop="startUserName" min-width="100" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="流程发起时间" + prop="processInstanceStartTime" + width="180" + /> + <el-table-column align="center" label="抄送任务" prop="taskName" min-width="180" /> + <el-table-column align="center" label="抄送人" prop="creatorName" min-width="100" /> + <el-table-column + align="center" + label="抄送时间" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column align="center" label="操作" fixed="right" width="80"> + <template #default="scope"> + <el-button link type="primary" @click="handleAudit(scope.row)">详情</el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as ProcessInstanceApi from '@/api/bpm/processInstance' + +defineOptions({ name: 'BpmProcessInstanceCopy' }) + +const { push } = useRouter() // 路由 + +const loading = ref(false) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + processInstanceId: '', + processInstanceName: '', + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询任务列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProcessInstanceApi.getProcessInstanceCopyPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 处理审批按钮 */ +const handleAudit = (row: any) => { + push({ + name: 'BpmProcessInstanceDetail', + query: { + id: row.processInstanceId + } + }) +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/task/done/index.vue b/src/views/bpm/task/done/index.vue new file mode 100644 index 0000000..a513719 --- /dev/null +++ b/src/views/bpm/task/done/index.vue @@ -0,0 +1,170 @@ +<template> + <doc-alert title="审批通过、不通过、驳回" url="https://doc.iocoder.cn/bpm/task-todo-done/" /> + <doc-alert title="审批加签、减签" url="https://doc.iocoder.cn/bpm/sign/" /> + <doc-alert + title="审批转办、委派、抄送" + url="https://doc.iocoder.cn/bpm/task-delegation-and-cc/" + /> + <doc-alert title="审批加签、减签" url="https://doc.iocoder.cn/bpm/sign/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="任务名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入任务名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="流程" prop="processInstance.name" width="180" /> + <el-table-column + align="center" + label="发起人" + prop="processInstance.startUser.nickname" + width="100" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="发起时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" label="当前任务" prop="name" width="180" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="任务开始时间" + prop="createTime" + width="180" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="任务结束时间" + prop="endTime" + width="180" + /> + <el-table-column align="center" label="审批状态" prop="status" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BPM_TASK_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column align="center" label="审批建议" prop="reason" min-width="180" /> + <el-table-column align="center" label="耗时" prop="durationInMillis" width="160"> + <template #default="scope"> + {{ formatPast2(scope.row.durationInMillis) }} + </template> + </el-table-column> + <el-table-column align="center" label="流程编号" prop="id" :show-overflow-tooltip="true" /> + <el-table-column align="center" label="任务编号" prop="id" :show-overflow-tooltip="true" /> + <el-table-column align="center" label="操作" fixed="right" width="80"> + <template #default="scope"> + <el-button link type="primary" @click="handleAudit(scope.row)">历史</el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter, formatPast2 } from '@/utils/formatTime' +import * as TaskApi from '@/api/bpm/task' + +defineOptions({ name: 'BpmTodoTask' }) + +const { push } = useRouter() // 路由 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: '', + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询任务列表 */ +const getList = async () => { + loading.value = true + try { + const data = await TaskApi.getTaskDonePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 处理审批按钮 */ +const handleAudit = (row: any) => { + push({ + name: 'BpmProcessInstanceDetail', + query: { + id: row.processInstance.id + } + }) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/task/manager/index.vue b/src/views/bpm/task/manager/index.vue new file mode 100644 index 0000000..688e515 --- /dev/null +++ b/src/views/bpm/task/manager/index.vue @@ -0,0 +1,166 @@ +<template> + <doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="任务名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入任务名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="流程" prop="processInstance.name" width="180" /> + <el-table-column + align="center" + label="发起人" + prop="processInstance.startUser.nickname" + width="100" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="发起时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" label="当前任务" prop="name" width="180" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="任务开始时间" + prop="createTime" + width="180" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="任务结束时间" + prop="endTime" + width="180" + /> + <el-table-column align="center" label="审批人" prop="assigneeUser.nickname" width="100" /> + <el-table-column align="center" label="审批状态" prop="status" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BPM_TASK_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column align="center" label="审批建议" prop="reason" min-width="180" /> + <el-table-column align="center" label="耗时" prop="durationInMillis" width="160"> + <template #default="scope"> + {{ formatPast2(scope.row.durationInMillis) }} + </template> + </el-table-column> + <el-table-column align="center" label="流程编号" prop="id" :show-overflow-tooltip="true" /> + <el-table-column align="center" label="任务编号" prop="id" :show-overflow-tooltip="true" /> + <el-table-column align="center" label="操作" fixed="right" width="80"> + <template #default="scope"> + <el-button link type="primary" @click="handleAudit(scope.row)">历史</el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter, formatPast2 } from '@/utils/formatTime' +import * as TaskApi from '@/api/bpm/task' + +// 它和【待办任务】【已办任务】的差异是,该菜单可以看全部的流程任务 +defineOptions({ name: 'BpmManagerTask' }) + +const { push } = useRouter() // 路由 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: '', + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询任务列表 */ +const getList = async () => { + loading.value = true + try { + const data = await TaskApi.getTaskManagerPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 处理审批按钮 */ +const handleAudit = (row: any) => { + push({ + name: 'BpmProcessInstanceDetail', + query: { + id: row.processInstance.id + } + }) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/task/todo/index.vue b/src/views/bpm/task/todo/index.vue new file mode 100644 index 0000000..670fc68 --- /dev/null +++ b/src/views/bpm/task/todo/index.vue @@ -0,0 +1,152 @@ +<template> + <doc-alert title="审批通过、不通过、驳回" url="https://doc.iocoder.cn/bpm/task-todo-done/" /> + <doc-alert title="审批加签、减签" url="https://doc.iocoder.cn/bpm/sign/" /> + <doc-alert + title="审批转办、委派、抄送" + url="https://doc.iocoder.cn/bpm/task-delegation-and-cc/" + /> + <doc-alert title="审批加签、减签" url="https://doc.iocoder.cn/bpm/sign/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="任务名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入任务名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="流程" prop="processInstance.name" width="180" /> + <el-table-column + align="center" + label="发起人" + prop="processInstance.startUser.nickname" + width="100" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="发起时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" label="当前任务" prop="name" width="180" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="任务时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" label="流程编号" prop="id" :show-overflow-tooltip="true" /> + <el-table-column align="center" label="任务编号" prop="id" :show-overflow-tooltip="true" /> + <el-table-column align="center" label="操作" fixed="right" width="80"> + <template #default="scope"> + <el-button link type="primary" @click="handleAudit(scope.row)">办理</el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as TaskApi from '@/api/bpm/task' + +defineOptions({ name: 'BpmTodoTask' }) + +const { push } = useRouter() // 路由 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: '', + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询任务列表 */ +const getList = async () => { + loading.value = true + try { + const data = await TaskApi.getTaskTodoPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 处理审批按钮 */ +const handleAudit = (row: any) => { + push({ + name: 'BpmProcessInstanceDetail', + query: { + id: row.processInstance.id + } + }) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/backlog/components/ClueFollowList.vue b/src/views/crm/backlog/components/ClueFollowList.vue new file mode 100644 index 0000000..4ed37d4 --- /dev/null +++ b/src/views/crm/backlog/components/ClueFollowList.vue @@ -0,0 +1,153 @@ +<template> + <ContentWrap> + <div class="pb-5 text-xl">分配给我的线索</div> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="状态" prop="followUpStatus"> + <el-select + v-model="queryParams.followUpStatus" + class="!w-240px" + placeholder="状态" + @change="handleQuery" + > + <el-option + v-for="(option, index) in FOLLOWUP_STATUS" + :label="option.label" + :value="option.value" + :key="index" + /> + </el-select> + </el-form-item> + </el-form> + </ContentWrap> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="线索名称" align="center" prop="name" fixed="left" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column label="线索来源" align="center" prop="source" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" /> + </template> + </el-table-column> + <el-table-column label="手机" align="center" prop="mobile" width="120" /> + <el-table-column label="电话" align="center" prop="telephone" width="130" /> + <el-table-column label="邮箱" align="center" prop="email" width="180" /> + <el-table-column label="地址" align="center" prop="detailAddress" width="180" /> + <el-table-column align="center" label="客户行业" prop="industryId" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户级别" prop="level" width="135"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column + label="最后跟进时间" + align="center" + prop="contactLastTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100" /> + <el-table-column + label="更新时间" + align="center" + prop="updateTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> +<script setup lang="ts"> +import * as ClueApi from '@/api/crm/clue' +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { FOLLOWUP_STATUS } from './common' + +defineOptions({ name: 'CrmClueFollowList' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + followUpStatus: false, + transformStatus: false +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ClueApi.getCluePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 打开线索详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmClueDetail', params: { id } }) +} + +/** 激活时 */ +onActivated(async () => { + await getList() +}) + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/backlog/components/ContractAuditList.vue b/src/views/crm/backlog/components/ContractAuditList.vue new file mode 100644 index 0000000..9c13237 --- /dev/null +++ b/src/views/crm/backlog/components/ContractAuditList.vue @@ -0,0 +1,247 @@ +<!-- 待审核合同 --> +<template> + <ContentWrap> + <div class="pb-5 text-xl">待审核合同</div> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="合同状态" prop="auditStatus"> + <el-select + v-model="queryParams.auditStatus" + class="!w-240px" + placeholder="状态" + @change="handleQuery" + > + <el-option + v-for="(option, index) in AUDIT_STATUS" + :label="option.label" + :value="option.value" + :key="index" + /> + </el-select> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" fixed="left" label="合同编号" prop="no" width="180" /> + <el-table-column align="center" fixed="left" label="合同名称" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="客户名称" prop="customerName" width="120"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="商机名称" prop="businessName" width="130"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openBusinessDetail(scope.row.businessId)" + > + {{ scope.row.businessName }} + </el-link> + </template> + </el-table-column> + <el-table-column + align="center" + label="合同金额(元)" + prop="totalPrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + align="center" + label="下单时间" + prop="orderDate" + width="120" + :formatter="dateFormatter2" + /> + <el-table-column + align="center" + label="合同开始时间" + prop="startTime" + width="120" + :formatter="dateFormatter2" + /> + <el-table-column + align="center" + label="合同结束时间" + prop="endTime" + width="120" + :formatter="dateFormatter2" + /> + <el-table-column align="center" label="客户签约人" prop="contactName" width="130"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openContactDetail(scope.row.signContactId)" + > + {{ scope.row.signContactName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="公司签约人" prop="signUserName" width="130" /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column + align="center" + label="已回款金额(元)" + prop="totalReceivablePrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + align="center" + label="未回款金额(元)" + prop="totalReceivablePrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + > + <template #default="scope"> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.totalReceivablePrice) }} + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="120" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="120" /> + <el-table-column align="center" fixed="right" label="合同状态" prop="auditStatus" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="scope.row.auditStatus" /> + </template> + </el-table-column> + <el-table-column fixed="right" label="操作" width="90"> + <template #default="scope"> + <el-button + link + v-hasPermi="['crm:contract:update']" + type="primary" + @click="handleProcessDetail(scope.row)" + > + 查看审批 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts" name="CheckContract"> +import { dateFormatter, dateFormatter2 } from '@/utils/formatTime' +import * as ContractApi from '@/api/crm/contract' +import { DICT_TYPE } from '@/utils/dict' +import { AUDIT_STATUS } from './common' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + sceneType: 1, // 我负责的 + auditStatus: 10 +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ContractApi.getContractPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 查看审批 */ +const handleProcessDetail = (row: ContractApi.ContractVO) => { + push({ name: 'BpmProcessInstanceDetail', query: { id: row.processInstanceId } }) +} + +/** 打开合同详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmContractDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 打开联系人详情 */ +const openContactDetail = (id: number) => { + push({ name: 'CrmContactDetail', params: { id } }) +} + +/** 打开商机详情 */ +const openBusinessDetail = (id: number) => { + push({ name: 'CrmBusinessDetail', params: { id } }) +} + +/** 激活时 */ +onActivated(async () => { + await getList() +}) + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> + +<style scoped></style> diff --git a/src/views/crm/backlog/components/ContractRemindList.vue b/src/views/crm/backlog/components/ContractRemindList.vue new file mode 100644 index 0000000..0cacf35 --- /dev/null +++ b/src/views/crm/backlog/components/ContractRemindList.vue @@ -0,0 +1,246 @@ +<!-- 即将到期的合同 --> +<template> + <ContentWrap> + <div class="pb-5 text-xl"> 即将到期的合同 </div> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="到期状态" prop="expiryType"> + <el-select + v-model="queryParams.expiryType" + class="!w-240px" + placeholder="状态" + @change="handleQuery" + > + <el-option + v-for="(option, index) in CONTRACT_EXPIRY_TYPE" + :label="option.label" + :value="option.value" + :key="index" + /> + </el-select> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" fixed="left" label="合同编号" prop="no" width="180" /> + <el-table-column align="center" fixed="left" label="合同名称" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="客户名称" prop="customerName" width="120"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="商机名称" prop="businessName" width="130"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openBusinessDetail(scope.row.businessId)" + > + {{ scope.row.businessName }} + </el-link> + </template> + </el-table-column> + <el-table-column + align="center" + label="合同金额(元)" + prop="totalPrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + align="center" + label="下单时间" + prop="orderDate" + width="120" + :formatter="dateFormatter2" + /> + <el-table-column + align="center" + label="合同开始时间" + prop="startTime" + width="120" + :formatter="dateFormatter2" + /> + <el-table-column + align="center" + label="合同结束时间" + prop="endTime" + width="120" + :formatter="dateFormatter2" + /> + <el-table-column align="center" label="客户签约人" prop="contactName" width="130"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openContactDetail(scope.row.signContactId)" + > + {{ scope.row.signContactName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="公司签约人" prop="signUserName" width="130" /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column + align="center" + label="已回款金额(元)" + prop="totalReceivablePrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + align="center" + label="未回款金额(元)" + prop="totalReceivablePrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + > + <template #default="scope"> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.totalReceivablePrice) }} + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="120" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="120" /> + <el-table-column align="center" fixed="right" label="合同状态" prop="auditStatus" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="scope.row.auditStatus" /> + </template> + </el-table-column> + <el-table-column fixed="right" label="操作" width="90"> + <template #default="scope"> + <el-button + link + v-hasPermi="['crm:contract:update']" + type="primary" + @click="handleProcessDetail(scope.row)" + > + 查看审批 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts" name="EndContract"> +import { dateFormatter, dateFormatter2 } from '@/utils/formatTime' +import * as ContractApi from '@/api/crm/contract' +import { fenToYuanFormat } from '@/utils/formatter' +import { DICT_TYPE } from '@/utils/dict' +import { CONTRACT_EXPIRY_TYPE } from './common' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + sceneType: '1', // 自己负责的 + expiryType: 1 +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ContractApi.getContractPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 查看审批 */ +const handleProcessDetail = (row: ContractApi.ContractVO) => { + push({ name: 'BpmProcessInstanceDetail', query: { id: row.processInstanceId } }) +} + +/** 打开合同详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmContractDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 打开联系人详情 */ +const openContactDetail = (id: number) => { + push({ name: 'CrmContactDetail', params: { id } }) +} + +/** 打开商机详情 */ +const openBusinessDetail = (id: number) => { + push({ name: 'CrmBusinessDetail', params: { id } }) +} + +/** 激活时 */ +onActivated(async () => { + await getList() +}) + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/backlog/components/CustomerFollowList.vue b/src/views/crm/backlog/components/CustomerFollowList.vue new file mode 100644 index 0000000..0f367a3 --- /dev/null +++ b/src/views/crm/backlog/components/CustomerFollowList.vue @@ -0,0 +1,170 @@ +<!-- 分配给我的客户 --> +<!-- WHERE followUpStatus = ? --> +<template> + <ContentWrap> + <div class="pb-5 text-xl">分配给我的客户</div> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="状态" prop="followUpStatus"> + <el-select + v-model="queryParams.followUpStatus" + class="!w-240px" + placeholder="状态" + @change="handleQuery" + > + <el-option + v-for="(option, index) in FOLLOWUP_STATUS" + :label="option.label" + :value="option.value" + :key="index" + /> + </el-select> + </el-form-item> + </el-form> + </ContentWrap> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="客户名称" fixed="left" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="客户来源" prop="source" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" /> + </template> + </el-table-column> + <el-table-column label="手机" align="center" prop="mobile" width="120" /> + <el-table-column label="电话" align="center" prop="telephone" width="130" /> + <el-table-column label="邮箱" align="center" prop="email" width="180" /> + <el-table-column align="center" label="客户级别" prop="level" width="135"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户行业" prop="industryId" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column align="center" label="锁定状态" prop="lockStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.lockStatus" /> + </template> + </el-table-column> + <el-table-column align="center" label="成交状态" prop="dealStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealStatus" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" /> + <el-table-column label="地址" align="center" prop="detailAddress" width="180" /> + <el-table-column align="center" label="距离进入公海天数" prop="poolDay" width="140"> + <template #default="scope"> {{ scope.row.poolDay }} 天</template> + </el-table-column> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import * as CustomerApi from '@/api/crm/customer' +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { FOLLOWUP_STATUS } from './common' + +defineOptions({ name: 'CrmCustomerFollowList' }) + +const { push } = useRouter() + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + sceneType: 1, + followUpStatus: false +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CustomerApi.getCustomerPage(queryParams.value) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.value.pageNo = 1 + getList() +} + +/** 打开客户详情 */ +const openDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 激活时 */ +onActivated(async () => { + await getList() +}) + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/backlog/components/CustomerPutPoolRemindList.vue b/src/views/crm/backlog/components/CustomerPutPoolRemindList.vue new file mode 100644 index 0000000..17f8df6 --- /dev/null +++ b/src/views/crm/backlog/components/CustomerPutPoolRemindList.vue @@ -0,0 +1,169 @@ +<!-- 待进入公海的客户 --> +<template> + <ContentWrap> + <div class="pb-5 text-xl"> 待进入公海的客户 </div> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="归属" prop="sceneType"> + <el-select + v-model="queryParams.sceneType" + class="!w-240px" + placeholder="归属" + @change="handleQuery" + > + <el-option + v-for="(option, index) in SCENE_TYPES" + :label="option.label" + :value="option.value" + :key="index" + /> + </el-select> + </el-form-item> + </el-form> + </ContentWrap> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="客户名称" fixed="left" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="客户来源" prop="source" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" /> + </template> + </el-table-column> + <el-table-column label="手机" align="center" prop="mobile" width="120" /> + <el-table-column label="电话" align="center" prop="telephone" width="130" /> + <el-table-column label="邮箱" align="center" prop="email" width="180" /> + <el-table-column align="center" label="客户级别" prop="level" width="135"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户行业" prop="industryId" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column align="center" label="锁定状态" prop="lockStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.lockStatus" /> + </template> + </el-table-column> + <el-table-column align="center" label="成交状态" prop="dealStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealStatus" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" /> + <el-table-column label="地址" align="center" prop="detailAddress" width="180" /> + <el-table-column align="center" label="距离进入公海天数" prop="poolDay" width="140"> + <template #default="scope"> {{ scope.row.poolDay }} 天</template> + </el-table-column> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import * as CustomerApi from '@/api/crm/customer' +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { SCENE_TYPES } from './common' + +defineOptions({ name: 'CrmCustomerPutPoolRemindList' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + sceneType: 1, // 我负责的 + pool: true // 固定 公海参数为 true +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CustomerApi.getPutPoolRemindCustomerPage(queryParams.value) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.value.pageNo = 1 + getList() +} + +/** 打开客户详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 激活时 */ +onActivated(async () => { + await getList() +}) + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> + +<style lang="scss"></style> diff --git a/src/views/crm/backlog/components/CustomerTodayContactList.vue b/src/views/crm/backlog/components/CustomerTodayContactList.vue new file mode 100644 index 0000000..87aa31d --- /dev/null +++ b/src/views/crm/backlog/components/CustomerTodayContactList.vue @@ -0,0 +1,180 @@ +<template> + <ContentWrap> + <div class="pb-5 text-xl"> 今日需联系客户 </div> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="状态" prop="contactStatus"> + <el-select + v-model="queryParams.contactStatus" + class="!w-240px" + placeholder="状态" + @change="handleQuery" + > + <el-option + v-for="(option, index) in CONTACT_STATUS" + :label="option.label" + :value="option.value" + :key="index" + /> + </el-select> + </el-form-item> + <el-form-item label="归属" prop="sceneType"> + <el-select + v-model="queryParams.sceneType" + class="!w-240px" + placeholder="归属" + @change="handleQuery" + > + <el-option + v-for="(option, index) in SCENE_TYPES" + :label="option.label" + :value="option.value" + :key="index" + /> + </el-select> + </el-form-item> + </el-form> + </ContentWrap> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="客户名称" fixed="left" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="客户来源" prop="source" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" /> + </template> + </el-table-column> + <el-table-column label="手机" align="center" prop="mobile" width="120" /> + <el-table-column label="电话" align="center" prop="telephone" width="130" /> + <el-table-column label="邮箱" align="center" prop="email" width="180" /> + <el-table-column align="center" label="客户级别" prop="level" width="135"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户行业" prop="industryId" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column align="center" label="锁定状态" prop="lockStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.lockStatus" /> + </template> + </el-table-column> + <el-table-column align="center" label="成交状态" prop="dealStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealStatus" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" /> + <el-table-column label="地址" align="center" prop="detailAddress" width="180" /> + <el-table-column align="center" label="距离进入公海天数" prop="poolDay" width="140"> + <template #default="scope"> {{ scope.row.poolDay }} 天</template> + </el-table-column> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import * as CustomerApi from '@/api/crm/customer' +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { CONTACT_STATUS, SCENE_TYPES } from './common' + +defineOptions({ name: 'CrmCustomerTodayContactList' }) + +const { push } = useRouter() + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + contactStatus: 1, + sceneType: 1, + pool: null // 是否公海数据 +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CustomerApi.getCustomerPage(queryParams.value) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.value.pageNo = 1 + getList() +} + +/** 打开客户详情 */ +const openDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> + +<style lang="scss"></style> diff --git a/src/views/crm/backlog/components/ReceivableAuditList.vue b/src/views/crm/backlog/components/ReceivableAuditList.vue new file mode 100644 index 0000000..2831d45 --- /dev/null +++ b/src/views/crm/backlog/components/ReceivableAuditList.vue @@ -0,0 +1,201 @@ +<!-- 待审核回款 --> +<template> + <ContentWrap> + <div class="pb-5 text-xl"> 待审核回款 </div> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="合同状态" prop="auditStatus"> + <el-select + v-model="queryParams.auditStatus" + class="!w-240px" + placeholder="状态" + @change="handleQuery" + > + <el-option + v-for="(option, index) in AUDIT_STATUS" + :label="option.label" + :value="option.value" + :key="index" + /> + </el-select> + </el-form-item> + </el-form> + </ContentWrap> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column align="center" fixed="left" label="回款编号" prop="no" width="180"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.no }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="客户名称" prop="customerName" width="120"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="合同编号" prop="contractNo" width="180"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openContractDetail(scope.row.contractId)" + > + {{ scope.row.contract.no }} + </el-link> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter2" + align="center" + label="回款日期" + prop="returnTime" + width="150px" + /> + <el-table-column + align="center" + label="回款金额(元)" + prop="price" + width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column align="center" label="回款方式" prop="returnType" width="130px"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE" :value="scope.row.returnType" /> + </template> + </el-table-column> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column + align="center" + label="合同金额(元)" + prop="contract.totalPrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="120" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="120" /> + <el-table-column align="center" fixed="right" label="回款状态" prop="auditStatus" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="scope.row.auditStatus" /> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="180px"> + <template #default="scope"> + <el-button + v-hasPermi="['crm:receivable:update']" + link + type="primary" + @click="handleProcessDetail(scope.row)" + > + 查看审批 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter, dateFormatter2 } from '@/utils/formatTime' +import * as ReceivableApi from '@/api/crm/receivable' +import { AUDIT_STATUS } from './common' +import { erpPriceTableColumnFormatter } from '@/utils' + +defineOptions({ name: 'CrmReceivableAuditList' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + auditStatus: 10 +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ReceivableApi.getReceivablePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 查看审批 */ +const handleProcessDetail = (row: ReceivableApi.ReceivableVO) => { + push({ name: 'BpmProcessInstanceDetail', query: { id: row.processInstanceId } }) +} + +/** 打开回款详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmReceivableDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 打开合同详情 */ +const openContractDetail = (id: number) => { + push({ name: 'CrmContractDetail', params: { id } }) +} + +/** 激活时 */ +onActivated(async () => { + await getList() +}) + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/backlog/components/ReceivablePlanRemindList.vue b/src/views/crm/backlog/components/ReceivablePlanRemindList.vue new file mode 100644 index 0000000..9a3cf0c --- /dev/null +++ b/src/views/crm/backlog/components/ReceivablePlanRemindList.vue @@ -0,0 +1,220 @@ +<!-- 待回款提醒 --> +<template> + <ContentWrap> + <div class="pb-5 text-xl">待回款提醒</div> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="合同状态" prop="remindType"> + <el-select + v-model="queryParams.remindType" + class="!w-240px" + placeholder="状态" + @change="handleQuery" + > + <el-option + v-for="(option, index) in RECEIVABLE_REMIND_TYPE" + :label="option.label" + :value="option.value" + :key="index" + /> + </el-select> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column align="center" fixed="left" label="客户名称" prop="customerName" width="150"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="合同编号" prop="contractNo" width="200px" /> + <el-table-column align="center" label="期数" prop="period"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.period }} + </el-link> + </template> + </el-table-column> + <el-table-column + align="center" + label="计划回款金额(元)" + prop="price" + width="160" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + :formatter="dateFormatter2" + align="center" + label="计划回款日期" + prop="returnTime" + width="180px" + /> + <el-table-column align="center" label="提前几天提醒" prop="remindDays" width="150" /> + <el-table-column + align="center" + label="提醒日期" + prop="remindTime" + width="180px" + :formatter="dateFormatter2" + /> + <el-table-column align="center" label="回款方式" prop="returnType" width="130px"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE" :value="scope.row.returnType" /> + </template> + </el-table-column> + <el-table-column align="center" label="备注" prop="remark" /> + <el-table-column label="负责人" prop="ownerUserName" width="120" /> + <el-table-column + align="center" + label="实际回款金额(元)" + prop="receivable.price" + width="160" + > + <template #default="scope"> + <el-text v-if="scope.row.receivable"> + {{ erpPriceInputFormatter(scope.row.receivable.price) }} + </el-text> + <el-text v-else>{{ erpPriceInputFormatter(0) }}</el-text> + </template> + </el-table-column> + <el-table-column + align="center" + label="实际回款日期" + prop="receivable.returnTime" + width="180px" + :formatter="dateFormatter2" + /> + <el-table-column + align="center" + label="实际回款金额(元)" + prop="receivable.price" + width="160" + > + <template #default="scope"> + <el-text v-if="scope.row.receivable"> + {{ erpPriceInputFormatter(scope.row.price - scope.row.receivable.price) }} + </el-text> + <el-text v-else>{{ erpPriceInputFormatter(scope.row.price) }}</el-text> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + <el-table-column align="center" fixed="right" label="操作" width="180px"> + <template #default="scope"> + <el-button + v-hasPermi="['crm:receivable:create']" + link + type="success" + @click="openReceivableForm(scope.row)" + :disabled="scope.row.receivableId" + > + 创建回款 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ReceivableForm ref="receivableFormRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter, dateFormatter2 } from '@/utils/formatTime' +import * as ReceivablePlanApi from '@/api/crm/receivable/plan' +import { RECEIVABLE_REMIND_TYPE } from './common' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' +import ReceivableForm from '@/views/crm/receivable/ReceivableForm.vue' + +defineOptions({ name: 'ReceivablePlanRemindList' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + remindType: 1 +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ReceivablePlanApi.getReceivablePlanPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 创建回款操作 */ +const receivableFormRef = ref() +const openReceivableForm = (row: ReceivablePlanApi.ReceivablePlanVO) => { + receivableFormRef.value.open('create', undefined, row) +} + +/** 打开详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmReceivablePlanDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 激活时 */ +onActivated(async () => { + await getList() +}) + +/** 初始化 **/ +onMounted(async () => { + await getList() +}) +</script> diff --git a/src/views/crm/backlog/components/common.ts b/src/views/crm/backlog/components/common.ts new file mode 100644 index 0000000..9ff6bfc --- /dev/null +++ b/src/views/crm/backlog/components/common.ts @@ -0,0 +1,39 @@ +/** 跟进状态 */ +export const FOLLOWUP_STATUS = [ + { label: '待跟进', value: false }, + { label: '已跟进', value: true } +] + +/** 归属范围 */ +export const SCENE_TYPES = [ + { label: '我负责的', value: 1 }, + { label: '我参与的', value: 2 }, + { label: '下属负责的', value: 3 } +] + +/** 联系状态 */ +export const CONTACT_STATUS = [ + { label: '今日需联系', value: 1 }, + { label: '已逾期', value: 2 }, + { label: '已联系', value: 3 } +] + +/** 审批状态 */ +export const AUDIT_STATUS = [ + { label: '待审批', value: 10 }, + { label: '审核通过', value: 20 }, + { label: '审核不通过', value: 30 } +] + +/** 回款提醒类型 */ +export const RECEIVABLE_REMIND_TYPE = [ + { label: '待回款', value: 1 }, + { label: '已逾期', value: 2 }, + { label: '已回款', value: 3 } +] + +/** 合同过期状态 */ +export const CONTRACT_EXPIRY_TYPE = [ + { label: '即将过期', value: 1 }, + { label: '已过期', value: 2 } +] diff --git a/src/views/crm/backlog/index.vue b/src/views/crm/backlog/index.vue new file mode 100644 index 0000000..49a1d4c --- /dev/null +++ b/src/views/crm/backlog/index.vue @@ -0,0 +1,177 @@ +<template> + <doc-alert title="【通用】跟进记录、待办事项" url="https://doc.iocoder.cn/crm/follow-up/" /> + + <el-row :gutter="20"> + <el-col :span="4" class="min-w-[200px]"> + <div class="side-item-list"> + <div + v-for="(item, index) in leftSides" + :key="index" + :class="leftMenu == item.menu ? 'side-item-select' : 'side-item-default'" + class="side-item" + @click="sideClick(item)" + > + {{ item.name }} + <el-badge v-if="item.count > 0" :max="99" :value="item.count" /> + </div> + </div> + </el-col> + <el-col :span="20" :xs="24"> + <CustomerTodayContactList v-if="leftMenu === 'customerTodayContact'" /> + <ClueFollowList v-if="leftMenu === 'clueFollow'" /> + <ContractAuditList v-if="leftMenu === 'contractAudit'" /> + <ReceivableAuditList v-if="leftMenu === 'receivableAudit'" /> + <ContractRemindList v-if="leftMenu === 'contractRemind'" /> + <CustomerFollowList v-if="leftMenu === 'customerFollow'" /> + <CustomerPutPoolRemindList v-if="leftMenu === 'customerPutPoolRemind'" /> + <ReceivablePlanRemindList v-if="leftMenu === 'receivablePlanRemind'" /> + </el-col> + </el-row> +</template> + +<script lang="ts" setup> +import CustomerFollowList from './components/CustomerFollowList.vue' +import CustomerTodayContactList from './components/CustomerTodayContactList.vue' +import CustomerPutPoolRemindList from './components/CustomerPutPoolRemindList.vue' +import ClueFollowList from './components/ClueFollowList.vue' +import ContractAuditList from './components/ContractAuditList.vue' +import ContractRemindList from './components/ContractRemindList.vue' +import ReceivablePlanRemindList from './components/ReceivablePlanRemindList.vue' +import ReceivableAuditList from './components/ReceivableAuditList.vue' +import * as CustomerApi from '@/api/crm/customer' +import * as ClueApi from '@/api/crm/clue' +import * as ContractApi from '@/api/crm/contract' +import * as ReceivableApi from '@/api/crm/receivable' +import * as ReceivablePlanApi from '@/api/crm/receivable/plan' + +defineOptions({ name: 'CrmBacklog' }) + +const leftMenu = ref('customerTodayContact') + +const clueFollowCount = ref(0) +const customerFollowCount = ref(0) +const customerPutPoolRemindCount = ref(0) +const customerTodayContactCount = ref(0) +const contractAuditCount = ref(0) +const contractRemindCount = ref(0) +const receivableAuditCount = ref(0) +const receivablePlanRemindCount = ref(0) + +const leftSides = ref([ + { + name: '今日需联系客户', + menu: 'customerTodayContact', + count: customerTodayContactCount + }, + { + name: '分配给我的线索', + menu: 'clueFollow', + count: clueFollowCount + }, + { + name: '分配给我的客户', + menu: 'customerFollow', + count: customerFollowCount + }, + { + name: '待进入公海的客户', + menu: 'customerPutPoolRemind', + count: customerPutPoolRemindCount + }, + { + name: '待审核合同', + menu: 'contractAudit', + count: contractAuditCount + }, + { + name: '待审核回款', + menu: 'receivableAudit', + count: receivableAuditCount + }, + { + name: '待回款提醒', + menu: 'receivablePlanRemind', + count: receivablePlanRemindCount + }, + { + name: '即将到期的合同', + menu: 'contractRemind', + count: contractRemindCount + } +]) + +/** 侧边点击 */ +const sideClick = (item: any) => { + leftMenu.value = item.menu +} + +const getCount = () => { + CustomerApi.getTodayContactCustomerCount().then( + (count) => (customerTodayContactCount.value = count) + ) + CustomerApi.getPutPoolRemindCustomerCount().then( + (count) => (customerPutPoolRemindCount.value = count) + ) + CustomerApi.getFollowCustomerCount().then((count) => (customerFollowCount.value = count)) + ClueApi.getFollowClueCount().then((count) => (clueFollowCount.value = count)) + ContractApi.getAuditContractCount().then((count) => (contractAuditCount.value = count)) + ContractApi.getRemindContractCount().then((count) => (contractRemindCount.value = count)) + ReceivableApi.getAuditReceivableCount().then((count) => (receivableAuditCount.value = count)) + ReceivablePlanApi.getReceivablePlanRemindCount().then( + (count) => (receivablePlanRemindCount.value = count) + ) +} + +/** 激活时 */ +onActivated(async () => { + getCount() +}) + +/** 初始化 */ +onMounted(async () => { + getCount() +}) +</script> + +<style lang="scss" scoped> +.side-item-list { + top: 0; + bottom: 0; + left: 0; + z-index: 1; + font-size: 14px; + background-color: var(--el-bg-color); + border: 1px solid var(--el-border-color); + border-radius: 5px; + + .side-item { + position: relative; + height: 50px; + padding: 0 20px; + line-height: 50px; + cursor: pointer; + } +} + +.side-item-default { + color: var(--el-text-color-primary); + border-right: 2px solid transparent; +} + +.side-item-select { + color: var(--el-color-primary); + background-color: var(--el-color-primary-light-9); + border-right: 2px solid var(--el-color-primary); +} + +.el-badge :deep(.el-badge__content) { + top: 0; + border: none; +} + +.el-badge { + position: absolute; + top: 0; + right: 15px; +} +</style> diff --git a/src/views/crm/business/BusinessForm.vue b/src/views/crm/business/BusinessForm.vue new file mode 100644 index 0000000..6b03047 --- /dev/null +++ b/src/views/crm/business/BusinessForm.vue @@ -0,0 +1,287 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1280"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-row> + <el-col :span="8"> + <el-form-item label="商机名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入商机名称" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="负责人" prop="ownerUserId"> + <el-select + v-model="formData.ownerUserId" + :disabled="formType !== 'create'" + class="w-1/1" + > + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="客户名称" prop="customerId"> + <el-select + :disabled="formData.customerDefault" + v-model="formData.customerId" + placeholder="请选择客户" + class="w-1/1" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="8"> + <el-form-item label="商机状态组" prop="statusTypeId"> + <el-select + v-model="formData.statusTypeId" + placeholder="请选择商机状态组" + clearable + class="w-1/1" + :disabled="formType !== 'create'" + > + <el-option + v-for="item in statusTypeList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="预计成交日期" prop="dealTime"> + <el-date-picker + v-model="formData.dealTime" + type="date" + value-format="x" + placeholder="选择预计成交日期" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="备注" prop="remark"> + <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-col> + </el-row> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="产品清单" name="product"> + <BusinessProductForm + ref="productFormRef" + :products="formData.products" + :disabled="disabled" + /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <el-row> + <el-col :span="8"> + <el-form-item label="产品总金额" prop="totalProductPrice"> + <el-input + disabled + v-model="formData.totalProductPrice" + :formatter="erpPriceTableColumnFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="整单折扣(%)" prop="discountPercent"> + <el-input-number + v-model="formData.discountPercent" + placeholder="请输入整单折扣" + controls-position="right" + :min="0" + :precision="2" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="折扣后金额" prop="price"> + <el-input + disabled + v-model="formData.totalPrice" + placeholder="请输入商机金额" + :formatter="erpPriceTableColumnFormatter" + /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as BusinessApi from '@/api/crm/business' +import * as BusinessStatusApi from '@/api/crm/business/status' +import * as CustomerApi from '@/api/crm/customer' +import * as UserApi from '@/api/system/user' +import { useUserStore } from '@/store/modules/user' +import BusinessProductForm from './components/BusinessProductForm.vue' +import { erpPriceMultiply, erpPriceTableColumnFormatter } from '@/utils' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + customerId: undefined, + ownerUserId: undefined, + statusTypeId: undefined, + dealTime: undefined, + discountPercent: 0, + totalProductPrice: undefined, + totalPrice: undefined, + remark: undefined, + products: [], + contactId: undefined, + customerDefault: false +}) +const formRules = reactive({ + name: [{ required: true, message: '商机名称不能为空', trigger: 'blur' }], + customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }], + ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }], + statusTypeId: [{ required: true, message: '商机状态组不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const statusTypeList = ref([]) // 商机状态类型列表 +const customerList = ref([]) // 客户列表的数据 + +/** 子表的表单 */ +const subTabsName = ref('product') +const productFormRef = ref() + +/** 计算 discountPrice、totalPrice 价格 */ +watch( + () => formData.value, + (val) => { + if (!val) { + return + } + const totalProductPrice = val.products.reduce((prev, curr) => prev + curr.totalPrice, 0) + const discountPrice = + val.discountPercent != null + ? erpPriceMultiply(totalProductPrice, val.discountPercent / 100.0) + : 0 + const totalPrice = totalProductPrice - discountPrice + // 赋值 + formData.value.totalProductPrice = totalProductPrice + formData.value.totalPrice = totalPrice + }, + { deep: true } +) + +/** 打开弹窗 */ +const open = async (type: string, id?: number, customerId?: number, contactId?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await BusinessApi.getBusiness(id) + } finally { + formLoading.value = false + } + } else { + if (customerId) { + formData.value.customerId = customerId + formData.value.customerDefault = true // 默认客户的选择,不允许变 + } + // 自动关联 contactId 联系人编号 + if (contactId) { + formData.value.contactId = contactId + } + } + // 获得客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() + // 加载商机状态类型列表 + statusTypeList.value = await BusinessStatusApi.getBusinessStatusTypeSimpleList() + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() + // 默认新建时选中自己 + if (formType.value === 'create') { + formData.value.ownerUserId = useUserStore().getUser.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + await productFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as BusinessApi.BusinessVO + if (formType.value === 'create') { + await BusinessApi.createBusiness(data) + message.success(t('common.createSuccess')) + } else { + await BusinessApi.updateBusiness(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + customerId: undefined, + ownerUserId: undefined, + statusTypeId: undefined, + dealTime: undefined, + discountPercent: 0, + totalProductPrice: undefined, + totalPrice: undefined, + remark: undefined, + products: [], + contactId: undefined, + customerDefault: false + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/crm/business/BusinessUpdateStatusForm.vue b/src/views/crm/business/BusinessUpdateStatusForm.vue new file mode 100644 index 0000000..4f2f761 --- /dev/null +++ b/src/views/crm/business/BusinessUpdateStatusForm.vue @@ -0,0 +1,108 @@ +<template> + <Dialog title="变更商机状态" v-model="dialogVisible" width="400"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="80px" + v-loading="formLoading" + > + <el-form-item label="商机阶段" prop="status"> + <el-select v-model="formData.status" placeholder="请选择商机阶段" class="w-1/1"> + <el-option + v-for="item in statusList" + :key="item.id" + :label="item.name + '(赢单率:' + item.percent + '%)'" + :value="item.id" + /> + <el-option + v-for="item in BusinessStatusApi.DEFAULT_STATUSES" + :key="item.endStatus" + :label="item.name + '(赢单率:' + item.percent + '%)'" + :value="-item.endStatus" + /> + </el-select> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as BusinessApi from '@/api/crm/business' +import * as BusinessStatusApi from '@/api/crm/business/status' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const formData = ref({ + id: undefined, + statusId: undefined, + endStatus: undefined, + status: undefined +}) +const formRules = reactive({ + status: [{ required: true, message: '商机阶段不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const statusList = ref([]) // 商机状态列表 + +/** 打开弹窗 */ +const open = async (business: BusinessApi.BusinessVO) => { + dialogVisible.value = true + resetForm() + formData.value = { + id: business.id, + statusId: business.statusId, + endStatus: business.endStatus, + status: business.endStatus != null ? -business.endStatus : business.statusId + } + // 加载状态列表 + formLoading.value = true + try { + statusList.value = await BusinessStatusApi.getBusinessStatusSimpleList(business.statusTypeId) + } finally { + formLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await BusinessApi.updateBusinessStatus({ + id: formData.value.id, + statusId: formData.value.status > 0 ? formData.value.status : undefined, + endStatus: formData.value.status < 0 ? -formData.value.status : undefined + }) + message.success('更新商机状态成功') + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + statusId: undefined, + endStatus: undefined, + status: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/crm/business/components/BusinessList.vue b/src/views/crm/business/components/BusinessList.vue new file mode 100644 index 0000000..f990606 --- /dev/null +++ b/src/views/crm/business/components/BusinessList.vue @@ -0,0 +1,186 @@ +<template> + <!-- 操作栏 --> + <el-row justify="end"> + <el-button @click="openForm"> + <Icon class="mr-5px" icon="ep:opportunity" /> + 创建商机 + </el-button> + <el-button + @click="openBusinessModal" + v-hasPermi="['crm:contact:create-business']" + v-if="queryParams.contactId" + > + <Icon class="mr-5px" icon="ep:circle-plus" />关联 + </el-button> + <el-button + @click="deleteContactBusinessList" + v-hasPermi="['crm:contact:delete-business']" + v-if="queryParams.contactId" + > + <Icon class="mr-5px" icon="ep:remove" />解除关联 + </el-button> + </el-row> + + <!-- 列表 --> + <ContentWrap class="mt-10px"> + <el-table + ref="businessRef" + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + > + <el-table-column type="selection" width="55" v-if="queryParams.contactId" /> + <el-table-column label="商机名称" fixed="left" align="center" prop="name"> + <template #default="scope"> + <el-link type="primary" :underline="false" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column + label="商机金额" + align="center" + prop="price" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="客户名称" align="center" prop="customerName" /> + <el-table-column label="商机组" align="center" prop="statusTypeName" /> + <el-table-column label="商机阶段" align="center" prop="statusName" /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加 --> + <BusinessForm ref="formRef" @success="getList" /> + <!-- 关联商机选择弹框 --> + <BusinessListModal + ref="businessModalRef" + :customer-id="props.customerId" + @success="createContactBusinessList" + /> +</template> +<script setup lang="ts"> +import * as BusinessApi from '@/api/crm/business' +import * as ContactApi from '@/api/crm/contact' +import BusinessForm from './../BusinessForm.vue' +import { BizTypeEnum } from '@/api/crm/permission' +import BusinessListModal from './BusinessListModal.vue' +import { erpPriceTableColumnFormatter } from '@/utils' + +const message = useMessage() // 消息 + +defineOptions({ name: 'CrmBusinessList' }) +const props = defineProps<{ + bizType: number // 业务类型 + bizId: number // 业务编号 + customerId?: number // 关联联系人与商机时,需要传入 customerId 进行筛选 + contactId?: number // 特殊:联系人编号;在【联系人】详情中,可以传递联系人编号,默认新建的商机关联到该联系人 +}>() + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + customerId: undefined as unknown, // 允许 undefined + number + contactId: undefined as unknown // 允许 undefined + number +}) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + // 置空参数 + queryParams.customerId = undefined + queryParams.contactId = undefined + // 执行查询 + let data = { list: [], total: 0 } + switch (props.bizType) { + case BizTypeEnum.CRM_CUSTOMER: + queryParams.customerId = props.bizId + data = await BusinessApi.getBusinessPageByCustomer(queryParams) + break + case BizTypeEnum.CRM_CONTACT: + queryParams.contactId = props.bizId + data = await BusinessApi.getBusinessPageByContact(queryParams) + break + default: + return + } + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 添加操作 */ +const formRef = ref() +const openForm = () => { + formRef.value.open('create', null, props.customerId, props.contactId) +} + +/** 打开联系人详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmBusinessDetail', params: { id } }) +} + +/** 打开联系人与商机的关联弹窗 */ +const businessModalRef = ref() +const openBusinessModal = () => { + businessModalRef.value.open() +} +const createContactBusinessList = async (businessIds: number[]) => { + const data = { + contactId: props.bizId, + businessIds: businessIds + } as ContactApi.ContactBusinessReqVO + businessRef.value.getSelectionRows().forEach((row: BusinessApi.BusinessVO) => { + data.businessIds.push(row.id) + }) + await ContactApi.createContactBusinessList(data) + // 刷新列表 + message.success('关联商机成功') + handleQuery() +} + +/** 解除联系人与商机的关联 */ +const businessRef = ref() +const deleteContactBusinessList = async () => { + const data = { + contactId: props.bizId, + businessIds: businessRef.value.getSelectionRows().map((row: BusinessApi.BusinessVO) => row.id) + } as ContactApi.ContactBusinessReqVO + if (data.businessIds.length === 0) { + return message.error('未选择商机') + } + await ContactApi.deleteContactBusinessList(data) + // 刷新列表 + message.success('取关商机成功') + handleQuery() +} + +/** 监听打开的 bizId + bizType,从而加载最新的列表 */ +watch( + () => [props.bizId, props.bizType], + () => { + handleQuery() + }, + { immediate: true, deep: true } +) +</script> diff --git a/src/views/crm/business/components/BusinessListModal.vue b/src/views/crm/business/components/BusinessListModal.vue new file mode 100644 index 0000000..3c21f06 --- /dev/null +++ b/src/views/crm/business/components/BusinessListModal.vue @@ -0,0 +1,156 @@ +<template> + <Dialog title="关联商机" v-model="dialogVisible"> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="商机名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入商机名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button type="primary" @click="openForm()" v-hasPermi="['crm:business:create']"> + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap class="mt-10px"> + <el-table + v-loading="loading" + ref="businessRef" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + > + <el-table-column type="selection" width="55" /> + <el-table-column label="商机名称" fixed="left" align="center" prop="name"> + <template #default="scope"> + <el-link type="primary" :underline="false" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column + label="商机金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="客户名称" align="center" prop="customerName" /> + <el-table-column label="商机组" align="center" prop="statusTypeName" /> + <el-table-column label="商机阶段" align="center" prop="statusName" /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + + <!-- 表单弹窗:添加 --> + <BusinessForm ref="formRef" @success="getList" /> + </Dialog> +</template> +<script setup lang="ts"> +import * as BusinessApi from '@/api/crm/business' +import BusinessForm from '../BusinessForm.vue' +import { erpPriceTableColumnFormatter } from '@/utils' + +const message = useMessage() // 消息弹窗 +const props = defineProps<{ + customerId: number +}>() +defineOptions({ name: 'BusinessListModal' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryFormRef = ref() // 搜索的表单 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + customerId: props.customerId +}) + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + queryParams.customerId = props.customerId // 解决 props.customerId 没更新到 queryParams 上的问题 + await getList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BusinessApi.getBusinessPageByCustomer(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加操作 */ +const formRef = ref() +const openForm = () => { + formRef.value.open('create') +} + +/** 关联商机提交 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const businessRef = ref() +const submitForm = async () => { + const businessIds = businessRef.value + .getSelectionRows() + .map((row: BusinessApi.BusinessVO) => row.id) + if (businessIds.length === 0) { + return message.error('未选择商机') + } + dialogVisible.value = false + emit('success', businessIds, businessRef.value.getSelectionRows()) +} + +/** 打开商机详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmBusinessDetail', params: { id } }) +} +</script> diff --git a/src/views/crm/business/components/BusinessProductForm.vue b/src/views/crm/business/components/BusinessProductForm.vue new file mode 100644 index 0000000..fbba065 --- /dev/null +++ b/src/views/crm/business/components/BusinessProductForm.vue @@ -0,0 +1,183 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!"> + <el-select + v-model="row.productId" + clearable + filterable + @change="onChangeProduct($event, row)" + placeholder="请选择产品" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productNo" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="row.productUnit" /> + </template> + </el-table-column> + <el-table-column label="价格(元)" min-width="120"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="售价(元)" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.businessPrice`" class="mb-0px!"> + <el-input-number + v-model="row.businessPrice" + controls-position="right" + :min="0.001" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="合计" prop="totalPrice" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3" v-if="!disabled"> + <el-button @click="handleAdd" round>+ 添加产品</el-button> + </el-row> +</template> +<script setup lang="ts"> +import * as ProductApi from '@/api/crm/product' +import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils' +import { DICT_TYPE } from '@/utils/dict' + +const props = defineProps<{ + products: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + businessPrice: [{ required: true, message: '合同价格不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const productList = ref<ProductApi.ProductVO[]>([]) // 产品列表 + +/** 初始化设置产品项 */ +watch( + () => props.products, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + if (item.businessPrice != null && item.count != null) { + item.totalPrice = erpPriceMultiply(item.businessPrice, item.count) + } else { + item.totalPrice = undefined + } + }) + }, + { deep: true } +) + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + productId: undefined, + productUnit: undefined, // 产品单位 + productNo: undefined, // 产品条码 + productPrice: undefined, // 产品价格 + businessPrice: undefined, + count: 1 + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} + +/** 处理产品变更 */ +const onChangeProduct = (productId, row) => { + const product = productList.value.find((item) => item.id === productId) + if (product) { + row.productUnit = product.unit + row.productNo = product.no + row.productPrice = product.price + row.businessPrice = product.price + } +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + productList.value = await ProductApi.getProductSimpleList() +}) +</script> diff --git a/src/views/crm/business/detail/BusinessDetailsHeader.vue b/src/views/crm/business/detail/BusinessDetailsHeader.vue new file mode 100644 index 0000000..50d1efe --- /dev/null +++ b/src/views/crm/business/detail/BusinessDetailsHeader.vue @@ -0,0 +1,37 @@ +<template> + <div> + <div class="flex items-start justify-between"> + <div> + <el-col> + <el-row> + <span class="text-xl font-bold">{{ business.name }}</span> + </el-row> + </el-col> + </div> + <div> + <!-- 右上:按钮 --> + <slot></slot> + </div> + </div> + </div> + <ContentWrap class="mt-10px"> + <el-descriptions :column="5" direction="vertical"> + <el-descriptions-item label="客户名称">{{ business.customerName }}</el-descriptions-item> + <el-descriptions-item label="商机金额(元)"> + {{ erpPriceInputFormatter(business.totalPrice) }} + </el-descriptions-item> + <el-descriptions-item label="商机组">{{ business.statusTypeName }}</el-descriptions-item> + <el-descriptions-item label="负责人">{{ business.ownerUserName }}</el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(business.createTime) }} + </el-descriptions-item> + </el-descriptions> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as BusinessApi from '@/api/crm/business' +import { formatDate } from '@/utils/formatTime' +import { erpPriceInputFormatter } from '@/utils' + +const { business } = defineProps<{ business: BusinessApi.BusinessVO }>() +</script> diff --git a/src/views/crm/business/detail/BusinessDetailsInfo.vue b/src/views/crm/business/detail/BusinessDetailsInfo.vue new file mode 100644 index 0000000..a2c9ce1 --- /dev/null +++ b/src/views/crm/business/detail/BusinessDetailsInfo.vue @@ -0,0 +1,61 @@ +<template> + <ContentWrap> + <el-collapse v-model="activeNames"> + <el-collapse-item name="basicInfo"> + <template #title> + <span class="text-base font-bold">基本信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="商机姓名">{{ business.name }}</el-descriptions-item> + <el-descriptions-item label="客户名称">{{ business.customerName }}</el-descriptions-item> + <el-descriptions-item label="商机金额(元)"> + {{ erpPriceInputFormatter(business.totalPrice) }} + </el-descriptions-item> + <el-descriptions-item label="预计成交日期"> + {{ formatDate(business.dealTime) }} + </el-descriptions-item> + <el-descriptions-item label="下次联系时间"> + {{ formatDate(business.contactNextTime) }} + </el-descriptions-item> + <el-descriptions-item label="商机状态组"> + {{ business.statusTypeName }} + </el-descriptions-item> + <el-descriptions-item label="商机阶段">{{ business.statusName }}</el-descriptions-item> + <el-descriptions-item label="备注">{{ business.remark }}</el-descriptions-item> + </el-descriptions> + </el-collapse-item> + <el-collapse-item name="systemInfo"> + <template #title> + <span class="text-base font-bold">系统信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="负责人">{{ business.ownerUserName }}</el-descriptions-item> + <el-descriptions-item label="最后跟进时间"> + {{ formatDate(business.contactLastTime) }} + </el-descriptions-item> + <el-descriptions-item label=""> </el-descriptions-item> + <el-descriptions-item label=""> </el-descriptions-item> + <el-descriptions-item label="创建人">{{ business.creatorName }}</el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(business.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="更新时间"> + {{ formatDate(business.updateTime) }} + </el-descriptions-item> + </el-descriptions> + </el-collapse-item> + </el-collapse> + </ContentWrap> +</template> +<script setup lang="ts"> +import * as BusinessApi from '@/api/crm/business' +import { formatDate } from '@/utils/formatTime' +import { erpPriceInputFormatter } from '@/utils' + +const { business } = defineProps<{ + business: BusinessApi.BusinessVO +}>() + +// 展示的折叠面板 +const activeNames = ref(['basicInfo', 'systemInfo']) +</script> diff --git a/src/views/crm/business/detail/BusinessProductList.vue b/src/views/crm/business/detail/BusinessProductList.vue new file mode 100644 index 0000000..9a31665 --- /dev/null +++ b/src/views/crm/business/detail/BusinessProductList.vue @@ -0,0 +1,66 @@ +<template> + <ContentWrap> + <el-table :data="business.products" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column + align="center" + label="产品名称" + fixed="left" + prop="productName" + min-width="160" + > + <template #default="scope"> + {{ scope.row.productName }} + </template> + </el-table-column> + <el-table-column label="产品条码" align="center" prop="productNo" min-width="120" /> + <el-table-column align="center" label="产品单位" prop="productUnit" min-width="160"> + <template #default="{ row }"> + <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="row.productUnit" /> + </template> + </el-table-column> + <el-table-column + label="产品价格(元)" + align="center" + prop="productPrice" + min-width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="商机价格(元)" + align="center" + prop="businessPrice" + min-width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + align="center" + label="数量" + prop="count" + min-width="100px" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="合计金额(元)" + align="center" + prop="totalPrice" + min-width="140" + :formatter="erpPriceTableColumnFormatter" + /> + </el-table> + <el-row class="mt-10px" justify="end"> + <el-col :span="3"> 整单折扣:{{ erpPriceInputFormatter(business.discountPercent) }}% </el-col> + <el-col :span="4"> + 产品总金额:{{ erpPriceInputFormatter(business.totalProductPrice) }} 元 + </el-col> + </el-row> + </ContentWrap> +</template> +<script setup lang="ts"> +import * as BusinessApi from '@/api/crm/business' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { DICT_TYPE } from '@/utils/dict' + +const { business } = defineProps<{ + business: BusinessApi.BusinessVO +}>() +</script> diff --git a/src/views/crm/business/detail/index.vue b/src/views/crm/business/detail/index.vue new file mode 100644 index 0000000..dbab819 --- /dev/null +++ b/src/views/crm/business/detail/index.vue @@ -0,0 +1,146 @@ +<template> + <BusinessDetailsHeader v-loading="loading" :business="business"> + <el-button v-if="permissionListRef?.validateWrite" @click="openForm('update', business.id)"> + 编辑 + </el-button> + <el-button + v-if="permissionListRef?.validateWrite" + :disabled="business.endStatus" + type="success" + @click="openStatusForm()" + > + 变更商机状态 + </el-button> + <el-button v-if="permissionListRef?.validateOwnerUser" type="primary" @click="transfer"> + 转移 + </el-button> + </BusinessDetailsHeader> + <el-col> + <el-tabs> + <el-tab-pane label="跟进记录"> + <FollowUpList :biz-id="businessId" :biz-type="BizTypeEnum.CRM_BUSINESS" /> + </el-tab-pane> + <el-tab-pane label="详细资料"> + <BusinessDetailsInfo :business="business" /> + </el-tab-pane> + <el-tab-pane label="联系人" lazy> + <ContactList + :biz-id="business.id!" + :biz-type="BizTypeEnum.CRM_BUSINESS" + :business-id="business.id" + :customer-id="business.customerId" + /> + </el-tab-pane> + <el-tab-pane label="产品"> + <BusinessProductList :business="business" /> + </el-tab-pane> + <el-tab-pane label="合同" lazy> + <ContractList :biz-id="business.id!" :biz-type="BizTypeEnum.CRM_BUSINESS" /> + </el-tab-pane> + <el-tab-pane label="操作日志"> + <OperateLogV2 :log-list="logList" /> + </el-tab-pane> + <el-tab-pane label="团队成员"> + <PermissionList + ref="permissionListRef" + :biz-id="business.id!" + :biz-type="BizTypeEnum.CRM_BUSINESS" + :show-action="true" + @quit-team="close" + /> + </el-tab-pane> + </el-tabs> + </el-col> + + <!-- 表单弹窗:添加/修改 --> + <BusinessForm ref="formRef" @success="getBusiness" /> + <BusinessUpdateStatusForm ref="statusFormRef" @success="getBusiness" /> + <CrmTransferForm ref="transferFormRef" :biz-type="BizTypeEnum.CRM_BUSINESS" @success="close" /> +</template> +<script lang="ts" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import * as BusinessApi from '@/api/crm/business' +import BusinessDetailsHeader from './BusinessDetailsHeader.vue' +import BusinessDetailsInfo from './BusinessDetailsInfo.vue' +import PermissionList from '@/views/crm/permission/components/PermissionList.vue' // 团队成员列表(权限) +import { BizTypeEnum } from '@/api/crm/permission' +import { OperateLogVO } from '@/api/system/operatelog' +import { getOperateLogPage } from '@/api/crm/operateLog' +import BusinessForm from '@/views/crm/business/BusinessForm.vue' +import CrmTransferForm from '@/views/crm/permission/components/TransferForm.vue' +import FollowUpList from '@/views/crm/followup/index.vue' +import ContactList from '@/views/crm/contact/components/ContactList.vue' +import BusinessUpdateStatusForm from '@/views/crm/business/BusinessUpdateStatusForm.vue' +import ContractList from '@/views/crm/contract/components/ContractList.vue' +import BusinessProductList from '@/views/crm/business/detail/BusinessProductList.vue' + +defineOptions({ name: 'CrmBusinessDetail' }) + +const message = useMessage() + +const businessId = ref(0) // 线索编号 +const loading = ref(true) // 加载中 +const business = ref<BusinessApi.BusinessVO>({} as BusinessApi.BusinessVO) // 商机详情 +const permissionListRef = ref<InstanceType<typeof PermissionList>>() // 团队成员列表 Ref + +/** 获取详情 */ +const getBusiness = async () => { + loading.value = true + try { + business.value = await BusinessApi.getBusiness(businessId.value) + await getOperateLog(businessId.value) + } finally { + loading.value = false + } +} + +/** 编辑 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 变更商机状态 */ +const statusFormRef = ref() +const openStatusForm = () => { + statusFormRef.value.open(business.value) +} + +/** 联系人转移 */ +const transferFormRef = ref<InstanceType<typeof CrmTransferForm>>() // 联系人转移表单 ref +const transfer = () => { + transferFormRef.value?.open(business.value.id) +} + +/** 获取操作日志 */ +const logList = ref<OperateLogVO[]>([]) // 操作日志列表 +const getOperateLog = async (contactId: number) => { + if (!contactId) { + return + } + const data = await getOperateLogPage({ + bizType: BizTypeEnum.CRM_BUSINESS, + bizId: contactId + }) + logList.value = data.list +} + +/** 关闭窗口 */ +const { delView } = useTagsViewStore() // 视图操作 +const { currentRoute } = useRouter() // 路由 +const close = () => { + delView(unref(currentRoute)) +} + +/** 初始化 */ +const { params } = useRoute() +onMounted(async () => { + if (!params.id) { + message.warning('参数错误,商机不能为空!') + close() + return + } + businessId.value = params.id as unknown as number + await getBusiness() +}) +</script> diff --git a/src/views/crm/business/index.vue b/src/views/crm/business/index.vue new file mode 100644 index 0000000..84e447c --- /dev/null +++ b/src/views/crm/business/index.vue @@ -0,0 +1,275 @@ +<template> + <doc-alert title="【商机】商机管理、商机状态" url="https://doc.iocoder.cn/crm/business/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="商机名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入商机名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button v-hasPermi="['crm:business:create']" type="primary" @click="openForm('create')"> + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button + v-hasPermi="['crm:business:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-tabs v-model="activeName" @tab-click="handleTabClick"> + <el-tab-pane label="我负责的" name="1" /> + <el-tab-pane label="我参与的" name="2" /> + <el-tab-pane label="下属负责的" name="3" /> + </el-tabs> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" fixed="left" label="商机名称" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" fixed="left" label="客户名称" prop="customerName" width="120"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column + :formatter="erpPriceTableColumnFormatter" + align="center" + label="商机金额(元)" + prop="totalPrice" + width="140" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="预计成交日期" + prop="dealTime" + width="180px" + /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + <el-table-column + align="center" + fixed="right" + label="商机状态组" + prop="statusTypeName" + width="140" + /> + <el-table-column + align="center" + fixed="right" + label="商机阶段" + prop="statusName" + width="120" + /> + <el-table-column align="center" fixed="right" label="操作" width="130px"> + <template #default="scope"> + <el-button + v-hasPermi="['crm:business:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['crm:business:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <BusinessForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as BusinessApi from '@/api/crm/business' +import BusinessForm from './BusinessForm.vue' +import { erpPriceTableColumnFormatter } from '@/utils' +import { TabsPaneContext } from 'element-plus' + +defineOptions({ name: 'CrmBusiness' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + sceneType: '1', // 默认和 activeName 相等 + name: null +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const activeName = ref('1') // 列表 tab + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BusinessApi.getBusinessPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** tab 切换 */ +const handleTabClick = (tab: TabsPaneContext) => { + queryParams.sceneType = tab.paneName + handleQuery() +} + +/** 打开客户详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmBusinessDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await BusinessApi.deleteBusiness(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await BusinessApi.exportBusiness(queryParams) + download.excel(data, '商机.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/business/status/BusinessStatusForm.vue b/src/views/crm/business/status/BusinessStatusForm.vue new file mode 100644 index 0000000..d6a4d6f --- /dev/null +++ b/src/views/crm/business/status/BusinessStatusForm.vue @@ -0,0 +1,194 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="状态组名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入状态组名" /> + </el-form-item> + <el-form-item label="应用部门" prop="deptIds"> + <template #label> + <Tooltip message="不选择部门时,默认全公司生效" title="应用部门" /> + </template> + <el-tree + ref="treeRef" + :data="deptList" + :props="defaultProps" + :check-strictly="!checkStrictly" + node-key="id" + placeholder="请选择归属部门" + show-checkbox + /> + </el-form-item> + <el-form-item label="阶段设置" prop="statuses"> + <el-table + border + style="width: 100%" + :data="formData.statuses.concat(BusinessStatusApi.DEFAULT_STATUSES)" + > + <el-table-column align="center" label="阶段" width="70"> + <template #default="scope"> + <el-text v-if="!scope.row.defaultStatus">阶段 {{ scope.$index + 1 }}</el-text> + <el-text v-else>结束</el-text> + </template> + </el-table-column> + <el-table-column align="center" label="阶段名称" width="160" prop="name"> + <template #default="{ row }"> + <el-input v-if="!row.endStatus" v-model="row.name" placeholder="请输入状态名称" /> + <el-text v-else>{{ row.name }}</el-text> + </template> + </el-table-column> + <el-table-column width="140" align="center" label="赢单率(%)" prop="percent"> + <template #default="{ row }"> + <el-input-number + v-if="!row.endStatus" + v-model="row.percent" + placeholder="请输入赢单率" + controls-position="right" + :min="0" + :max="100" + :precision="2" + class="!w-1/1" + /> + <el-text v-else>{{ row.percent }}</el-text> + </template> + </el-table-column> + <el-table-column label="操作" width="110" align="center"> + <template #default="scope"> + <el-button + v-if="!scope.row.endStatus" + link + type="primary" + @click="addStatus(scope.$index)" + > + 添加 + </el-button> + <el-button + v-if="!scope.row.endStatus" + link + type="danger" + @click="deleteStatusArea(scope.$index)" + :disabled="formData.statuses.length <= 1" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as BusinessStatusApi from '@/api/crm/business/status' +import { defaultProps, handleTree } from '@/utils/tree' +import * as DeptApi from '@/api/system/dept' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的组:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + deptIds: [], + statuses: [] +}) +const formRules = reactive({ + name: [{ required: true, message: '状态组名不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const deptList = ref<Tree[]>([]) // 树形结构 +const treeRef = ref() // 菜单树组件 Ref +const checkStrictly = ref(true) // 是否严格模式,即父子不关联 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await BusinessStatusApi.getBusinessStatus(id) + treeRef.value.setCheckedKeys(formData.value.deptIds) + if (formData.value.statuses.length == 0) { + addStatus() + } + } finally { + formLoading.value = false + } + } else { + addStatus() + } + // 加载部门树 + deptList.value = handleTree(await DeptApi.getSimpleDeptList()) +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as BusinessStatusApi.BusinessStatusTypeVO + data.deptIds = treeRef.value.getCheckedKeys(false) + if (formType.value === 'create') { + await BusinessStatusApi.createBusinessStatus(data) + message.success(t('common.createSuccess')) + } else { + await BusinessStatusApi.updateBusinessStatus(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + checkStrictly.value = true + formData.value = { + id: undefined, + name: '', + deptIds: [], + statuses: [] + } + treeRef.value?.setCheckedNodes([]) + formRef.value?.resetFields() +} + +/** 添加状态 */ +const addStatus = () => { + const data = formData.value + data.statuses.push({ + name: '', + percent: undefined + }) +} + +/** 删除状态 */ +const deleteStatusArea = (index: number) => { + const data = formData.value + data.statuses.splice(index, 1) +} +</script> diff --git a/src/views/crm/business/status/index.vue b/src/views/crm/business/status/index.vue new file mode 100644 index 0000000..ef51488 --- /dev/null +++ b/src/views/crm/business/status/index.vue @@ -0,0 +1,150 @@ +<template> + <doc-alert title="【商机】商机管理、商机状态" url="https://doc.iocoder.cn/crm/business/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['crm:business-status:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="状态组名" align="center" prop="name" /> + <el-table-column label="应用部门" align="center" prop="deptNames"> + <template #default="scope"> + <span v-if="scope.row?.deptNames?.length > 0"> + {{ scope.row.deptNames.join(' ') }} + </span> + <span v-else>全公司</span> + </template> + </el-table-column> + <el-table-column label="创建人" align="center" prop="creator" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['crm:business-status:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['crm:business-status:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <BusinessStatusForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as BusinessStatusApi from '@/api/crm/business/status' +import BusinessStatusForm from './BusinessStatusForm.vue' +import { deleteBusinessStatus } from '@/api/crm/business/status' + +defineOptions({ name: 'CrmBusinessStatus' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10 +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BusinessStatusApi.getBusinessStatusPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await BusinessStatusApi.deleteBusinessStatus(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/clue/ClueForm.vue b/src/views/crm/clue/ClueForm.vue new file mode 100644 index 0000000..82a1320 --- /dev/null +++ b/src/views/crm/clue/ClueForm.vue @@ -0,0 +1,259 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-row> + <el-col :span="12"> + <el-form-item label="线索名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入线索名称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="客户来源" prop="source"> + <el-select v-model="formData.source" placeholder="请选择客户来源" class="w-1/1"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="手机" prop="mobile"> + <el-input v-model="formData.mobile" placeholder="请输入手机" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="负责人" prop="ownerUserId"> + <el-select + v-model="formData.ownerUserId" + :disabled="formType !== 'create'" + class="w-1/1" + > + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="电话" prop="telephone"> + <el-input v-model="formData.telephone" placeholder="请输入电话" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="邮箱" prop="email"> + <el-input v-model="formData.email" placeholder="请输入邮箱" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="微信" prop="wechat"> + <el-input v-model="formData.wechat" placeholder="请输入微信" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="QQ" prop="qq"> + <el-input v-model="formData.qq" placeholder="请输入 QQ" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="客户行业" prop="industryId"> + <el-select v-model="formData.industryId" placeholder="请选择客户行业" class="w-1/1"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="客户级别" prop="level"> + <el-select v-model="formData.level" placeholder="请选择客户级别" class="w-1/1"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="地址" prop="areaId"> + <el-cascader + v-model="formData.areaId" + :options="areaList" + :props="defaultProps" + class="w-1/1" + clearable + filterable + placeholder="请选择城市" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="详细地址" prop="detailAddress"> + <el-input v-model="formData.detailAddress" placeholder="请输入详细地址" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="下次联系时间" prop="contactNextTime"> + <el-date-picker + v-model="formData.contactNextTime" + placeholder="选择下次联系时间" + type="datetime" + value-format="x" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="备注" prop="remark"> + <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as ClueApi from '@/api/crm/clue' +import * as AreaApi from '@/api/system/area' +import { defaultProps } from '@/utils/tree' +import * as UserApi from '@/api/system/user' +import { useUserStore } from '@/store/modules/user' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const areaList = ref([]) // 地区列表 +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const formData = ref({ + id: undefined, + name: undefined, + contactNextTime: undefined, + ownerUserId: 0, + mobile: undefined, + telephone: undefined, + qq: undefined, + wechat: undefined, + email: undefined, + areaId: undefined, + detailAddress: undefined, + industryId: undefined, + level: undefined, + source: undefined, + remark: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '线索名称不能为空', trigger: 'blur' }], + ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ClueApi.getClue(id) + } finally { + formLoading.value = false + } + } + // 获得地区列表 + areaList.value = await AreaApi.getAreaTree() + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() + // 默认新建时选中自己 + if (formType.value === 'create') { + formData.value.ownerUserId = useUserStore().getUser.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ClueApi.ClueVO + if (formType.value === 'create') { + await ClueApi.createClue(data) + message.success(t('common.createSuccess')) + } else { + await ClueApi.updateClue(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + contactNextTime: undefined, + ownerUserId: 0, + mobile: undefined, + telephone: undefined, + qq: undefined, + wechat: undefined, + email: undefined, + areaId: undefined, + detailAddress: undefined, + industryId: undefined, + level: undefined, + source: undefined, + remark: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/crm/clue/detail/ClueDetailsHeader.vue b/src/views/crm/clue/detail/ClueDetailsHeader.vue new file mode 100644 index 0000000..41552c7 --- /dev/null +++ b/src/views/crm/clue/detail/ClueDetailsHeader.vue @@ -0,0 +1,43 @@ +<template> + <div v-loading="loading"> + <div class="flex items-start justify-between"> + <div> + <!-- 左上:线索基本信息 --> + <el-col> + <el-row> + <span class="text-xl font-bold">{{ clue.name }}</span> + </el-row> + </el-col> + </div> + <div> + <!-- 右上:按钮 --> + <slot></slot> + </div> + </div> + </div> + <ContentWrap class="mt-10px"> + <el-descriptions :column="5" direction="vertical"> + <el-descriptions-item label="线索来源"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="clue.source" /> + </el-descriptions-item> + <el-descriptions-item label="手机"> {{ clue.mobile }} </el-descriptions-item> + <el-descriptions-item label="负责人"> + {{ clue.ownerUserName }} + </el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(clue.createTime) }} + </el-descriptions-item> + </el-descriptions> + </ContentWrap> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import * as ClueApi from '@/api/crm/clue' +import { formatDate } from '@/utils/formatTime' + +defineOptions({ name: 'CrmClueDetailsHeader' }) +defineProps<{ + clue: ClueApi.ClueVO // 线索信息 + loading: boolean // 加载中 +}>() +</script> diff --git a/src/views/crm/clue/detail/ClueDetailsInfo.vue b/src/views/crm/clue/detail/ClueDetailsInfo.vue new file mode 100644 index 0000000..5a1d01f --- /dev/null +++ b/src/views/crm/clue/detail/ClueDetailsInfo.vue @@ -0,0 +1,72 @@ +<template> + <ContentWrap> + <el-collapse v-model="activeNames" class=""> + <el-collapse-item name="basicInfo"> + <template #title> + <span class="text-base font-bold">基本信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="线索名称"> + {{ clue.name }} + </el-descriptions-item> + <el-descriptions-item label="客户来源"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="clue.source" /> + </el-descriptions-item> + <el-descriptions-item label="手机">{{ clue.mobile }}</el-descriptions-item> + <el-descriptions-item label="电话">{{ clue.telephone }}</el-descriptions-item> + <el-descriptions-item label="邮箱">{{ clue.email }}</el-descriptions-item> + <el-descriptions-item label="地址"> + {{ clue.areaName }} {{ clue.detailAddress }} + </el-descriptions-item> + <el-descriptions-item label="QQ">{{ clue.qq }}</el-descriptions-item> + <el-descriptions-item label="微信">{{ clue.wechat }}</el-descriptions-item> + <el-descriptions-item label="客户行业"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="clue.industryId" /> + </el-descriptions-item> + <el-descriptions-item label="客户级别"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="clue.level" /> + </el-descriptions-item> + <el-descriptions-item label="下次联系时间"> + {{ formatDate(clue.contactNextTime) }} + </el-descriptions-item> + <el-descriptions-item label="备注">{{ clue.remark }}</el-descriptions-item> + </el-descriptions> + </el-collapse-item> + <el-collapse-item name="systemInfo"> + <template #title> + <span class="text-base font-bold">系统信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="负责人">{{ clue.ownerUserName }}</el-descriptions-item> + <el-descriptions-item label="最后跟进记录"> + {{ clue.contactLastContent }} + </el-descriptions-item> + <el-descriptions-item label="最后跟进时间"> + {{ formatDate(clue.contactLastTime) }} + </el-descriptions-item> + <el-descriptions-item label=""> </el-descriptions-item> + <el-descriptions-item label="创建人">{{ clue.creatorName }}</el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(clue.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="更新时间"> + {{ formatDate(clue.updateTime) }} + </el-descriptions-item> + </el-descriptions> + </el-collapse-item> + </el-collapse> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ClueApi from '@/api/crm/clue' +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' + +defineOptions({ name: 'CrmClueDetailsInfo' }) +const { clue } = defineProps<{ + clue: ClueApi.ClueVO // 线索明细 +}>() + +const activeNames = ref(['basicInfo', 'systemInfo']) // 展示的折叠面板 +</script> +<style lang="scss" scoped></style> diff --git a/src/views/crm/clue/detail/index.vue b/src/views/crm/clue/detail/index.vue new file mode 100644 index 0000000..4c211e6 --- /dev/null +++ b/src/views/crm/clue/detail/index.vue @@ -0,0 +1,130 @@ +<template> + <ClueDetailsHeader :clue="clue" :loading="loading"> + <el-button + v-if="permissionListRef?.validateWrite" + v-hasPermi="['crm:clue:update']" + type="primary" + @click="openForm" + > + 编辑 + </el-button> + <el-button v-if="permissionListRef?.validateOwnerUser" type="primary" @click="transfer"> + 转移 + </el-button> + <el-button + v-if="permissionListRef?.validateOwnerUser && !clue.transformStatus" + type="success" + @click="handleTransform" + > + 转化为客户 + </el-button> + <el-button v-else disabled type="success">已转化客户</el-button> + </ClueDetailsHeader> + <el-col> + <el-tabs> + <el-tab-pane label="跟进记录"> + <FollowUpList :biz-id="clueId" :biz-type="BizTypeEnum.CRM_CLUE" /> + </el-tab-pane> + <el-tab-pane label="基本信息"> + <ClueDetailsInfo :clue="clue" /> + </el-tab-pane> + <el-tab-pane label="团队成员"> + <PermissionList + ref="permissionListRef" + :biz-id="clue.id!" + :biz-type="BizTypeEnum.CRM_CLUE" + :show-action="true" + @quit-team="close" + /> + </el-tab-pane> + <el-tab-pane label="操作日志"> + <OperateLogV2 :log-list="logList" /> + </el-tab-pane> + </el-tabs> + </el-col> + + <!-- 表单弹窗:添加/修改 --> + <ClueForm ref="formRef" @success="getClue" /> + <CrmTransferForm ref="transferFormRef" :biz-type="BizTypeEnum.CRM_CLUE" @success="close" /> +</template> +<script lang="ts" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import * as ClueApi from '@/api/crm/clue' +import ClueForm from '@/views/crm/clue/ClueForm.vue' +import ClueDetailsHeader from './ClueDetailsHeader.vue' // 线索明细 - 头部 +import ClueDetailsInfo from './ClueDetailsInfo.vue' // 线索明细 - 详细信息 +import PermissionList from '@/views/crm/permission/components/PermissionList.vue' // 团队成员列表(权限) +import CrmTransferForm from '@/views/crm/permission/components/TransferForm.vue' +import FollowUpList from '@/views/crm/followup/index.vue' +import { BizTypeEnum } from '@/api/crm/permission' +import type { OperateLogVO } from '@/api/system/operatelog' +import { getOperateLogPage } from '@/api/crm/operateLog' + +defineOptions({ name: 'CrmClueDetail' }) + +const clueId = ref(0) // 线索编号 +const loading = ref(true) // 加载中 +const message = useMessage() // 消息弹窗 +const { delView } = useTagsViewStore() // 视图操作 +const { currentRoute } = useRouter() // 路由 + +const permissionListRef = ref<InstanceType<typeof PermissionList>>() // 团队成员列表 Ref + +/** 获取详情 */ +const clue = ref<ClueApi.ClueVO>({} as ClueApi.ClueVO) // 线索详情 +const getClue = async () => { + loading.value = true + try { + clue.value = await ClueApi.getClue(clueId.value) + await getOperateLog() + } finally { + loading.value = false + } +} + +/** 编辑线索 */ +const formRef = ref<InstanceType<typeof ClueForm>>() // 线索表单 Ref +const openForm = () => { + formRef.value?.open('update', clueId.value) +} + +/** 线索转移 */ +const transferFormRef = ref<InstanceType<typeof CrmTransferForm>>() // 线索转移表单 ref +const transfer = () => { + transferFormRef.value?.open(clueId.value) +} + +/** 转化为客户 */ +const handleTransform = async () => { + await message.confirm(`确定将【${clue.value.name}】转化为客户吗?`) + await ClueApi.transformClue(clueId.value) + message.success(`转化客户【${clue.value.name}】成功`) + await getClue() +} + +/** 获取操作日志 */ +const logList = ref<OperateLogVO[]>([]) // 操作日志列表 +const getOperateLog = async () => { + const data = await getOperateLogPage({ + bizType: BizTypeEnum.CRM_CLUE, + bizId: clueId.value + }) + logList.value = data.list +} + +const close = () => { + delView(unref(currentRoute)) +} + +/** 初始化 */ +const { params } = useRoute() +onMounted(() => { + if (!params.id) { + message.warning('参数错误,线索不能为空!') + close() + return + } + clueId.value = params.id as unknown as number + getClue() +}) +</script> diff --git a/src/views/crm/clue/index.vue b/src/views/crm/clue/index.vue new file mode 100644 index 0000000..f90d497 --- /dev/null +++ b/src/views/crm/clue/index.vue @@ -0,0 +1,270 @@ +<template> + <doc-alert title="【线索】线索管理" url="https://doc.iocoder.cn/crm/clue/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="线索名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入线索名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="转化状态" prop="transformStatus"> + <el-select v-model="queryParams.transformStatus" class="!w-240px"> + <el-option :value="false" label="未转化" /> + <el-option :value="true" label="已转化" /> + </el-select> + </el-form-item> + <el-form-item label="手机号" prop="mobile"> + <el-input + v-model="queryParams.mobile" + placeholder="请输入手机号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="电话" prop="telephone"> + <el-input + v-model="queryParams.telephone" + placeholder="请输入电话" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button type="primary" @click="openForm('create')" v-hasPermi="['crm:clue:create']"> + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['crm:clue:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-tabs v-model="activeName" @tab-click="handleTabClick"> + <el-tab-pane label="我负责的" name="1" /> + <el-tab-pane label="我参与的" name="2" /> + <el-tab-pane label="下属负责的" name="3" /> + </el-tabs> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="线索名称" align="center" prop="name" fixed="left" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column label="线索来源" align="center" prop="source" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" /> + </template> + </el-table-column> + <el-table-column label="手机" align="center" prop="mobile" width="120" /> + <el-table-column label="电话" align="center" prop="telephone" width="130" /> + <el-table-column label="邮箱" align="center" prop="email" width="180" /> + <el-table-column label="地址" align="center" prop="detailAddress" width="180" /> + <el-table-column align="center" label="客户行业" prop="industryId" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户级别" prop="level" width="135"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column + label="最后跟进时间" + align="center" + prop="contactLastTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100" /> + <el-table-column + label="更新时间" + align="center" + prop="updateTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + <el-table-column label="操作" align="center" min-width="110" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['crm:clue:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['crm:clue:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ClueForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as ClueApi from '@/api/crm/clue' +import ClueForm from './ClueForm.vue' +import { TabsPaneContext } from 'element-plus' + +defineOptions({ name: 'CrmClue' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + sceneType: '1', // 默认和 activeName 相等 + name: null, + telephone: null, + mobile: null, + transformStatus: false +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const activeName = ref('1') // 列表 tab + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ClueApi.getCluePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** tab 切换 */ +const handleTabClick = (tab: TabsPaneContext) => { + queryParams.sceneType = tab.paneName + handleQuery() +} + +/** 打开线索详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmClueDetail', params: { id } }) +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ClueApi.deleteClue(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ClueApi.exportClue(queryParams) + download.excel(data, '线索.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/contact/ContactForm.vue b/src/views/crm/contact/ContactForm.vue new file mode 100644 index 0000000..ac749da --- /dev/null +++ b/src/views/crm/contact/ContactForm.vue @@ -0,0 +1,310 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-row> + <el-col :span="12"> + <el-form-item label="联系人姓名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入姓名" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="负责人" prop="ownerUserId"> + <el-select + v-model="formData.ownerUserId" + :disabled="formType !== 'create'" + class="w-1/1" + > + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="客户名称" prop="customerId"> + <el-select + :disabled="formData.customerDefault" + v-model="formData.customerId" + placeholder="请选择客户" + class="w-1/1" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="手机" prop="mobile"> + <el-input v-model="formData.mobile" placeholder="请输入手机" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="电话" prop="telephone"> + <el-input v-model="formData.telephone" placeholder="请输入电话" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="邮箱" prop="email"> + <el-input v-model="formData.email" placeholder="请输入邮箱" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="微信" prop="wechat"> + <el-input v-model="formData.wechat" placeholder="请输入微信" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="QQ" prop="qq"> + <el-input v-model="formData.qq" placeholder="请输入 QQ" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="职位" prop="post"> + <el-input v-model="formData.post" placeholder="请输入职位" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="关键决策人" prop="master" style="width: 400px"> + <el-radio-group v-model="formData.master"> + <el-radio + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="性别" prop="sex"> + <el-select v-model="formData.sex" placeholder="请选择" class="w-1/1"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="直属上级" prop="parentId"> + <el-select v-model="formData.parentId" placeholder="请选择直属上级" class="w-1/1"> + <el-option + v-for="item in contactList" + :key="item.id" + :disabled="item.id == formData.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="地址" prop="areaId"> + <el-cascader + v-model="formData.areaId" + :options="areaList" + :props="defaultProps" + class="w-1/1" + clearable + filterable + placeholder="请选择城市" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="详细地址" prop="detailAddress"> + <el-input v-model="formData.detailAddress" placeholder="请输入详细地址" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="下次联系时间" prop="contactNextTime"> + <el-date-picker + v-model="formData.contactNextTime" + placeholder="选择下次联系时间" + type="datetime" + value-format="x" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="备注" prop="remark"> + <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as ContactApi from '@/api/crm/contact' +import { DICT_TYPE, getBoolDictOptions, getIntDictOptions } from '@/utils/dict' +import * as UserApi from '@/api/system/user' +import * as CustomerApi from '@/api/crm/customer' +import * as AreaApi from '@/api/system/area' +import { defaultProps } from '@/utils/tree' +import { useUserStore } from '@/store/modules/user' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const areaList = ref([]) // 地区列表 +const formData = ref({ + id: undefined, + name: undefined, + customerId: undefined, + contactNextTime: undefined, + ownerUserId: 0, + mobile: undefined, + telephone: undefined, + qq: undefined, + wechat: undefined, + email: undefined, + areaId: undefined, + detailAddress: undefined, + sex: undefined, + master: false, + post: undefined, + parentId: undefined, + remark: undefined, + businessId: undefined, + customerDefault: false +}) +const formRules = reactive({ + name: [{ required: true, message: '姓名不能为空', trigger: 'blur' }], + customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }], + ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const customerList = ref<CustomerApi.CustomerVO[]>([]) // 客户列表 +const contactList = ref<ContactApi.ContactVO[]>([]) // 联系人列表 + +/** 打开弹窗 */ +const open = async (type: string, id?: number, customerId?: number, businessId?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ContactApi.getContact(id) + } finally { + formLoading.value = false + } + } else { + if (customerId) { + formData.value.customerId = customerId + formData.value.customerDefault = true // 默认客户的选择,不允许变 + } + // 自动关联 businessId 商机编号 + if (businessId) { + formData.value.businessId = businessId + } + } + // 获得联系人列表 + contactList.value = await ContactApi.getSimpleContactList() + // 获得客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() + // 获得地区列表 + areaList.value = await AreaApi.getAreaTree() + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() + // 默认新建时选中自己 + if (formType.value === 'create') { + formData.value.ownerUserId = useUserStore().getUser.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ContactApi.ContactVO + if (formType.value === 'create') { + await ContactApi.createContact(data) + message.success(t('common.createSuccess')) + } else { + await ContactApi.updateContact(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + customerId: undefined, + contactNextTime: undefined, + ownerUserId: 0, + mobile: undefined, + telephone: undefined, + qq: undefined, + wechat: undefined, + email: undefined, + areaId: undefined, + detailAddress: undefined, + sex: undefined, + master: false, + post: undefined, + parentId: undefined, + remark: undefined, + businessId: undefined, + customerDefault: false + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/crm/contact/components/ContactList.vue b/src/views/crm/contact/components/ContactList.vue new file mode 100644 index 0000000..1c12ca8 --- /dev/null +++ b/src/views/crm/contact/components/ContactList.vue @@ -0,0 +1,185 @@ +<template> + <!-- 操作栏 --> + <el-row justify="end"> + <el-button @click="openForm"> + <Icon class="mr-5px" icon="system-uicons:contacts" /> + 创建联系人 + </el-button> + <el-button + v-if="queryParams.businessId" + v-hasPermi="['crm:contact:create-business']" + @click="openBusinessModal" + > + <Icon class="mr-5px" icon="ep:circle-plus" /> + 关联 + </el-button> + <el-button + v-if="queryParams.businessId" + v-hasPermi="['crm:contact:delete-business']" + @click="deleteContactBusinessList" + > + <Icon class="mr-5px" icon="ep:remove" /> + 解除关联 + </el-button> + </el-row> + + <!-- 列表 --> + <ContentWrap class="mt-10px"> + <el-table + ref="contactRef" + v-loading="loading" + :data="list" + :show-overflow-tooltip="true" + :stripe="true" + > + <el-table-column v-if="queryParams.businessId" type="selection" width="55" /> + <el-table-column align="center" fixed="left" label="姓名" prop="name"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="手机号" prop="mobile" /> + <el-table-column align="center" label="职位" prop="post" /> + <el-table-column align="center" label="直属上级" prop="parentName" /> + <el-table-column align="center" label="是否关键决策人" min-width="100" prop="master"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.master" /> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加 --> + <ContactForm ref="formRef" @success="getList" /> + <!-- 关联商机选择弹框 --> + <ContactListModal + v-if="customerId" + ref="contactModalRef" + :customer-id="customerId" + @success="createContactBusinessList" + /> +</template> +<script lang="ts" setup> +import * as ContactApi from '@/api/crm/contact' +import ContactForm from './../ContactForm.vue' +import { DICT_TYPE } from '@/utils/dict' +import { BizTypeEnum } from '@/api/crm/permission' +import ContactListModal from './ContactListModal.vue' + +defineOptions({ name: 'CrmContactList' }) +const props = defineProps<{ + bizType: number // 业务类型 + bizId: number // 业务编号 + customerId?: number // 特殊:客户编号;在【商机】详情中,可以传递客户编号,默认新建的联系人关联到该客户 + businessId?: number // 特殊:商机编号;在【商机】详情中,可以传递商机编号,默认新建的联系人关联到该商机 +}>() + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + customerId: undefined as unknown, // 允许 undefined + number + businessId: undefined as unknown // 允许 undefined + number +}) +const message = useMessage() + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + // 置空参数 + queryParams.customerId = undefined + // 执行查询 + let data = { list: [], total: 0 } + switch (props.bizType) { + case BizTypeEnum.CRM_CUSTOMER: + queryParams.customerId = props.bizId + data = await ContactApi.getContactPageByCustomer(queryParams) + break + case BizTypeEnum.CRM_BUSINESS: + queryParams.businessId = props.bizId + data = await ContactApi.getContactPageByBusiness(queryParams) + break + default: + return + } + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 添加操作 */ +const formRef = ref() +const openForm = () => { + formRef.value.open('create', undefined, props.customerId, props.businessId) +} + +/** 打开联系人详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmContactDetail', params: { id } }) +} + +/** 打开联系人与商机的关联弹窗 */ +const contactModalRef = ref() +const openBusinessModal = () => { + contactModalRef.value.open() +} +const createContactBusinessList = async (contactIds: number[]) => { + const data = { + businessId: props.bizId, + contactIds: contactIds + } as ContactApi.ContactBusiness2ReqVO + contactRef.value.getSelectionRows().forEach((row: ContactApi.ContactVO) => { + data.contactIds.push(row.id) + }) + await ContactApi.createContactBusinessList2(data) + // 刷新列表 + message.success('关联联系人成功') + handleQuery() +} + +/** 解除联系人与商机的关联 */ +const contactRef = ref() +const deleteContactBusinessList = async () => { + const data = { + businessId: props.bizId, + contactIds: contactRef.value.getSelectionRows().map((row: ContactApi.ContactVO) => row.id) + } as ContactApi.ContactBusiness2ReqVO + if (data.contactIds.length === 0) { + return message.error('未选择联系人') + } + await ContactApi.deleteContactBusinessList2(data) + // 刷新列表 + message.success('取关联系人成功') + handleQuery() +} + +/** 监听打开的 bizId + bizType,从而加载最新的列表 */ +watch( + () => [props.bizId, props.bizType], + () => { + handleQuery() + }, + { immediate: true, deep: true } +) +</script> diff --git a/src/views/crm/contact/components/ContactListModal.vue b/src/views/crm/contact/components/ContactListModal.vue new file mode 100644 index 0000000..8b655c1 --- /dev/null +++ b/src/views/crm/contact/components/ContactListModal.vue @@ -0,0 +1,160 @@ +<template> + <Dialog v-model="dialogVisible" title="关联联系人"> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="90px" + > + <el-form-item label="联系人名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入联系人名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button v-hasPermi="['crm:business:create']" type="primary" @click="openForm()"> + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap class="mt-10px"> + <el-table + ref="contactRef" + v-loading="loading" + :data="list" + :show-overflow-tooltip="true" + :stripe="true" + > + <el-table-column type="selection" width="55" /> + <el-table-column align="center" fixed="left" label="姓名" prop="name"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="手机号" prop="mobile" /> + <el-table-column align="center" label="职位" prop="post" /> + <el-table-column align="center" label="直属上级" prop="parentName" /> + <el-table-column align="center" label="是否关键决策人" min-width="100" prop="master"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.master" /> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + + <!-- 表单弹窗:添加 --> + <ContactForm ref="formRef" @success="getList" /> + </Dialog> +</template> +<script lang="ts" setup> +import * as ContactApi from '@/api/crm/contact' +import ContactForm from '../ContactForm.vue' +import { DICT_TYPE } from '@/utils/dict' + +const message = useMessage() // 消息弹窗 +const props = defineProps<{ + customerId: number +}>() +defineOptions({ name: 'ContactListModal' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryFormRef = ref() // 搜索的表单 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + customerId: props.customerId +}) + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + queryParams.customerId = props.customerId // 解决 props.customerId 没更新到 queryParams 上的问题 + await getList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ContactApi.getContactPageByCustomer(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加操作 */ +const formRef = ref() +const openForm = () => { + formRef.value.open('create') +} + +/** 关联联系人提交 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const contactRef = ref() +const submitForm = async () => { + const contactIds = contactRef.value.getSelectionRows().map((row: ContactApi.ContactVO) => row.id) + if (contactIds.length === 0) { + return message.error('未选择联系人') + } + dialogVisible.value = false + emit('success', contactIds, contactRef.value.getSelectionRows()) +} + +/** 打开联系人详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmContactDetail', params: { id } }) +} +</script> diff --git a/src/views/crm/contact/detail/ContactDetailsHeader.vue b/src/views/crm/contact/detail/ContactDetailsHeader.vue new file mode 100644 index 0000000..12fb3bc --- /dev/null +++ b/src/views/crm/contact/detail/ContactDetailsHeader.vue @@ -0,0 +1,33 @@ +<template> + <div> + <div class="flex items-start justify-between"> + <div> + <el-col> + <el-row> + <span class="text-xl font-bold">{{ contact.name }}</span> + </el-row> + </el-col> + </div> + <div> + <!-- 右上:按钮 --> + <slot></slot> + </div> + </div> + </div> + <ContentWrap class="mt-10px"> + <el-descriptions :column="5" direction="vertical"> + <el-descriptions-item label="客户名称">{{ contact.customerName }}</el-descriptions-item> + <el-descriptions-item label="职务">{{ contact.post }}</el-descriptions-item> + <el-descriptions-item label="手机">{{ contact.mobile }}</el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(contact.createTime) }} + </el-descriptions-item> + </el-descriptions> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ContactApi from '@/api/crm/contact' +import { formatDate } from '@/utils/formatTime' + +const { contact } = defineProps<{ contact: ContactApi.ContactVO }>() +</script> diff --git a/src/views/crm/contact/detail/ContactDetailsInfo.vue b/src/views/crm/contact/detail/ContactDetailsInfo.vue new file mode 100644 index 0000000..9e8bfff --- /dev/null +++ b/src/views/crm/contact/detail/ContactDetailsInfo.vue @@ -0,0 +1,69 @@ +<template> + <ContentWrap> + <el-collapse v-model="activeNames"> + <el-collapse-item name="basicInfo"> + <template #title> + <span class="text-base font-bold">基本信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="姓名">{{ contact.name }}</el-descriptions-item> + <el-descriptions-item label="客户名称">{{ contact.customerName }}</el-descriptions-item> + <el-descriptions-item label="手机">{{ contact.mobile }}</el-descriptions-item> + <el-descriptions-item label="电话">{{ contact.telephone }}</el-descriptions-item> + <el-descriptions-item label="邮箱">{{ contact.email }}</el-descriptions-item> + <el-descriptions-item label="QQ">{{ contact.qq }}</el-descriptions-item> + <el-descriptions-item label="微信">{{ contact.wechat }}</el-descriptions-item> + <el-descriptions-item label="地址"> + {{ contact.areaName }} {{ contact.detailAddress }} + </el-descriptions-item> + <el-descriptions-item label="职务">{{ contact.post }}</el-descriptions-item> + <el-descriptions-item label="直属上级">{{ contact.parentName }}</el-descriptions-item> + <el-descriptions-item label="关键决策人"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="contact.master" /> + </el-descriptions-item> + <el-descriptions-item label="性别"> + <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="contact.sex" /> + </el-descriptions-item> + <el-descriptions-item label="下次联系时间"> + {{ formatDate(contact.contactNextTime) }} + </el-descriptions-item> + <el-descriptions-item label="备注">{{ contact.remark }}</el-descriptions-item> + </el-descriptions> + </el-collapse-item> + <el-collapse-item name="systemInfo"> + <template #title> + <span class="text-base font-bold">系统信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="负责人">{{ contact.ownerUserName }}</el-descriptions-item> + <el-descriptions-item label="最后跟进记录"> + {{ contact.contactLastContent }} + </el-descriptions-item> + <el-descriptions-item label="最后跟进时间"> + {{ formatDate(contact.contactLastTime) }} + </el-descriptions-item> + <el-descriptions-item label=""> </el-descriptions-item> + <el-descriptions-item label="创建人">{{ contact.creatorName }}</el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(contact.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="更新时间"> + {{ formatDate(contact.updateTime) }} + </el-descriptions-item> + </el-descriptions> + </el-collapse-item> + </el-collapse> + </ContentWrap> +</template> +<script setup lang="ts"> +import * as ContactApi from '@/api/crm/contact' +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' + +const { contact } = defineProps<{ + contact: ContactApi.ContactVO +}>() + +// 展示的折叠面板 +const activeNames = ref(['basicInfo', 'systemInfo']) +</script> diff --git a/src/views/crm/contact/detail/index.vue b/src/views/crm/contact/detail/index.vue new file mode 100644 index 0000000..7989d56 --- /dev/null +++ b/src/views/crm/contact/detail/index.vue @@ -0,0 +1,121 @@ +<template> + <ContactDetailsHeader v-loading="loading" :contact="contact"> + <el-button v-if="permissionListRef?.validateWrite" @click="openForm('update', contact.id)"> + 编辑 + </el-button> + <el-button v-if="permissionListRef?.validateOwnerUser" type="primary" @click="transfer"> + 转移 + </el-button> + </ContactDetailsHeader> + <el-col> + <el-tabs> + <el-tab-pane label="跟进记录"> + <FollowUpList :biz-id="contactId" :biz-type="BizTypeEnum.CRM_CONTACT" /> + </el-tab-pane> + <el-tab-pane label="详细资料"> + <ContactDetailsInfo :contact="contact" /> + </el-tab-pane> + <el-tab-pane label="操作日志"> + <OperateLogV2 :log-list="logList" /> + </el-tab-pane> + <el-tab-pane label="团队成员"> + <PermissionList + ref="permissionListRef" + :biz-id="contact.id!" + :biz-type="BizTypeEnum.CRM_CONTACT" + :show-action="true" + @quit-team="close" + /> + </el-tab-pane> + <el-tab-pane label="商机" lazy> + <BusinessList + :biz-id="contact.id!" + :biz-type="BizTypeEnum.CRM_CONTACT" + :contact-id="contact.id" + :customer-id="contact.customerId" + /> + </el-tab-pane> + </el-tabs> + </el-col> + <!-- 表单弹窗:添加/修改 --> + <ContactForm ref="formRef" @success="getContact" /> + <CrmTransferForm ref="transferFormRef" :biz-type="BizTypeEnum.CRM_CONTACT" @success="close" /> +</template> +<script lang="ts" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import * as ContactApi from '@/api/crm/contact' +import ContactDetailsHeader from '@/views/crm/contact/detail/ContactDetailsHeader.vue' +import ContactDetailsInfo from '@/views/crm/contact/detail/ContactDetailsInfo.vue' +import BusinessList from '@/views/crm/business/components/BusinessList.vue' // 商机列表 +import PermissionList from '@/views/crm/permission/components/PermissionList.vue' // 团队成员列表(权限) +import { BizTypeEnum } from '@/api/crm/permission' +import { OperateLogVO } from '@/api/system/operatelog' +import { getOperateLogPage } from '@/api/crm/operateLog' +import ContactForm from '@/views/crm/contact/ContactForm.vue' +import CrmTransferForm from '@/views/crm/permission/components/TransferForm.vue' +import FollowUpList from '@/views/crm/followup/index.vue' + +defineOptions({ name: 'CrmContactDetail' }) + +const message = useMessage() + +const contactId = ref(0) // 线索编号 +const loading = ref(true) // 加载中 +const contact = ref<ContactApi.ContactVO>({} as ContactApi.ContactVO) // 联系人详情 +const permissionListRef = ref<InstanceType<typeof PermissionList>>() // 团队成员列表 Ref + +/** 获取详情 */ +const getContact = async () => { + loading.value = true + try { + contact.value = await ContactApi.getContact(contactId.value) + await getOperateLog(contactId.value) + } finally { + loading.value = false + } +} + +/** 编辑 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 联系人转移 */ +const transferFormRef = ref<InstanceType<typeof CrmTransferForm>>() // 联系人转移表单 ref +const transfer = () => { + transferFormRef.value?.open(contact.value.id) +} + +/** 获取操作日志 */ +const logList = ref<OperateLogVO[]>([]) // 操作日志列表 +const getOperateLog = async (contactId: number) => { + if (!contactId) { + return + } + const data = await getOperateLogPage({ + bizType: BizTypeEnum.CRM_CONTACT, + bizId: contactId + }) + logList.value = data.list +} + +/** 关闭窗口 */ +const { delView } = useTagsViewStore() // 视图操作 +const { currentRoute } = useRouter() // 路由 +const close = () => { + delView(unref(currentRoute)) +} + +/** 初始化 */ +const { params } = useRoute() +onMounted(async () => { + if (!params.id) { + message.warning('参数错误,联系人不能为空!') + close() + return + } + contactId.value = params.id as unknown as number + await getContact() +}) +</script> diff --git a/src/views/crm/contact/index.vue b/src/views/crm/contact/index.vue new file mode 100644 index 0000000..ec26f1e --- /dev/null +++ b/src/views/crm/contact/index.vue @@ -0,0 +1,332 @@ +<template> + <doc-alert title="【客户】客户管理、公海客户" url="https://doc.iocoder.cn/crm/customer/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="queryParams.customerId" + class="!w-240px" + clearable + lable-key="name" + placeholder="请选择客户" + value-key="id" + @keyup.enter="handleQuery" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id!" + /> + </el-select> + </el-form-item> + <el-form-item label="姓名" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入姓名" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="手机号" prop="mobile"> + <el-input + v-model="queryParams.mobile" + class="!w-240px" + clearable + placeholder="请输入手机号" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="电话" prop="telephone"> + <el-input + v-model="queryParams.telephone" + class="!w-240px" + clearable + placeholder="请输入电话" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="微信" prop="wechat"> + <el-input + v-model="queryParams.wechat" + class="!w-240px" + clearable + placeholder="请输入微信" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="电子邮箱" prop="email"> + <el-input + v-model="queryParams.email" + class="!w-240px" + clearable + placeholder="请输入电子邮箱" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button v-hasPermi="['crm:contact:create']" type="primary" @click="openForm('create')"> + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button + v-hasPermi="['crm:contact:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-tabs v-model="activeName" @tab-click="handleTabClick"> + <el-tab-pane label="我负责的" name="1" /> + <el-tab-pane label="我参与的" name="2" /> + <el-tab-pane label="下属负责的" name="3" /> + </el-tabs> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" fixed="left" label="联系人姓名" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" fixed="left" label="客户名称" prop="customerName" width="120"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="手机" prop="mobile" width="120" /> + <el-table-column align="center" label="电话" prop="telephone" width="130" /> + <el-table-column align="center" label="邮箱" prop="email" width="180" /> + <el-table-column align="center" label="职位" prop="post" width="120" /> + <el-table-column align="center" label="地址" prop="detailAddress" width="120" /> + <el-table-column align="center" label="关键决策人" prop="master" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.master" /> + </template> + </el-table-column> + <el-table-column align="center" label="直属上级" prop="parentName" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.parentId)"> + {{ scope.row.parentName }} + </el-link> + </template> + </el-table-column> + <el-table-column label="地址" align="center" prop="detailAddress" width="180" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="性别" prop="sex"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" /> + </template> + </el-table-column> + <el-table-column align="center" label="备注" prop="remark" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="120" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="120" /> + <el-table-column align="center" fixed="right" label="操作" width="200"> + <template #default="scope"> + <el-button + v-hasPermi="['crm:contact:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['crm:contact:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ContactForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as ContactApi from '@/api/crm/contact' +import ContactForm from './ContactForm.vue' +import { DICT_TYPE } from '@/utils/dict' +import * as CustomerApi from '@/api/crm/customer' +import { TabsPaneContext } from 'element-plus' + +defineOptions({ name: 'CrmContact' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + sceneType: '1', // 默认和 activeName 相等 + mobile: undefined, + telephone: undefined, + email: undefined, + customerId: undefined, + name: undefined, + wechat: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const activeName = ref('1') // 列表 tab +const customerList = ref<CustomerApi.CustomerVO[]>([]) // 客户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ContactApi.getContactPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** tab 切换 */ +const handleTabClick = (tab: TabsPaneContext) => { + queryParams.sceneType = tab.paneName + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ContactApi.deleteContact(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ContactApi.exportContact(queryParams) + download.excel(data, '联系人.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 打开联系人详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmContactDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + customerList.value = await CustomerApi.getCustomerSimpleList() +}) +</script> diff --git a/src/views/crm/contract/ContractForm.vue b/src/views/crm/contract/ContractForm.vue new file mode 100644 index 0000000..9c5b2c6 --- /dev/null +++ b/src/views/crm/contract/ContractForm.vue @@ -0,0 +1,369 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="1280"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="120px" + > + <el-row> + <el-col :span="8"> + <el-form-item label="合同编号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="合同名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入合同名称" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="负责人" prop="ownerUserId"> + <el-select + v-model="formData.ownerUserId" + :disabled="formType !== 'create'" + class="w-1/1" + > + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="8"> + <el-form-item label="客户名称" prop="customerId"> + <el-select + v-model="formData.customerId" + placeholder="请选择客户" + class="w-1/1" + @change="handleCustomerChange" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="商机名称" prop="businessId"> + <el-select + @change="handleBusinessChange" + :disabled="!formData.customerId" + v-model="formData.businessId" + class="w-1/1" + > + <el-option + v-for="item in getBusinessOptions" + :key="item.id" + :label="item.name" + :value="item.id!" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="8"> + <el-form-item label="下单日期" prop="orderDate"> + <el-date-picker + v-model="formData.orderDate" + placeholder="选择下单日期" + type="date" + value-format="x" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="开始时间" prop="startTime"> + <el-date-picker + v-model="formData.startTime" + placeholder="选择开始时间" + type="date" + value-format="x" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="结束时间" prop="endTime"> + <el-date-picker + v-model="formData.endTime" + placeholder="选择结束时间" + type="date" + value-format="x" + class="!w-1/1" + /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="8"> + <el-form-item label="公司签约人" prop="signUserId"> + <el-select v-model="formData.signUserId" class="w-1/1"> + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id!" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="客户签约人" prop="signContactId"> + <el-select + v-model="formData.signContactId" + :disabled="!formData.customerId" + class="w-1/1" + > + <el-option + v-for="item in getContactOptions" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" /> + </el-form-item> + </el-col> + </el-row> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="产品清单" name="product"> + <ContractProductForm + ref="productFormRef" + :products="formData.products" + :disabled="disabled" + /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <el-row> + <el-col :span="8"> + <el-form-item label="产品总金额" prop="totalProductPrice"> + <el-input + disabled + v-model="formData.totalProductPrice" + :formatter="erpPriceTableColumnFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="整单折扣(%)" prop="discountPercent"> + <el-input-number + v-model="formData.discountPercent" + placeholder="请输入整单折扣" + controls-position="right" + :min="0" + :precision="2" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="折扣后金额" prop="totalPrice"> + <el-input + disabled + v-model="formData.totalPrice" + placeholder="请输入商机金额" + :formatter="erpPriceTableColumnFormattere" + /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">保存</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as CustomerApi from '@/api/crm/customer' +import * as ContractApi from '@/api/crm/contract' +import * as UserApi from '@/api/system/user' +import * as ContactApi from '@/api/crm/contact' +import * as BusinessApi from '@/api/crm/business' +import { erpPriceMultiply, erpPriceTableColumnFormatter } from '@/utils' +import { useUserStore } from '@/store/modules/user' +import ContractProductForm from '@/views/crm/contract/components/ContractProductForm.vue' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + no: undefined, + name: undefined, + customerId: undefined, + businessId: undefined, + orderDate: undefined, + startTime: undefined, + endTime: undefined, + signUserId: undefined, + signContactId: undefined, + ownerUserId: undefined, + discountPercent: 0, + totalProductPrice: undefined, + remark: undefined, + products: [] +}) +const formRules = reactive({ + name: [{ required: true, message: '合同名称不能为空', trigger: 'blur' }], + customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }], + orderDate: [{ required: true, message: '下单日期不能为空', trigger: 'blur' }], + ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const customerList = ref([]) // 客户列表的数据 +const businessList = ref<BusinessApi.BusinessVO[]>([]) +const contactList = ref<ContactApi.ContactVO[]>([]) + +/** 子表的表单 */ +const subTabsName = ref('product') +const productFormRef = ref() + +/** 计算 discountPrice、totalPrice 价格 */ +watch( + () => formData.value, + (val) => { + if (!val) { + return + } + const totalProductPrice = val.products.reduce((prev, curr) => prev + curr.totalPrice, 0) + const discountPrice = + val.discountPercent != null + ? erpPriceMultiply(totalProductPrice, val.discountPercent / 100.0) + : 0 + const totalPrice = totalProductPrice - discountPrice + // 赋值 + formData.value.totalProductPrice = totalProductPrice + formData.value.totalPrice = totalPrice + }, + { deep: true } +) + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ContractApi.getContract(id) + } finally { + formLoading.value = false + } + } + // 获得客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() + // 默认新建时选中自己 + if (formType.value === 'create') { + formData.value.ownerUserId = useUserStore().getUser.id + } + // 获取联系人 + contactList.value = await ContactApi.getSimpleContactList() + // 获得商机列表 + businessList.value = await BusinessApi.getSimpleBusinessList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + productFormRef.value.validate() + try { + const data = unref(formData.value) as unknown as ContractApi.ContractVO + if (formType.value === 'create') { + await ContractApi.createContract(data) + message.success(t('common.createSuccess')) + } else { + await ContractApi.updateContract(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + no: undefined, + name: undefined, + customerId: undefined, + businessId: undefined, + orderDate: undefined, + startTime: undefined, + endTime: undefined, + signUserId: undefined, + signContactId: undefined, + ownerUserId: undefined, + discountPercent: 0, + totalProductPrice: undefined, + remark: undefined, + products: [] + } + formRef.value?.resetFields() +} + +/** 处理切换客户 */ +const handleCustomerChange = () => { + formData.value.businessId = undefined + formData.value.signContactId = undefined + formData.value.products = [] +} + +/** 处理商机变化 */ +const handleBusinessChange = async (businessId: number) => { + const business = await BusinessApi.getBusiness(businessId) + business.products.forEach((item) => { + item.contractPrice = item.businessPrice + }) + formData.value.products = business.products +} + +/** 动态获取客户联系人 */ +const getContactOptions = computed(() => + contactList.value.filter((item) => item.customerId == formData.value.customerId) +) +/** 动态获取商机 */ +const getBusinessOptions = computed(() => + businessList.value.filter((item) => item.customerId == formData.value.customerId) +) +</script> diff --git a/src/views/crm/contract/components/ContractList.vue b/src/views/crm/contract/components/ContractList.vue new file mode 100644 index 0000000..f693c9a --- /dev/null +++ b/src/views/crm/contract/components/ContractList.vue @@ -0,0 +1,136 @@ +<template> + <!-- 操作栏 --> + <el-row justify="end"> + <el-button @click="openForm"> + <Icon class="mr-5px" icon="clarity:contract-line" /> + 创建合同 + </el-button> + </el-row> + + <!-- 列表 --> + <ContentWrap class="mt-10px"> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="合同名称" fixed="left" align="center" prop="name"> + <template #default="scope"> + <el-link type="primary" :underline="false" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column label="合同编号" align="center" prop="no" /> + <el-table-column label="客户名称" align="center" prop="customerName" /> + <el-table-column + label="合同金额(元)" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="开始时间" + align="center" + prop="startTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column + label="结束时间" + align="center" + prop="endTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column align="center" label="状态" prop="auditStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="scope.row.auditStatus" /> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加 --> + <ContractForm ref="formRef" @success="getList" /> +</template> +<script setup lang="ts"> +import * as ContractApi from '@/api/crm/contract' +import ContractForm from './../ContractForm.vue' +import { BizTypeEnum } from '@/api/crm/permission' +import { dateFormatter } from '@/utils/formatTime' +import { DICT_TYPE } from '@/utils/dict' +import { erpPriceTableColumnFormatter } from '@/utils' + +defineOptions({ name: 'CrmContractList' }) +const props = defineProps<{ + bizType: number // 业务类型 + bizId: number // 业务编号 +}>() + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + customerId: undefined as unknown // 允许 undefined + number +}) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + // 置空参数 + queryParams.customerId = undefined + // 执行查询 + let data = { list: [], total: 0 } + switch (props.bizType) { + case BizTypeEnum.CRM_CUSTOMER: + queryParams.customerId = props.bizId + data = await ContractApi.getContractPageByCustomer(queryParams) + break + case BizTypeEnum.CRM_BUSINESS: + queryParams.businessId = props.bizId + data = await ContractApi.getContractPageByBusiness(queryParams) + break + default: + return + } + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 添加 */ +const formRef = ref() +const openForm = () => { + formRef.value.open('create') +} + +/** 打开合同详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmContractDetail', params: { id } }) +} + +/** 监听打开的 bizId + bizType,从而加载最新的列表 */ +watch( + () => [props.bizId, props.bizType], + () => { + handleQuery() + }, + { immediate: true, deep: true } +) +</script> diff --git a/src/views/crm/contract/components/ContractProductForm.vue b/src/views/crm/contract/components/ContractProductForm.vue new file mode 100644 index 0000000..c33b996 --- /dev/null +++ b/src/views/crm/contract/components/ContractProductForm.vue @@ -0,0 +1,183 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!"> + <el-select + v-model="row.productId" + clearable + filterable + @change="onChangeProduct($event, row)" + placeholder="请选择产品" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productNo" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="row.productUnit" /> + </template> + </el-table-column> + <el-table-column label="价格(元)" min-width="120"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="售价(元)" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.contractPrice`" class="mb-0px!"> + <el-input-number + v-model="row.contractPrice" + controls-position="right" + :min="0.001" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="合计" prop="totalPrice" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3" v-if="!disabled"> + <el-button @click="handleAdd" round>+ 添加产品</el-button> + </el-row> +</template> +<script setup lang="ts"> +import * as ProductApi from '@/api/crm/product' +import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils' +import { DICT_TYPE } from '@/utils/dict' + +const props = defineProps<{ + products: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + contractPrice: [{ required: true, message: '合同价格不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const productList = ref<ProductApi.ProductVO[]>([]) // 产品列表 + +/** 初始化设置产品项 */ +watch( + () => props.products, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + if (item.contractPrice != null && item.count != null) { + item.totalPrice = erpPriceMultiply(item.contractPrice, item.count) + } else { + item.totalPrice = undefined + } + }) + }, + { deep: true } +) + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + productId: undefined, + productUnit: undefined, // 产品单位 + productNo: undefined, // 产品条码 + productPrice: undefined, // 产品价格 + contractPrice: undefined, + count: 1 + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} + +/** 处理产品变更 */ +const onChangeProduct = (productId, row) => { + const product = productList.value.find((item) => item.id === productId) + if (product) { + row.productUnit = product.unit + row.productNo = product.no + row.productPrice = product.price + row.contractPrice = product.price + } +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + productList.value = await ProductApi.getProductSimpleList() +}) +</script> diff --git a/src/views/crm/contract/config/index.vue b/src/views/crm/contract/config/index.vue new file mode 100644 index 0000000..be654f7 --- /dev/null +++ b/src/views/crm/contract/config/index.vue @@ -0,0 +1,103 @@ +<template> + <doc-alert title="【合同】合同管理、合同提醒" url="https://doc.iocoder.cn/crm/contract/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="160px" + v-loading="formLoading" + > + <el-card shadow="never"> + <!-- 操作 --> + <template #header> + <div class="flex items-center justify-between"> + <CardTitle title="合同配置设置" /> + <el-button type="primary" @click="onSubmit" v-hasPermi="['crm:contract-config:update']"> + 保存 + </el-button> + </div> + </template> + <!-- 表单 --> + <el-form-item label="提前提醒设置" prop="notifyEnabled"> + <el-radio-group + v-model="formData.notifyEnabled" + @change="changeNotifyEnable" + class="ml-4" + > + <el-radio :label="false" size="large">不提醒</el-radio> + <el-radio :label="true" size="large">提醒</el-radio> + </el-radio-group> + </el-form-item> + <div v-if="formData.notifyEnabled"> + <el-form-item> + 提前 <el-input-number class="mx-2" v-model="formData.notifyDays" /> 天提醒 + </el-form-item> + </div> + </el-card> + </el-form> + </ContentWrap> +</template> +<script setup lang="ts"> +import * as ContractConfigApi from '@/api/crm/contract/config' +import { CardTitle } from '@/components/Card' + +defineOptions({ name: 'CrmContractConfig' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const formLoading = ref(false) +const formData = ref({ + notifyEnabled: false, + notifyDays: undefined +}) +const formRules = reactive({}) +const formRef = ref() // 表单 Ref + +/** 获取配置 */ +const getConfig = async () => { + try { + formLoading.value = true + const data = await ContractConfigApi.getContractConfig() + if (data === null) { + return + } + formData.value = data + } finally { + formLoading.value = false + } +} + +/** 提交配置 */ +const onSubmit = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as ContractConfigApi.ContractConfigVO + await ContractConfigApi.saveContractConfig(data) + message.success(t('common.updateSuccess')) + await getConfig() + formLoading.value = false + } finally { + formLoading.value = false + } +} + +/** 更改提前提醒设置 */ +const changeNotifyEnable = () => { + if (!formData.value.notifyEnabled) { + formData.value.notifyDays = undefined + } +} + +onMounted(() => { + getConfig() +}) +</script> diff --git a/src/views/crm/contract/detail/ContractDetailsHeader.vue b/src/views/crm/contract/detail/ContractDetailsHeader.vue new file mode 100644 index 0000000..9cfbfc7 --- /dev/null +++ b/src/views/crm/contract/detail/ContractDetailsHeader.vue @@ -0,0 +1,45 @@ +<!-- 合同详情头部组件--> +<template> + <div> + <div class="flex items-start justify-between"> + <div> + <el-col> + <el-row> + <span class="text-xl font-bold">{{ contract.name }}</span> + </el-row> + </el-col> + </div> + <div> + <!-- 右上:按钮 --> + <slot></slot> + </div> + </div> + </div> + <ContentWrap class="mt-10px"> + <el-descriptions :column="5" direction="vertical"> + <el-descriptions-item label="客户名称"> + {{ contract.customerName }} + </el-descriptions-item> + <el-descriptions-item label="合同金额(元)"> + {{ erpPriceInputFormatter(contract.totalPrice) }} + </el-descriptions-item> + <el-descriptions-item label="下单时间"> + {{ formatDate(contract.orderDate) }} + </el-descriptions-item> + <el-descriptions-item label="回款金额(元)"> + {{ erpPriceInputFormatter(contract.totalReceivablePrice) }} + </el-descriptions-item> + <el-descriptions-item label="负责人"> + {{ contract.ownerUserName }} + </el-descriptions-item> + </el-descriptions> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ContractApi from '@/api/crm/contract' +import { formatDate } from '@/utils/formatTime' +import { erpPriceInputFormatter } from '@/utils' + +defineOptions({ name: 'ContractDetailsHeader' }) +defineProps<{ contract: ContractApi.ContractVO }>() +</script> diff --git a/src/views/crm/contract/detail/ContractDetailsInfo.vue b/src/views/crm/contract/detail/ContractDetailsInfo.vue new file mode 100644 index 0000000..73aa144 --- /dev/null +++ b/src/views/crm/contract/detail/ContractDetailsInfo.vue @@ -0,0 +1,76 @@ +<!-- 合同详情组件 --> +<template> + <ContentWrap> + <el-collapse v-model="activeNames"> + <el-collapse-item name="contractInfo"> + <template #title> + <span class="text-base font-bold">基本信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="合同编号">{{ contract.no }}</el-descriptions-item> + <el-descriptions-item label="合同名称">{{ contract.name }}</el-descriptions-item> + <el-descriptions-item label="客户名称">{{ contract.customerName }}</el-descriptions-item> + <el-descriptions-item label="商机名称">{{ contract.businessName }}</el-descriptions-item> + <el-descriptions-item label="合同金额(元)"> + {{ erpPriceInputFormatter(contract.totalPrice) }} + </el-descriptions-item> + <el-descriptions-item label="下单时间"> + {{ formatDate(contract.orderDate) }} + </el-descriptions-item> + <el-descriptions-item label="合同开始时间"> + {{ formatDate(contract.startTime) }} + </el-descriptions-item> + <el-descriptions-item label="合同结束时间"> + {{ formatDate(contract.endTime) }} + </el-descriptions-item> + <el-descriptions-item label="客户签约人"> + {{ contract.signContactName }} + </el-descriptions-item> + <el-descriptions-item label="公司签约人"> + {{ contract.signUserName }} + </el-descriptions-item> + <el-descriptions-item label="备注"> + {{ contract.remark }} + </el-descriptions-item> + <el-descriptions-item label="合同状态"> + <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="contract.auditStatus" /> + </el-descriptions-item> + </el-descriptions> + </el-collapse-item> + <el-collapse-item name="systemInfo"> + <template #title> + <span class="text-base font-bold">系统信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="负责人">{{ contract.ownerUserName }}</el-descriptions-item> + <el-descriptions-item label="最后跟进时间"> + {{ formatDate(contract.contactLastTime) }} + </el-descriptions-item> + <el-descriptions-item label=""> </el-descriptions-item> + <el-descriptions-item label=""> </el-descriptions-item> + <el-descriptions-item label="创建人">{{ contract.creatorName }}</el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(contract.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="更新时间"> + {{ formatDate(contract.updateTime) }} + </el-descriptions-item> + </el-descriptions> + </el-collapse-item> + </el-collapse> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ContractApi from '@/api/crm/contract' +import { formatDate } from '@/utils/formatTime' +import { DICT_TYPE } from '@/utils/dict' +import { erpPriceInputFormatter } from '@/utils' + +defineOptions({ name: 'ContractDetailsInfo' }) +defineProps<{ + contract: ContractApi.ContractVO +}>() + +// 展示的折叠面板 +const activeNames = ref(['contractInfo', 'systemInfo']) +</script> diff --git a/src/views/crm/contract/detail/ContractProductList.vue b/src/views/crm/contract/detail/ContractProductList.vue new file mode 100644 index 0000000..ea23d17 --- /dev/null +++ b/src/views/crm/contract/detail/ContractProductList.vue @@ -0,0 +1,66 @@ +<template> + <ContentWrap> + <el-table :data="contract.products" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column + align="center" + label="产品名称" + fixed="left" + prop="productName" + min-width="160" + > + <template #default="scope"> + {{ scope.row.productName }} + </template> + </el-table-column> + <el-table-column label="产品条码" align="center" prop="productNo" min-width="120" /> + <el-table-column align="center" label="产品单位" prop="productUnit" min-width="160"> + <template #default="{ row }"> + <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="row.productUnit" /> + </template> + </el-table-column> + <el-table-column + label="产品价格(元)" + align="center" + prop="productPrice" + min-width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="合同价格(元)" + align="center" + prop="contractPrice" + min-width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + align="center" + label="数量" + prop="count" + min-width="100px" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="合计金额(元)" + align="center" + prop="totalPrice" + min-width="140" + :formatter="erpPriceTableColumnFormatter" + /> + </el-table> + <el-row class="mt-10px" justify="end"> + <el-col :span="3"> 整单折扣:{{ erpPriceInputFormatter(contract.discountPercent) }}% </el-col> + <el-col :span="4"> + 产品总金额:{{ erpPriceInputFormatter(contract.totalProductPrice) }} 元 + </el-col> + </el-row> + </ContentWrap> +</template> +<script setup lang="ts"> +import * as ContractApi from '@/api/crm/contract' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { DICT_TYPE } from '@/utils/dict' + +const { contract } = defineProps<{ + contract: ContractApi.ContractVO +}>() +</script> diff --git a/src/views/crm/contract/detail/index.vue b/src/views/crm/contract/detail/index.vue new file mode 100644 index 0000000..1369a35 --- /dev/null +++ b/src/views/crm/contract/detail/index.vue @@ -0,0 +1,139 @@ +<!-- 合同详情页面组件--> +<template> + <ContractDetailsHeader v-loading="loading" :contract="contract"> + <el-button v-if="permissionListRef?.validateWrite" @click="openForm('update', contract.id)"> + 编辑 + </el-button> + <el-button v-if="permissionListRef?.validateOwnerUser" type="primary" @click="transferContract"> + 转移 + </el-button> + </ContractDetailsHeader> + <el-col> + <el-tabs> + <el-tab-pane label="跟进记录"> + <FollowUpList :biz-id="contract.id" :biz-type="BizTypeEnum.CRM_CONTRACT" /> + </el-tab-pane> + <el-tab-pane label="基本信息"> + <ContractDetailsInfo :contract="contract" /> + </el-tab-pane> + <el-tab-pane label="产品"> + <ContractProductList :contract="contract" /> + </el-tab-pane> + <el-tab-pane label="回款"> + <ReceivablePlanList + :contract-id="contract.id!" + :customer-id="contract.customerId" + @create-receivable="createReceivable" + /> + <ReceivableList + ref="receivableListRef" + :contract-id="contract.id!" + :customer-id="contract.customerId" + /> + </el-tab-pane> + <el-tab-pane label="团队成员"> + <PermissionList + ref="permissionListRef" + :biz-id="contract.id!" + :biz-type="BizTypeEnum.CRM_CONTRACT" + :show-action="!permissionListRef?.isPool || false" + @quit-team="close" + /> + </el-tab-pane> + <el-tab-pane label="操作日志"> + <OperateLogV2 :log-list="logList" /> + </el-tab-pane> + </el-tabs> + </el-col> + + <!-- 表单弹窗:添加/修改 --> + <ContractForm ref="formRef" @success="getContractData" /> + <CrmTransferForm ref="transferFormRef" :biz-type="BizTypeEnum.CRM_CONTRACT" @success="close" /> +</template> +<script lang="ts" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import { OperateLogVO } from '@/api/system/operatelog' +import * as ContractApi from '@/api/crm/contract' +import ContractDetailsInfo from './ContractDetailsInfo.vue' +import ContractDetailsHeader from './ContractDetailsHeader.vue' +import ContractProductList from './ContractProductList.vue' +import { BizTypeEnum } from '@/api/crm/permission' +import { getOperateLogPage } from '@/api/crm/operateLog' +import ContractForm from '@/views/crm/contract/ContractForm.vue' +import CrmTransferForm from '@/views/crm/permission/components/TransferForm.vue' +import PermissionList from '@/views/crm/permission/components/PermissionList.vue' +import FollowUpList from '@/views/crm/followup/index.vue' +import ReceivableList from '@/views/crm/receivable/components/ReceivableList.vue' +import ReceivablePlanList from '@/views/crm/receivable/plan/components/ReceivablePlanList.vue' + +defineOptions({ name: 'CrmContractDetail' }) +const props = defineProps<{ id?: number }>() + +const route = useRoute() +const message = useMessage() +const contractId = ref(0) // 编号 +const loading = ref(true) // 加载中 +const contract = ref<ContractApi.ContractVO>({} as ContractApi.ContractVO) // 详情 +const permissionListRef = ref<InstanceType<typeof PermissionList>>() // 团队成员列表 Ref + +/** 编辑 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 获取详情 */ +const getContractData = async () => { + loading.value = true + try { + contract.value = await ContractApi.getContract(contractId.value) + await getOperateLog(contractId.value) + } finally { + loading.value = false + } +} + +/** 获取操作日志 */ +const logList = ref<OperateLogVO[]>([]) // 操作日志列表 +const getOperateLog = async (contractId: number) => { + if (!contractId) { + return + } + const data = await getOperateLogPage({ + bizType: BizTypeEnum.CRM_CONTRACT, + bizId: contractId + }) + logList.value = data.list +} + +/** 从回款计划创建回款 */ +const receivableListRef = ref<InstanceType<typeof ReceivableList>>() // 回款列表 Ref +const createReceivable = (planData: any) => { + receivableListRef.value?.createReceivable(planData) +} + +/** 转移 */ +const transferFormRef = ref<InstanceType<typeof CrmTransferForm>>() // 合同转移表单 ref +const transferContract = () => { + transferFormRef.value?.open(contract.value.id) +} + +/** 关闭 */ +const { delView } = useTagsViewStore() // 视图操作 +const { currentRoute } = useRouter() // 路由 +const close = () => { + delView(unref(currentRoute)) +} + +/** 初始化 */ +onMounted(async () => { + const id = props.id || route.params.id + if (!id) { + message.warning('参数错误,合同不能为空!') + close() + return + } + contractId.value = id as unknown as number + await getContractData() +}) +</script> diff --git a/src/views/crm/contract/index.vue b/src/views/crm/contract/index.vue new file mode 100644 index 0000000..0c9d728 --- /dev/null +++ b/src/views/crm/contract/index.vue @@ -0,0 +1,398 @@ +<template> + <doc-alert title="【合同】合同管理、合同提醒" url="https://doc.iocoder.cn/crm/contract/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="合同编号" prop="no"> + <el-input + v-model="queryParams.no" + class="!w-240px" + clearable + placeholder="请输入合同编号" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="合同名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入合同名称" + @keyup.enter="handleQuery" + /> + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="queryParams.customerId" + class="!w-240px" + clearable + lable-key="name" + placeholder="请选择客户" + value-key="id" + @keyup.enter="handleQuery" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id!" + /> + </el-select> + </el-form-item> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button v-hasPermi="['crm:contract:create']" type="primary" @click="openForm('create')"> + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button + v-hasPermi="['crm:contract:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-tabs v-model="activeName" @tab-click="handleTabClick"> + <el-tab-pane label="我负责的" name="1" /> + <el-tab-pane label="我参与的" name="2" /> + <el-tab-pane label="下属负责的" name="3" /> + </el-tabs> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" fixed="left" label="合同编号" prop="no" width="180" /> + <el-table-column align="center" fixed="left" label="合同名称" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="客户名称" prop="customerName" width="120"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="商机名称" prop="businessName" width="130"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openBusinessDetail(scope.row.businessId)" + > + {{ scope.row.businessName }} + </el-link> + </template> + </el-table-column> + <el-table-column + align="center" + label="合同金额(元)" + prop="totalPrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + align="center" + label="下单时间" + prop="orderDate" + width="120" + :formatter="dateFormatter2" + /> + <el-table-column + align="center" + label="合同开始时间" + prop="startTime" + width="120" + :formatter="dateFormatter2" + /> + <el-table-column + align="center" + label="合同结束时间" + prop="endTime" + width="120" + :formatter="dateFormatter2" + /> + <el-table-column align="center" label="客户签约人" prop="contactName" width="130"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openContactDetail(scope.row.signContactId)" + > + {{ scope.row.signContactName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="公司签约人" prop="signUserName" width="130" /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column + align="center" + label="已回款金额(元)" + prop="totalReceivablePrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + align="center" + label="未回款金额(元)" + prop="totalReceivablePrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + > + <template #default="scope"> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.totalReceivablePrice) }} + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="120" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="120" /> + <el-table-column align="center" fixed="right" label="合同状态" prop="auditStatus" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="scope.row.auditStatus" /> + </template> + </el-table-column> + <el-table-column fixed="right" label="操作" width="250"> + <template #default="scope"> + <el-button + v-if="scope.row.auditStatus === 0" + v-hasPermi="['crm:contract:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-if="scope.row.auditStatus === 0" + v-hasPermi="['crm:contract:update']" + link + type="primary" + @click="handleSubmit(scope.row)" + > + 提交审核 + </el-button> + <el-button + v-else + link + v-hasPermi="['crm:contract:update']" + type="primary" + @click="handleProcessDetail(scope.row)" + > + 查看审批 + </el-button> + <el-button + v-hasPermi="['crm:contract:query']" + link + type="primary" + @click="openDetail(scope.row.id)" + > + 详情 + </el-button> + <el-button + v-hasPermi="['crm:contract:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ContractForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { dateFormatter, dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import * as ContractApi from '@/api/crm/contract' +import ContractForm from './ContractForm.vue' +import { DICT_TYPE } from '@/utils/dict' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' +import * as CustomerApi from '@/api/crm/customer' +import { TabsPaneContext } from 'element-plus' + +defineOptions({ name: 'CrmContract' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + sceneType: '1', // 默认和 activeName 相等 + name: null, + customerId: null, + orderDate: [], + no: null +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const activeName = ref('1') // 列表 tab +const customerList = ref<CustomerApi.CustomerVO[]>([]) // 客户列表 + +/** tab 切换 */ +const handleTabClick = (tab: TabsPaneContext) => { + queryParams.sceneType = tab.paneName + handleQuery() +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ContractApi.getContractPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ContractApi.deleteContract(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ContractApi.exportContract(queryParams) + download.excel(data, '合同.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 提交审核 **/ +const handleSubmit = async (row: ContractApi.ContractVO) => { + await message.confirm(`您确定提交【${row.name}】审核吗?`) + await ContractApi.submitContract(row.id) + message.success('提交审核成功!') + await getList() +} + +/** 查看审批 */ +const handleProcessDetail = (row: ContractApi.ContractVO) => { + push({ name: 'BpmProcessInstanceDetail', query: { id: row.processInstanceId } }) +} + +/** 打开合同详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmContractDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 打开联系人详情 */ +const openContactDetail = (id: number) => { + push({ name: 'CrmContactDetail', params: { id } }) +} + +/** 打开商机详情 */ +const openBusinessDetail = (id: number) => { + push({ name: 'CrmBusinessDetail', params: { id } }) +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + customerList.value = await CustomerApi.getCustomerSimpleList() +}) +</script> diff --git a/src/views/crm/customer/CustomerForm.vue b/src/views/crm/customer/CustomerForm.vue new file mode 100644 index 0000000..8286971 --- /dev/null +++ b/src/views/crm/customer/CustomerForm.vue @@ -0,0 +1,259 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-row> + <el-col :span="12"> + <el-form-item label="客户名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入客户名称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="客户来源" prop="source"> + <el-select v-model="formData.source" placeholder="请选择客户来源" class="w-1/1"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="手机" prop="mobile"> + <el-input v-model="formData.mobile" placeholder="请输入手机" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="负责人" prop="ownerUserId"> + <el-select + v-model="formData.ownerUserId" + :disabled="formType !== 'create'" + class="w-1/1" + > + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="电话" prop="telephone"> + <el-input v-model="formData.telephone" placeholder="请输入电话" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="邮箱" prop="email"> + <el-input v-model="formData.email" placeholder="请输入邮箱" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="微信" prop="wechat"> + <el-input v-model="formData.wechat" placeholder="请输入微信" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="QQ" prop="qq"> + <el-input v-model="formData.qq" placeholder="请输入 QQ" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="客户行业" prop="industryId"> + <el-select v-model="formData.industryId" placeholder="请选择客户行业" class="w-1/1"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="客户级别" prop="level"> + <el-select v-model="formData.level" placeholder="请选择客户级别" class="w-1/1"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="地址" prop="areaId"> + <el-cascader + v-model="formData.areaId" + :options="areaList" + :props="defaultProps" + class="w-1/1" + clearable + filterable + placeholder="请选择城市" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="详细地址" prop="detailAddress"> + <el-input v-model="formData.detailAddress" placeholder="请输入详细地址" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="下次联系时间" prop="contactNextTime"> + <el-date-picker + v-model="formData.contactNextTime" + placeholder="选择下次联系时间" + type="datetime" + value-format="x" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="备注" prop="remark"> + <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as CustomerApi from '@/api/crm/customer' +import * as AreaApi from '@/api/system/area' +import { defaultProps } from '@/utils/tree' +import * as UserApi from '@/api/system/user' +import { useUserStore } from '@/store/modules/user' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const areaList = ref([]) // 地区列表 +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const formData = ref({ + id: undefined, + name: undefined, + contactNextTime: undefined, + ownerUserId: 0, + mobile: undefined, + telephone: undefined, + qq: undefined, + wechat: undefined, + email: undefined, + areaId: undefined, + detailAddress: undefined, + industryId: undefined, + level: undefined, + source: undefined, + remark: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '客户名称不能为空', trigger: 'blur' }], + ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await CustomerApi.getCustomer(id) + } finally { + formLoading.value = false + } + } + // 获得地区列表 + areaList.value = await AreaApi.getAreaTree() + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() + // 默认新建时选中自己 + if (formType.value === 'create') { + formData.value.ownerUserId = useUserStore().getUser.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as CustomerApi.CustomerVO + if (formType.value === 'create') { + await CustomerApi.createCustomer(data) + message.success(t('common.createSuccess')) + } else { + await CustomerApi.updateCustomer(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + contactNextTime: undefined, + ownerUserId: 0, + mobile: undefined, + telephone: undefined, + qq: undefined, + wechat: undefined, + email: undefined, + areaId: undefined, + detailAddress: undefined, + industryId: undefined, + level: undefined, + source: undefined, + remark: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/crm/customer/CustomerImportForm.vue b/src/views/crm/customer/CustomerImportForm.vue new file mode 100644 index 0000000..17721a1 --- /dev/null +++ b/src/views/crm/customer/CustomerImportForm.vue @@ -0,0 +1,158 @@ +<!-- 客户导入窗口 --> +<template> + <Dialog v-model="dialogVisible" title="客户导入" width="400"> + <div class="flex items-center my-10px"> + <span class="mr-10px">负责人</span> + <el-select v-model="ownerUserId" class="!w-240px" clearable> + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </div> + <el-upload + ref="uploadRef" + v-model:file-list="fileList" + :auto-upload="false" + :disabled="formLoading" + :limit="1" + :on-exceed="handleExceed" + accept=".xlsx, .xls" + action="none" + drag + > + <Icon icon="ep:upload" /> + <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div> + <template #tip> + <div class="el-upload__tip text-center"> + <div class="el-upload__tip"> + <el-checkbox v-model="updateSupport" /> + 是否更新已经存在的客户数据(“客户名称”重复) + </div> + <span>仅允许导入 xls、xlsx 格式文件。</span> + <el-link + :underline="false" + style="font-size: 12px; vertical-align: baseline" + type="primary" + @click="importTemplate" + > + 下载模板 + </el-link> + </div> + </template> + </el-upload> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as CustomerApi from '@/api/crm/customer' +import download from '@/utils/download' +import type { UploadUserFile } from 'element-plus' +import * as UserApi from '@/api/system/user' +import { useUserStore } from '@/store/modules/user' + +defineOptions({ name: 'SystemUserImportForm' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const uploadRef = ref() +const fileList = ref<UploadUserFile[]>([]) // 文件列表 +const updateSupport = ref(false) // 是否更新已经存在的客户数据 +const ownerUserId = ref<undefined | number>() // 负责人编号 +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + await resetForm() + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() + ownerUserId.value = useUserStore().getUser.id +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const submitForm = async () => { + if (fileList.value.length == 0) { + message.error('请上传文件') + return + } + + formLoading.value = true + try { + const formData = new FormData() + formData.append('updateSupport', String(updateSupport.value)) + formData.append('file', fileList.value[0].raw as Blob) + formData.append('ownerUserId', String(ownerUserId.value)) + const res = await CustomerApi.handleImport(formData) + submitFormSuccess(res) + } catch { + submitFormError() + } finally { + formLoading.value = false + } +} + +/** 文件上传成功 */ +const emits = defineEmits(['success']) +const submitFormSuccess = (response: any) => { + if (response.code !== 0) { + message.error(response.msg) + formLoading.value = false + return + } + // 拼接提示语 + const data = response.data + let text = '上传成功数量:' + data.createCustomerNames.length + ';' + for (let customerName of data.createCustomerNames) { + text += '< ' + customerName + ' >' + } + text += '更新成功数量:' + data.updateCustomerNames.length + ';' + for (const customerName of data.updateCustomerNames) { + text += '< ' + customerName + ' >' + } + text += '更新失败数量:' + Object.keys(data.failureCustomerNames).length + ';' + for (const customerName in data.failureCustomerNames) { + text += '< ' + customerName + ': ' + data.failureCustomerNames[customerName] + ' >' + } + message.alert(text) + formLoading.value = false + dialogVisible.value = false + // 发送操作成功的事件 + emits('success') +} + +/** 上传错误提示 */ +const submitFormError = (): void => { + message.error('上传失败,请您重新上传!') + formLoading.value = false +} + +/** 重置表单 */ +const resetForm = async () => { + // 重置上传状态和文件 + fileList.value = [] + updateSupport.value = false + ownerUserId.value = undefined + await nextTick() + uploadRef.value?.clearFiles() +} + +/** 文件数超出提示 */ +const handleExceed = (): void => { + message.error('最多只能上传一个文件!') +} + +/** 下载模板操作 */ +const importTemplate = async () => { + const res = await CustomerApi.importCustomerTemplate() + download.excel(res, '客户导入模版.xls') +} +</script> diff --git a/src/views/crm/customer/detail/CustomerDetailsHeader.vue b/src/views/crm/customer/detail/CustomerDetailsHeader.vue new file mode 100644 index 0000000..514ec61 --- /dev/null +++ b/src/views/crm/customer/detail/CustomerDetailsHeader.vue @@ -0,0 +1,43 @@ +<template> + <div v-loading="loading"> + <div class="flex items-start justify-between"> + <div> + <!-- 左上:客户基本信息 --> + <el-col> + <el-row> + <span class="text-xl font-bold">{{ customer.name }}</span> + </el-row> + </el-col> + </div> + <div> + <!-- 右上:按钮 --> + <slot></slot> + </div> + </div> + </div> + <ContentWrap class="mt-10px"> + <el-descriptions :column="5" direction="vertical"> + <el-descriptions-item label="客户级别"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="customer.level" /> + </el-descriptions-item> + <el-descriptions-item label="成交状态"> + {{ customer.dealStatus ? '已成交' : '未成交' }} + </el-descriptions-item> + <el-descriptions-item label="负责人">{{ customer.ownerUserName }}</el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(customer.createTime) }} + </el-descriptions-item> + </el-descriptions> + </ContentWrap> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import * as CustomerApi from '@/api/crm/customer' +import { formatDate } from '@/utils/formatTime' + +defineOptions({ name: 'CrmCustomerDetailsHeader' }) +defineProps<{ + customer: CustomerApi.CustomerVO // 客户信息 + loading: boolean // 加载中 +}>() +</script> diff --git a/src/views/crm/customer/detail/CustomerDetailsInfo.vue b/src/views/crm/customer/detail/CustomerDetailsInfo.vue new file mode 100644 index 0000000..d9ea62a --- /dev/null +++ b/src/views/crm/customer/detail/CustomerDetailsInfo.vue @@ -0,0 +1,72 @@ +<template> + <ContentWrap> + <el-collapse v-model="activeNames" class=""> + <el-collapse-item name="basicInfo"> + <template #title> + <span class="text-base font-bold">基本信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="客户名称"> + {{ customer.name }} + </el-descriptions-item> + <el-descriptions-item label="客户来源"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="customer.source" /> + </el-descriptions-item> + <el-descriptions-item label="手机">{{ customer.mobile }}</el-descriptions-item> + <el-descriptions-item label="电话">{{ customer.telephone }}</el-descriptions-item> + <el-descriptions-item label="邮箱">{{ customer.email }}</el-descriptions-item> + <el-descriptions-item label="地址"> + {{ customer.areaName }} {{ customer.detailAddress }} + </el-descriptions-item> + <el-descriptions-item label="QQ">{{ customer.qq }}</el-descriptions-item> + <el-descriptions-item label="微信">{{ customer.wechat }}</el-descriptions-item> + <el-descriptions-item label="客户行业"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="customer.industryId" /> + </el-descriptions-item> + <el-descriptions-item label="客户级别"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="customer.level" /> + </el-descriptions-item> + <el-descriptions-item label="下次联系时间"> + {{ formatDate(customer.contactNextTime) }} + </el-descriptions-item> + <el-descriptions-item label="备注">{{ customer.remark }}</el-descriptions-item> + </el-descriptions> + </el-collapse-item> + <el-collapse-item name="systemInfo"> + <template #title> + <span class="text-base font-bold">系统信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="负责人">{{ customer.ownerUserName }}</el-descriptions-item> + <el-descriptions-item label="最后跟进记录"> + {{ customer.contactLastContent }} + </el-descriptions-item> + <el-descriptions-item label="最后跟进时间"> + {{ formatDate(customer.contactLastTime) }} + </el-descriptions-item> + <el-descriptions-item label=""> </el-descriptions-item> + <el-descriptions-item label="创建人">{{ customer.creatorName }}</el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(customer.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="更新时间"> + {{ formatDate(customer.updateTime) }} + </el-descriptions-item> + </el-descriptions> + </el-collapse-item> + </el-collapse> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as CustomerApi from '@/api/crm/customer' +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' + +defineOptions({ name: 'CrmCustomerDetailsInfo' }) +const { customer } = defineProps<{ + customer: CustomerApi.CustomerVO // 客户明细 +}>() + +const activeNames = ref(['basicInfo', 'systemInfo']) // 展示的折叠面板 +</script> +<style lang="scss" scoped></style> diff --git a/src/views/crm/customer/detail/index.vue b/src/views/crm/customer/detail/index.vue new file mode 100644 index 0000000..6818f69 --- /dev/null +++ b/src/views/crm/customer/detail/index.vue @@ -0,0 +1,222 @@ +<template> + <CustomerDetailsHeader :customer="customer" :loading="loading"> + <el-button + v-if="permissionListRef?.validateWrite" + v-hasPermi="['crm:customer:update']" + type="primary" + @click="openForm" + > + 编辑 + </el-button> + <el-button v-if="permissionListRef?.validateOwnerUser" type="primary" @click="transfer"> + 转移 + </el-button> + <el-button v-if="permissionListRef?.validateWrite" @click="handleUpdateDealStatus"> + 更改成交状态 + </el-button> + <el-button + v-if="customer.lockStatus && permissionListRef?.validateOwnerUser" + @click="handleUnlock" + > + 解锁 + </el-button> + <el-button + v-if="!customer.lockStatus && permissionListRef?.validateOwnerUser" + @click="handleLock" + > + 锁定 + </el-button> + <el-button v-if="!customer.ownerUserId" type="primary" @click="handleReceive"> 领取</el-button> + <el-button v-if="!customer.ownerUserId" type="primary" @click="handleDistributeForm"> + 分配 + </el-button> + <el-button + v-if="customer.ownerUserId && permissionListRef?.validateOwnerUser" + @click="handlePutPool" + > + 放入公海 + </el-button> + </CustomerDetailsHeader> + <el-col> + <el-tabs> + <el-tab-pane label="跟进记录"> + <FollowUpList :biz-id="customerId" :biz-type="BizTypeEnum.CRM_CUSTOMER" /> + </el-tab-pane> + <el-tab-pane label="基本信息"> + <CustomerDetailsInfo :customer="customer" /> + </el-tab-pane> + <el-tab-pane label="联系人" lazy> + <ContactList :biz-id="customer.id!" :biz-type="BizTypeEnum.CRM_CUSTOMER" /> + </el-tab-pane> + <el-tab-pane label="团队成员"> + <PermissionList + ref="permissionListRef" + :biz-id="customer.id!" + :biz-type="BizTypeEnum.CRM_CUSTOMER" + :show-action="!permissionListRef?.isPool || false" + @quit-team="close" + /> + </el-tab-pane> + <el-tab-pane label="商机" lazy> + <BusinessList :biz-id="customer.id!" :biz-type="BizTypeEnum.CRM_CUSTOMER" /> + </el-tab-pane> + <el-tab-pane label="合同" lazy> + <ContractList :biz-id="customer.id!" :biz-type="BizTypeEnum.CRM_CUSTOMER" /> + </el-tab-pane> + <el-tab-pane label="回款" lazy> + <ReceivablePlanList :customer-id="customer.id!" @create-receivable="createReceivable" /> + <ReceivableList ref="receivableListRef" :customer-id="customer.id!" /> + </el-tab-pane> + <el-tab-pane label="操作日志"> + <OperateLogV2 :log-list="logList" /> + </el-tab-pane> + </el-tabs> + </el-col> + + <!-- 表单弹窗:添加/修改 --> + <CustomerForm ref="formRef" @success="getCustomer" /> + <CustomerDistributeForm ref="distributeForm" @success="getCustomer" /> + <CrmTransferForm ref="transferFormRef" :biz-type="BizTypeEnum.CRM_CUSTOMER" @success="close" /> +</template> +<script lang="ts" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import * as CustomerApi from '@/api/crm/customer' +import CustomerForm from '@/views/crm/customer/CustomerForm.vue' +import CustomerDetailsInfo from './CustomerDetailsInfo.vue' // 客户明细 - 详细信息 +import CustomerDetailsHeader from './CustomerDetailsHeader.vue' // 客户明细 - 头部 +import ContactList from '@/views/crm/contact/components/ContactList.vue' // 联系人列表 +import ContractList from '@/views/crm/contract/components/ContractList.vue' // 合同列表 +import BusinessList from '@/views/crm/business/components/BusinessList.vue' // 商机列表 +import ReceivableList from '@/views/crm/receivable/components/ReceivableList.vue' // 回款列表 +import ReceivablePlanList from '@/views/crm/receivable/plan/components/ReceivablePlanList.vue' // 回款计划列表 +import PermissionList from '@/views/crm/permission/components/PermissionList.vue' // 团队成员列表(权限) +import CrmTransferForm from '@/views/crm/permission/components/TransferForm.vue' +import FollowUpList from '@/views/crm/followup/index.vue' +import { BizTypeEnum } from '@/api/crm/permission' +import type { OperateLogVO } from '@/api/system/operatelog' +import { getOperateLogPage } from '@/api/crm/operateLog' +import CustomerDistributeForm from '@/views/crm/customer/pool/CustomerDistributeForm.vue' + +defineOptions({ name: 'CrmCustomerDetail' }) + +const customerId = ref(0) // 客户编号 +const loading = ref(true) // 加载中 +const message = useMessage() // 消息弹窗 +const { delView } = useTagsViewStore() // 视图操作 +const { push, currentRoute } = useRouter() // 路由 + +const permissionListRef = ref<InstanceType<typeof PermissionList>>() // 团队成员列表 Ref + +/** 获取详情 */ +const customer = ref<CustomerApi.CustomerVO>({} as CustomerApi.CustomerVO) // 客户详情 +const getCustomer = async () => { + loading.value = true + try { + customer.value = await CustomerApi.getCustomer(customerId.value) + await getOperateLog() + } finally { + loading.value = false + } +} + +/** 编辑客户 */ +const formRef = ref<InstanceType<typeof CustomerForm>>() // 客户表单 Ref +const openForm = () => { + formRef.value?.open('update', customerId.value) +} + +/** 更新成交状态操作 */ +const handleUpdateDealStatus = async () => { + const dealStatus = !customer.value.dealStatus + try { + // 更新状态的二次确认 + await message.confirm(`确定更新成交状态为【${dealStatus ? '已成交' : '未成交'}】吗?`) + // 发起更新 + await CustomerApi.updateCustomerDealStatus(customerId.value, dealStatus) + message.success(`更新成交状态成功`) + // 刷新数据 + await getCustomer() + } catch {} +} + +/** 客户转移 */ +const transferFormRef = ref<InstanceType<typeof CrmTransferForm>>() // 客户转移表单 ref +const transfer = () => { + transferFormRef.value?.open(customerId.value) +} + +/** 锁定客户 */ +const handleLock = async () => { + await message.confirm(`确定锁定客户【${customer.value.name}】 吗?`) + await CustomerApi.lockCustomer(unref(customerId.value), true) + message.success(`锁定客户【${customer.value.name}】成功`) + await getCustomer() +} + +/** 解锁客户 */ +const handleUnlock = async () => { + await message.confirm(`确定解锁客户【${customer.value.name}】 吗?`) + await CustomerApi.lockCustomer(unref(customerId.value), false) + message.success(`解锁客户【${customer.value.name}】成功`) + await getCustomer() +} + +/** 领取客户 */ +const handleReceive = async () => { + await message.confirm(`确定领取客户【${customer.value.name}】 吗?`) + await CustomerApi.receiveCustomer([unref(customerId.value)]) + message.success(`领取客户【${customer.value.name}】成功`) + await getCustomer() +} + +/** 分配客户 */ +const distributeForm = ref<InstanceType<typeof CustomerDistributeForm>>() // 分配客户表单 Ref +const handleDistributeForm = async () => { + distributeForm.value?.open(customerId.value) +} + +/** 客户放入公海 */ +const handlePutPool = async () => { + await message.confirm(`确定将客户【${customer.value.name}】放入公海吗?`) + await CustomerApi.putCustomerPool(unref(customerId.value)) + message.success(`客户【${customer.value.name}】放入公海成功`) + // 加载 + close() +} + +/** 获取操作日志 */ +const logList = ref<OperateLogVO[]>([]) // 操作日志列表 +const getOperateLog = async () => { + if (!customerId.value) { + return + } + const data = await getOperateLogPage({ + bizType: BizTypeEnum.CRM_CUSTOMER, + bizId: customerId.value + }) + logList.value = data.list +} + +/** 从回款计划创建回款 */ +const receivableListRef = ref<InstanceType<typeof ReceivableList>>() // 回款列表 Ref +const createReceivable = (planData: any) => { + receivableListRef.value?.createReceivable(planData) +} + +const close = () => { + delView(unref(currentRoute)) + push({ name: 'CrmCustomer' }) +} + +/** 初始化 */ +const { params } = useRoute() +onMounted(() => { + if (!params.id) { + message.warning('参数错误,客户不能为空!') + close() + return + } + customerId.value = params.id as unknown as number + getCustomer() +}) +</script> diff --git a/src/views/crm/customer/index.vue b/src/views/crm/customer/index.vue new file mode 100644 index 0000000..86bddc0 --- /dev/null +++ b/src/views/crm/customer/index.vue @@ -0,0 +1,343 @@ +<template> + <doc-alert title="【客户】客户管理、公海客户" url="https://doc.iocoder.cn/crm/customer/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="客户名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入客户名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="手机" prop="mobile"> + <el-input + v-model="queryParams.mobile" + class="!w-240px" + clearable + placeholder="请输入手机" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="所属行业" prop="industryId"> + <el-select + v-model="queryParams.industryId" + class="!w-240px" + clearable + placeholder="请选择所属行业" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="客户级别" prop="level"> + <el-select + v-model="queryParams.level" + class="!w-240px" + clearable + placeholder="请选择客户级别" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="客户来源" prop="source"> + <el-select + v-model="queryParams.source" + class="!w-240px" + clearable + placeholder="请选择客户来源" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button v-hasPermi="['crm:customer:create']" type="primary" @click="openForm('create')"> + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button v-hasPermi="['crm:customer:import']" plain type="warning" @click="handleImport"> + <Icon icon="ep:upload" /> + 导入 + </el-button> + <el-button + v-hasPermi="['crm:customer:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-tabs v-model="activeName" @tab-click="handleTabClick"> + <el-tab-pane label="我负责的" name="1" /> + <el-tab-pane label="我参与的" name="2" /> + <el-tab-pane label="下属负责的" name="3" /> + </el-tabs> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" fixed="left" label="客户名称" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="客户来源" prop="source" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" /> + </template> + </el-table-column> + <el-table-column align="center" label="手机" prop="mobile" width="120" /> + <el-table-column align="center" label="电话" prop="telephone" width="130" /> + <el-table-column align="center" label="邮箱" prop="email" width="180" /> + <el-table-column align="center" label="客户级别" prop="level" width="135"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户行业" prop="industryId" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column align="center" label="锁定状态" prop="lockStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.lockStatus" /> + </template> + </el-table-column> + <el-table-column align="center" label="成交状态" prop="dealStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealStatus" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" /> + <el-table-column align="center" label="地址" prop="detailAddress" width="180" /> + <el-table-column align="center" label="距离进入公海天数" prop="poolDay" width="140"> + <template #default="scope"> {{ scope.row.poolDay }} 天</template> + </el-table-column> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + <el-table-column align="center" fixed="right" label="操作" min-width="150"> + <template #default="scope"> + <el-button + v-hasPermi="['crm:customer:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['crm:customer:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <CustomerForm ref="formRef" @success="getList" /> + <CustomerImportForm ref="importFormRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as CustomerApi from '@/api/crm/customer' +import CustomerForm from './CustomerForm.vue' +import CustomerImportForm from './CustomerImportForm.vue' +import { TabsPaneContext } from 'element-plus' + +defineOptions({ name: 'CrmCustomer' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + sceneType: '1', // 默认和 activeName 相等 + name: '', + mobile: '', + industryId: undefined, + level: undefined, + source: undefined, + pool: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const activeName = ref('1') // 列表 tab + +/** tab 切换 */ +const handleTabClick = (tab: TabsPaneContext) => { + queryParams.sceneType = tab.paneName as string + handleQuery() +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CustomerApi.getCustomerPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 打开客户详情 */ +const { currentRoute, push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await CustomerApi.deleteCustomer(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导入按钮操作 */ +const importFormRef = ref<InstanceType<typeof CustomerImportForm>>() +const handleImport = () => { + importFormRef.value?.open() +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await CustomerApi.exportCustomer(queryParams) + download.excel(data, '客户.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 监听路由变化更新列表 */ +watch( + () => currentRoute.value, + () => { + getList() + } +) + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/customer/limitConfig/CustomerLimitConfigForm.vue b/src/views/crm/customer/limitConfig/CustomerLimitConfigForm.vue new file mode 100644 index 0000000..c7338a4 --- /dev/null +++ b/src/views/crm/customer/limitConfig/CustomerLimitConfigForm.vue @@ -0,0 +1,150 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="200px" + v-loading="formLoading" + > + <el-form-item label="规则适用人群" prop="userIds"> + <el-select multiple filterable v-model="formData.userIds"> + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="规则适用部门" prop="deptIds"> + <el-tree-select + v-model="formData.deptIds" + :data="deptTree" + :props="defaultProps" + multiple + filterable + check-strictly + node-key="id" + placeholder="请选择规则适用部门" + /> + </el-form-item> + <el-form-item + :label=" + formData.type === LimitConfType.CUSTOMER_QUANTITY_LIMIT + ? '拥有客户数上限' + : '锁定客户数上限' + " + prop="maxCount" + > + <el-input-number v-model="formData.maxCount" placeholder="请输入数量上限" /> + </el-form-item> + <el-form-item + label="成交客户是否占用拥有客户数" + v-if="formData.type === LimitConfType.CUSTOMER_QUANTITY_LIMIT" + prop="dealCountEnabled" + > + <el-switch v-model="formData.dealCountEnabled" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as CustomerLimitConfigApi from '@/api/crm/customer/limitConfig' +import * as DeptApi from '@/api/system/dept' +import { defaultProps, handleTree } from '@/utils/tree' +import * as UserApi from '@/api/system/user' +import { cloneDeep } from 'lodash-es' +import { LimitConfType } from '@/api/crm/customer/limitConfig' +import { aw } from '../../../../../dist-prod/assets/index-9eac537b' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + type: LimitConfType.CUSTOMER_LOCK_LIMIT, // 给个默认值,避免 IDE 报错 + userIds: undefined, + deptIds: undefined, + maxCount: undefined, + dealCountEnabled: false +}) +const formRules = reactive({ + type: [{ required: true, message: '规则类型不能为空', trigger: 'change' }], + maxCount: [{ required: true, message: '数量上限不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const deptTree = ref() // 部门树形结构 +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 打开弹窗 */ +const open = async (type: string, limitConfType: LimitConfType, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await CustomerLimitConfigApi.getCustomerLimitConfig(id) + } finally { + formLoading.value = false + } + } else { + formData.value.type = limitConfType + } + // 获得部门树 + deptTree.value = handleTree(await DeptApi.getSimpleDeptList()) + // 获得用户 + userOptions.value = await UserApi.getSimpleUserList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as CustomerLimitConfigApi.CustomerLimitConfigVO + if (formType.value === 'create') { + await CustomerLimitConfigApi.createCustomerLimitConfig(data) + message.success(t('common.createSuccess')) + } else { + await CustomerLimitConfigApi.updateCustomerLimitConfig(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + type: LimitConfType.CUSTOMER_LOCK_LIMIT, + userIds: undefined, + deptIds: undefined, + maxCount: undefined, + dealCountEnabled: false + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/crm/customer/limitConfig/CustomerLimitConfigList.vue b/src/views/crm/customer/limitConfig/CustomerLimitConfigList.vue new file mode 100644 index 0000000..f5c488c --- /dev/null +++ b/src/views/crm/customer/limitConfig/CustomerLimitConfigList.vue @@ -0,0 +1,150 @@ +<template> + <el-button plain @click="handleQuery"> <Icon icon="ep:refresh" class="mr-5px" /> 刷新 </el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['crm:customer-limit-config:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + class="mt-4" + > + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column + label="规则适用人群" + align="center" + :formatter="(row) => row.users?.map((user: any) => user.nickname).join(',')" + /> + <el-table-column + label="规则适用部门" + align="center" + :formatter="(row) => row.depts?.map((dept: any) => dept.name).join(',')" + /> + <el-table-column + :label=" + confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT ? '拥有客户数上限' : '锁定客户数上限' + " + align="center" + prop="maxCount" + /> + <el-table-column + v-if="confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT" + label="成交客户是否占用拥有客户数" + align="center" + prop="dealCountEnabled" + min-width="100" + > + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealCountEnabled" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" min-width="110" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['crm:customer-limit-config:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['crm:customer-limit-config:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + + <!-- 表单弹窗:添加/修改 --> + <CustomerLimitConfigForm ref="formRef" @success="getList" /> +</template> +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as CustomerLimitConfigApi from '@/api/crm/customer/limitConfig' +import CustomerLimitConfigForm from './CustomerLimitConfigForm.vue' +import { DICT_TYPE } from '@/utils/dict' +import { LimitConfType } from '@/api/crm/customer/limitConfig' + +defineOptions({ name: 'CustomerLimitConfigList' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const { confType } = defineProps<{ confType: LimitConfType }>() + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + type: confType +}) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CustomerLimitConfigApi.getCustomerLimitConfigPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, confType, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await CustomerLimitConfigApi.deleteCustomerLimitConfig(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/customer/limitConfig/index.vue b/src/views/crm/customer/limitConfig/index.vue new file mode 100644 index 0000000..01f3ef6 --- /dev/null +++ b/src/views/crm/customer/limitConfig/index.vue @@ -0,0 +1,22 @@ +<template> + <doc-alert title="【客户】客户管理、公海客户" url="https://doc.iocoder.cn/crm/customer/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <!-- 列表 --> + <ContentWrap> + <el-tabs> + <el-tab-pane label="拥有客户数限制"> + <CustomerLimitConfigList :confType="LimitConfType.CUSTOMER_QUANTITY_LIMIT" /> + </el-tab-pane> + <el-tab-pane label="锁定客户数限制"> + <CustomerLimitConfigList :confType="LimitConfType.CUSTOMER_LOCK_LIMIT" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> +</template> +<script setup lang="ts"> +import CustomerLimitConfigList from './CustomerLimitConfigList.vue' +import { LimitConfType } from '@/api/crm/customer/limitConfig' + +defineOptions({ name: 'CrmCustomerLimitConfig' }) +</script> diff --git a/src/views/crm/customer/pool/CustomerDistributeForm.vue b/src/views/crm/customer/pool/CustomerDistributeForm.vue new file mode 100644 index 0000000..5fd80a1 --- /dev/null +++ b/src/views/crm/customer/pool/CustomerDistributeForm.vue @@ -0,0 +1,85 @@ +<template> + <Dialog v-model="dialogVisible" title="分配客户"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item label="负责人" prop="ownerUserId"> + <el-select v-model="formData.ownerUserId" class="w-1/1"> + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as CustomerApi from '@/api/crm/customer' +import * as UserApi from '@/api/system/user' +import { distributeCustomer } from '@/api/crm/customer' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const formData = ref({ + id: undefined, + ownerUserId: undefined +}) +const formRules = reactive({ + ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + resetForm() + formData.value.id = id + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await CustomerApi.distributeCustomer([formData.value.id], formData.value.ownerUserId) + message.success('分配客户成功') + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + ownerUserId: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/crm/customer/pool/index.vue b/src/views/crm/customer/pool/index.vue new file mode 100644 index 0000000..eab90e0 --- /dev/null +++ b/src/views/crm/customer/pool/index.vue @@ -0,0 +1,270 @@ +<template> + <doc-alert title="【客户】客户管理、公海客户" url="https://doc.iocoder.cn/crm/customer/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="客户名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入客户名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="手机" prop="mobile"> + <el-input + v-model="queryParams.mobile" + class="!w-240px" + clearable + placeholder="请输入手机" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="所属行业" prop="industryId"> + <el-select + v-model="queryParams.industryId" + class="!w-240px" + clearable + placeholder="请选择所属行业" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="客户级别" prop="level"> + <el-select + v-model="queryParams.level" + class="!w-240px" + clearable + placeholder="请选择客户级别" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="客户来源" prop="source"> + <el-select + v-model="queryParams.source" + class="!w-240px" + clearable + placeholder="请选择客户来源" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery(undefined)"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['crm:customer:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="客户名称" fixed="left" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="客户来源" prop="source" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" /> + </template> + </el-table-column> + <el-table-column label="手机" align="center" prop="mobile" width="120" /> + <el-table-column label="电话" align="center" prop="telephone" width="130" /> + <el-table-column label="邮箱" align="center" prop="email" width="180" /> + <el-table-column align="center" label="客户级别" prop="level" width="135"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户行业" prop="industryId" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column align="center" label="成交状态" prop="dealStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealStatus" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column align="center" label="最后跟进记录" prop="contactLastContent" width="200" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as CustomerApi from '@/api/crm/customer' + +defineOptions({ name: 'CrmCustomerPool' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + name: '', + mobile: '', + industryId: undefined, + level: undefined, + source: undefined, + sceneType: undefined, + pool: true +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CustomerApi.getCustomerPage(queryParams.value) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.value.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + queryParams.value = { + pageNo: 1, + pageSize: 10, + name: '', + mobile: '', + industryId: undefined, + level: undefined, + source: undefined, + sceneType: undefined, + pool: true + } + handleQuery() +} + +/** 打开客户详情 */ +const { currentRoute, push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await CustomerApi.exportCustomer(queryParams.value) + download.excel(data, '客户公海.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 监听路由变化更新列表 */ +watch( + () => currentRoute.value, + () => { + getList() + } +) + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/customer/poolConfig/index.vue b/src/views/crm/customer/poolConfig/index.vue new file mode 100644 index 0000000..2880887 --- /dev/null +++ b/src/views/crm/customer/poolConfig/index.vue @@ -0,0 +1,136 @@ +<template> + <doc-alert title="【客户】客户管理、公海客户" url="https://doc.iocoder.cn/crm/customer/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="160px" + v-loading="formLoading" + > + <el-card shadow="never"> + <!-- 操作 --> + <template #header> + <div class="flex items-center justify-between"> + <CardTitle title="客户公海规则设置" /> + <el-button + type="primary" + @click="onSubmit" + v-hasPermi="['crm:customer-pool-config:update']" + > + 保存 + </el-button> + </div> + </template> + <!-- 表单 --> + <el-form-item label="客户公海规则设置" prop="enabled"> + <el-radio-group v-model="formData.enabled" @change="changeEnable" class="ml-4"> + <el-radio :label="false" size="large">不启用</el-radio> + <el-radio :label="true" size="large">启用</el-radio> + </el-radio-group> + </el-form-item> + <div v-if="formData.enabled"> + <el-form-item> + <el-input-number class="mr-2" v-model="formData.contactExpireDays" /> + 天不跟进或 + <el-input-number class="mx-2" v-model="formData.dealExpireDays" /> + 天未成交 + </el-form-item> + <el-form-item label="提前提醒设置" prop="notifyEnabled"> + <el-radio-group + v-model="formData.notifyEnabled" + @change="changeNotifyEnable" + class="ml-4" + > + <el-radio :label="false" size="large">不提醒</el-radio> + <el-radio :label="true" size="large">提醒</el-radio> + </el-radio-group> + </el-form-item> + <div v-if="formData.notifyEnabled"> + <el-form-item> + 提前 <el-input-number class="mx-2" v-model="formData.notifyDays" /> 天提醒 + </el-form-item> + </div> + </div> + </el-card> + </el-form> + </ContentWrap> +</template> +<script setup lang="ts"> +import * as CustomerPoolConfigApi from '@/api/crm/customer/poolConfig' +import { CardTitle } from '@/components/Card' + +defineOptions({ name: 'CrmCustomerPoolConfig' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const formLoading = ref(false) +const formData = ref({ + enabled: false, + contactExpireDays: undefined, + dealExpireDays: undefined, + notifyEnabled: false, + notifyDays: undefined +}) +const formRules = reactive({ + enabled: [{ required: true, message: '是否启用客户公海不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 获取配置 */ +const getConfig = async () => { + try { + formLoading.value = true + const data = await CustomerPoolConfigApi.getCustomerPoolConfig() + if (data === null) { + return + } + formData.value = data + } finally { + formLoading.value = false + } +} + +/** 提交配置 */ +const onSubmit = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as CustomerPoolConfigApi.CustomerPoolConfigVO + await CustomerPoolConfigApi.saveCustomerPoolConfig(data) + message.success(t('common.updateSuccess')) + await getConfig() + formLoading.value = false + } finally { + formLoading.value = false + } +} + +/** 更改客户公海规则设置 */ +const changeEnable = () => { + if (!formData.value.enabled) { + formData.value.contactExpireDays = undefined + formData.value.dealExpireDays = undefined + formData.value.notifyEnabled = false + formData.value.notifyDays = undefined + } +} + +/** 更改提前提醒设置 */ +const changeNotifyEnable = () => { + if (!formData.value.notifyEnabled) { + formData.value.notifyDays = undefined + } +} + +onMounted(() => { + getConfig() +}) +</script> diff --git a/src/views/crm/followup/FollowUpRecordForm.vue b/src/views/crm/followup/FollowUpRecordForm.vue new file mode 100644 index 0000000..eb626f0 --- /dev/null +++ b/src/views/crm/followup/FollowUpRecordForm.vue @@ -0,0 +1,188 @@ +<!-- 跟进记录的添加表单弹窗 --> +<template> + <Dialog v-model="dialogVisible" title="添加跟进记录" width="50%"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="120px" + > + <el-row> + <el-col :span="12"> + <el-form-item label="跟进类型" prop="type"> + <el-select v-model="formData.type" placeholder="请选择跟进类型"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_FOLLOW_UP_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="下次联系时间" prop="nextTime"> + <el-date-picker + v-model="formData.nextTime" + placeholder="选择下次联系时间" + type="date" + value-format="x" + /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="跟进内容" prop="content"> + <el-input v-model="formData.content" :rows="3" type="textarea" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="图片" prop="picUrls"> + <UploadImgs v-model="formData.picUrls" class="min-w-80px" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="附件" prop="fileUrls"> + <UploadFile v-model="formData.fileUrls" class="min-w-80px" /> + </el-form-item> + </el-col> + <el-col :span="24" v-if="formData.bizType == BizTypeEnum.CRM_CUSTOMER"> + <el-form-item label="关联联系人" prop="contactIds"> + <el-button @click="handleOpenContact"> + <Icon class="mr-5px" icon="ep:plus" /> + 添加联系人 + </el-button> + <FollowUpRecordContactForm :contacts="formData.contacts" /> + </el-form-item> + </el-col> + <el-col :span="24" v-if="formData.bizType == BizTypeEnum.CRM_CUSTOMER"> + <el-form-item label="关联商机" prop="businessIds"> + <el-button @click="handleOpenBusiness"> + <Icon class="mr-5px" icon="ep:plus" /> + 添加商机 + </el-button> + <FollowUpRecordBusinessForm :businesses="formData.businesses" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + + <!-- 弹窗 --> + <ContactListModal + ref="contactTableSelectRef" + :customer-id="formData.bizId" + @success="handleAddContact" + /> + <BusinessListModal + ref="businessTableSelectRef" + :customer-id="formData.bizId" + @success="handleAddBusiness" + /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { FollowUpRecordApi, FollowUpRecordVO } from '@/api/crm/followup' +import { BizTypeEnum } from '@/api/crm/permission' +import FollowUpRecordBusinessForm from './components/FollowUpRecordBusinessForm.vue' +import FollowUpRecordContactForm from './components/FollowUpRecordContactForm.vue' +import BusinessListModal from '@/views/crm/business/components/BusinessListModal.vue' +import * as BusinessApi from '@/api/crm/business' +import ContactListModal from '@/views/crm/contact/components/ContactListModal.vue' +import * as ContactApi from '@/api/crm/contact' + +defineOptions({ name: 'FollowUpRecordForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + bizType: undefined, + bizId: undefined, + businesses: [], + contacts: [] +}) +const formRules = reactive({ + type: [{ required: true, message: '跟进类型不能为空', trigger: 'change' }], + content: [{ required: true, message: '跟进内容不能为空', trigger: 'blur' }], + nextTime: [{ required: true, message: '下次联系时间不能为空', trigger: 'blur' }] +}) + +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (bizType: number, bizId: number) => { + dialogVisible.value = true + resetForm() + formData.value.bizType = bizType + formData.value.bizId = bizId +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = { + ...formData.value, + contactIds: formData.value.contacts.map((item) => item.id), + businessIds: formData.value.businesses.map((item) => item.id) + } as unknown as FollowUpRecordVO + await FollowUpRecordApi.createFollowUpRecord(data) + message.success(t('common.createSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 关联联系人 */ +const contactTableSelectRef = ref<InstanceType<typeof ContactListModal>>() +const handleOpenContact = () => { + contactTableSelectRef.value?.open() +} +const handleAddContact = (contactId: [], newContacts: ContactApi.ContactVO[]) => { + newContacts.forEach((contact) => { + if (!formData.value.contacts.some((item) => item.id === contact.id)) { + formData.value.contacts.push(contact) + } + }) +} + +/** 关联商机 */ +const businessTableSelectRef = ref<InstanceType<typeof BusinessListModal>>() +const handleOpenBusiness = () => { + businessTableSelectRef.value?.open() +} +const handleAddBusiness = (businessId: [], newBusinesses: BusinessApi.BusinessVO[]) => { + newBusinesses.forEach((business) => { + if (!formData.value.businesses.some((item) => item.id === business.id)) { + formData.value.businesses.push(business) + } + }) +} + +/** 重置表单 */ +const resetForm = () => { + formRef.value?.resetFields() + formData.value = { + bizId: undefined, + bizType: undefined, + businesses: [], + contacts: [] + } +} +</script> diff --git a/src/views/crm/followup/components/FollowUpRecordBusinessForm.vue b/src/views/crm/followup/components/FollowUpRecordBusinessForm.vue new file mode 100644 index 0000000..620b5fb --- /dev/null +++ b/src/views/crm/followup/components/FollowUpRecordBusinessForm.vue @@ -0,0 +1,42 @@ +<template> + <el-table :data="formData" :show-overflow-tooltip="true" :stripe="true" height="120"> + <el-table-column label="商机名称" fixed="left" align="center" prop="name" /> + <el-table-column + label="商机金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="客户名称" align="center" prop="customerName" /> + <el-table-column label="商机组" align="center" prop="statusTypeName" /> + <el-table-column label="商机阶段" align="center" prop="statusName" /> + <el-table-column align="center" fixed="right" label="操作" width="80"> + <template #default="{ $index }"> + <el-button link type="danger" @click="handleDelete($index)"> 移除</el-button> + </template> + </el-table-column> + </el-table> +</template> + +<script lang="ts" setup> +import { erpPriceTableColumnFormatter } from '@/utils' + +const props = defineProps<{ + businesses: undefined +}>() +const formData = ref([]) + +/** 初始化商机列表 */ +watch( + () => props.businesses, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} +</script> diff --git a/src/views/crm/followup/components/FollowUpRecordContactForm.vue b/src/views/crm/followup/components/FollowUpRecordContactForm.vue new file mode 100644 index 0000000..b3b5d3a --- /dev/null +++ b/src/views/crm/followup/components/FollowUpRecordContactForm.vue @@ -0,0 +1,47 @@ +<template> + <el-table :data="contacts" :show-overflow-tooltip="true" :stripe="true" height="150"> + <el-table-column label="姓名" fixed="left" align="center" prop="name"> + <template #default="scope"> + <el-link type="primary" :underline="false" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column label="手机号" align="center" prop="mobile" /> + <el-table-column label="职位" align="center" prop="post" /> + <el-table-column label="直属上级" align="center" prop="parentName" /> + <el-table-column label="是否关键决策人" align="center" prop="master" min-width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.master" /> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="130"> + <template #default="scope"> + <el-button link type="danger" @click="handleDelete(scope.row.id)"> 移除</el-button> + </template> + </el-table-column> + </el-table> +</template> + +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' + +const props = defineProps<{ + contacts: undefined +}>() +const formData = ref([]) + +/** 初始化联系人列表 */ +watch( + () => props.contacts, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} +</script> diff --git a/src/views/crm/followup/index.vue b/src/views/crm/followup/index.vue new file mode 100644 index 0000000..d0b2271 --- /dev/null +++ b/src/views/crm/followup/index.vue @@ -0,0 +1,167 @@ +<!-- 某个记录的跟进记录列表,目前主要用于 CRM 客户、商机等详情界面 --> +<template> + <!-- 操作栏 --> + <el-row class="mb-10px" justify="end"> + <el-button @click="openForm"> + <Icon class="mr-5px" icon="ep:edit" /> + 写跟进 + </el-button> + </el-row> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="跟进人" prop="creatorName" /> + <el-table-column align="center" label="跟进类型" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_FOLLOW_UP_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column align="center" label="跟进内容" prop="content" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="nextTime" + width="180px" + /> + <el-table-column + align="center" + label="关联联系人" + prop="contactIds" + v-if="bizType === BizTypeEnum.CRM_CUSTOMER" + > + <template #default="scope"> + <el-link + v-for="contact in scope.row.contacts" + :key="`key-${contact.id}`" + :underline="false" + type="primary" + @click="openContactDetail(contact.id)" + class="ml-5px" + > + {{ contact.name }} + </el-link> + </template> + </el-table-column> + <el-table-column + align="center" + label="关联商机" + prop="businessIds" + v-if="bizType === BizTypeEnum.CRM_CUSTOMER" + > + <template #default="scope"> + <el-link + v-for="business in scope.row.businesses" + :key="`key-${business.id}`" + :underline="false" + type="primary" + @click="openBusinessDetail(business.id)" + class="ml-5px" + > + {{ business.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button link type="danger" @click="handleDelete(scope.row.id)"> 删除 </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <FollowUpRecordForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import { DICT_TYPE } from '@/utils/dict' +import { FollowUpRecordApi, FollowUpRecordVO } from '@/api/crm/followup' +import FollowUpRecordForm from './FollowUpRecordForm.vue' +import { BizTypeEnum } from '@/api/crm/permission' + +/** 跟进记录列表 */ +defineOptions({ name: 'FollowUpRecord' }) +const props = defineProps<{ + bizType: number + bizId: number +}>() +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<FollowUpRecordVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + bizType: 0, + bizId: 0 +}) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await FollowUpRecordApi.getFollowUpRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 添加/修改操作 */ +const formRef = ref<InstanceType<typeof FollowUpRecordForm>>() +const openForm = () => { + formRef.value?.open(props.bizType, props.bizId) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await FollowUpRecordApi.deleteFollowUpRecord(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 打开联系人详情 */ +const { push } = useRouter() +const openContactDetail = (id: number) => { + push({ name: 'CrmContactDetail', params: { id } }) +} + +/** 打开商机详情 */ +const openBusinessDetail = (id: number) => { + push({ name: 'CrmBusinessDetail', params: { id } }) +} + +watch( + () => props.bizId, + () => { + queryParams.bizType = props.bizType + queryParams.bizId = props.bizId + getList() + } +) +</script> diff --git a/src/views/crm/permission/components/PermissionForm.vue b/src/views/crm/permission/components/PermissionForm.vue new file mode 100644 index 0000000..9cf8867 --- /dev/null +++ b/src/views/crm/permission/components/PermissionForm.vue @@ -0,0 +1,137 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="30%"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item v-if="formType === 'create'" label="选择人员" prop="userId"> + <el-select v-model="formData.userId"> + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="权限级别" prop="level"> + <el-radio-group v-model="formData.level"> + <template + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_PERMISSION_LEVEL)" + :key="dict.value" + > + <el-radio v-if="dict.value != PermissionLevelEnum.OWNER" :label="dict.value"> + {{ dict.label }} + </el-radio> + </template> + </el-radio-group> + </el-form-item> + <el-form-item + v-if="formType === 'create' && formData.bizType === BizTypeEnum.CRM_CUSTOMER" + label="同时添加至" + > + <el-checkbox-group v-model="formData.toBizTypes"> + <el-checkbox :label="BizTypeEnum.CRM_CONTACT">联系人</el-checkbox> + <el-checkbox :label="BizTypeEnum.CRM_BUSINESS">商机</el-checkbox> + <el-checkbox :label="BizTypeEnum.CRM_CONTRACT">合同</el-checkbox> + </el-checkbox-group> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as UserApi from '@/api/system/user' +import * as PermissionApi from '@/api/crm/permission' +import { BizTypeEnum, PermissionLevelEnum } from '@/api/crm/permission' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' + +defineOptions({ name: 'CrmPermissionForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const formData = ref<PermissionApi.PermissionVO>({} as PermissionApi.PermissionVO) +const formRules = reactive({ + userId: [{ required: true, message: '人员不能为空', trigger: 'blur' }], + level: [{ required: true, message: '权限级别不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: 'create' | 'update', bizType: number, bizId: number, ids?: number[]) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + '团队成员' + formType.value = type + resetForm(bizType, bizId) + // 修改时,设置数据 + if (ids) { + formData.value.ids = ids + } +} +/** 打开修改权限弹窗 */ +const open0 = async ( + type: 'create' | 'update', + bizType: number, + bizId: number, + id: number, + level: number +) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + '团队成员' + formType.value = type + resetForm(bizType, bizId) + // 修改时,设置数据 + formData.value.level = level + formData.value.ids = [id] +} +defineExpose({ open, open0 }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value + if (formType.value === 'create') { + await PermissionApi.createPermission(unref(data)) + message.success(t('common.createSuccess')) + } else { + await PermissionApi.updatePermission(unref(data)) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = (bizType: number, bizId: number) => { + formRef.value?.resetFields() + formData.value = {} as PermissionApi.PermissionVO + formData.value = { ...formData.value, bizType, bizId } +} +onMounted(async () => { + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/crm/permission/components/PermissionList.vue b/src/views/crm/permission/components/PermissionList.vue new file mode 100644 index 0000000..39c7aab --- /dev/null +++ b/src/views/crm/permission/components/PermissionList.vue @@ -0,0 +1,206 @@ +<template> + <!-- 操作栏 --> + <el-row v-if="showAction" justify="end"> + <el-button v-if="validateOwnerUser" type="primary" @click="openForm"> + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button v-if="validateOwnerUser" @click="handleUpdate"> + <Icon class="mr-5px" icon="ep:edit" /> + 编辑 + </el-button> + <el-button v-if="validateOwnerUser" @click="handleDelete"> + <Icon class="mr-5px" icon="ep:delete" /> + 移除 + </el-button> + <el-button v-if="!validateOwnerUser && list.length > 0" type="danger" @click="handleQuit"> + 退出团队 + </el-button> + </el-row> + <!-- 团队成员展示 --> + <el-table + ref="elTableRef" + v-loading="loading" + :data="list" + :show-overflow-tooltip="true" + :stripe="true" + class="mt-20px" + @selection-change="handleSelectionChange" + > + <el-table-column type="selection" width="55" /> + <el-table-column align="center" label="姓名" prop="nickname" /> + <el-table-column align="center" label="部门" prop="deptName" /> + <el-table-column align="center" label="岗位" prop="postNames" /> + <el-table-column align="center" label="权限级别" prop="level"> + <template #default="{ row }"> + <dict-tag :type="DICT_TYPE.CRM_PERMISSION_LEVEL" :value="row.level" /> + </template> + </el-table-column> + <el-table-column :formatter="dateFormatter" align="center" label="加入时间" prop="createTime" /> + </el-table> + + <!-- 表单弹窗:添加/修改 --> + <CrmPermissionForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import { ElTable } from 'element-plus' +import * as PermissionApi from '@/api/crm/permission' +import { useUserStoreWithOut } from '@/store/modules/user' +import CrmPermissionForm from './PermissionForm.vue' +import { DICT_TYPE } from '@/utils/dict' + +defineOptions({ name: 'CrmPermissionList' }) + +const message = useMessage() // 消息 + +const props = defineProps<{ + bizType: number // 模块类型 + bizId: number | undefined // 模块数据编号 + showAction: boolean //是否展示操作按钮 +}>() +const loading = ref(true) // 列表的加载中 +const list = ref<PermissionApi.PermissionVO[]>([]) // 列表的数据 +const formData = ref({ + ownerUserId: 0 +}) +const userStore = useUserStoreWithOut() // 用户信息缓存 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PermissionApi.getPermissionList({ + bizType: props.bizType, + bizId: props.bizId + }) + list.value = data + const permission = list.value.find( + (item) => + item.userId === userStore.getUser.id && + item.level === PermissionApi.PermissionLevelEnum.OWNER + ) + if (permission) { + formData.value.ownerUserId = userStore.getUser.id + } + } finally { + loading.value = false + } +} +const multipleSelection = ref<PermissionApi.PermissionVO[]>([]) // 选择的团队成员 +const elTableRef = ref<InstanceType<typeof ElTable>>() +const handleSelectionChange = (val: PermissionApi.PermissionVO[]) => { + if (val.findIndex((item) => item.level === PermissionApi.PermissionLevelEnum.OWNER) !== -1) { + message.warning('不能选择负责人!') + elTableRef.value?.clearSelection() + return + } + multipleSelection.value = val +} + +/** 编辑团队成员 */ +const formRef = ref<InstanceType<typeof CrmPermissionForm>>() // 权限表单 Ref +const handleUpdate = () => { + if (multipleSelection.value?.length === 0) { + message.warning('请先选择团队成员后操作!') + return + } + if (multipleSelection.value?.length > 1) { + message.warning('编辑团队成员时只能选择一个!') + return + } + formRef.value?.open0( + 'update', + props.bizType, + props.bizId!, + multipleSelection.value[0].id!, + multipleSelection.value[0].level + ) +} + +/** 移除团队成员 */ +const handleDelete = async () => { + if (multipleSelection.value?.length === 0) { + message.warning('请先选择团队成员后操作!') + return + } + await message.delConfirm() + const ids = multipleSelection.value?.map((item) => item.id) as unknown as number[] + await PermissionApi.deletePermissionBatch(ids) + message.success('移除团队成员成功!') + await getList() +} + +/** 添加团队成员 */ +const openForm = () => { + formRef.value?.open('create', props.bizType, props.bizId!) +} + +// 校验负责人权限和编辑权限 +const validateOwnerUser = ref(false) +const validateWrite = ref(false) +const isPool = ref(false) +watch( + list, + (newArr) => { + isPool.value = false + if (newArr?.length > 0) { + isPool.value = !list.value.some( + (item) => item.level === PermissionApi.PermissionLevelEnum.OWNER + ) + validateOwnerUser.value = false + validateWrite.value = false + const userId = userStore.getUser?.id + list.value + .filter((item) => item.userId === userId) + .forEach((item) => { + if (item.level === PermissionApi.PermissionLevelEnum.OWNER) { + validateOwnerUser.value = true + validateWrite.value = true + } else if (item.level === PermissionApi.PermissionLevelEnum.WRITE) { + validateWrite.value = true + } + }) + } else { + isPool.value = true + } + }, + { + immediate: true + } +) + +defineExpose({ openForm, validateOwnerUser, validateWrite, isPool }) +const emits = defineEmits<{ + (e: 'quitTeam'): void +}>() +/** 退出团队 */ +const handleQuit = async () => { + const permission = list.value.find( + (item) => + item.userId === userStore.getUser.id && item.level === PermissionApi.PermissionLevelEnum.OWNER + ) + if (permission) { + message.warning('负责人不能退出团队!') + return + } + const userPermission = list.value.find((item) => item.userId === userStore.getUser.id) + if (!userPermission) { + return + } + await PermissionApi.deleteSelfPermission(userPermission.id!) + message.success('退出团队成员成功!') + emits('quitTeam') +} + +watch( + () => props.bizId, + (bizId) => { + if (!bizId) { + return + } + getList() + }, + { immediate: true, deep: true } +) +</script> diff --git a/src/views/crm/permission/components/TransferForm.vue b/src/views/crm/permission/components/TransferForm.vue new file mode 100644 index 0000000..311071b --- /dev/null +++ b/src/views/crm/permission/components/TransferForm.vue @@ -0,0 +1,162 @@ +<!-- 转移数据的表单弹窗,目前主要用于 CRM 客户、商机等详情界面 --> +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="30%"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="150px" + > + <el-form-item label="选择新负责人" prop="newOwnerUserId"> + <el-select v-model="formData.newOwnerUserId"> + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="老负责人"> + <el-radio-group v-model="oldOwnerHandler" @change="handleOwnerChange"> + <el-radio :label="false" size="large">移除</el-radio> + <el-radio :label="true" size="large">加入团队</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item v-if="oldOwnerHandler" label="老负责人权限级别" prop="oldOwnerPermissionLevel"> + <el-radio-group v-model="formData.oldOwnerPermissionLevel"> + <template + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_PERMISSION_LEVEL)" + :key="dict.value" + > + <el-radio v-if="dict.value != PermissionLevelEnum.OWNER" :label="dict.value"> + {{ dict.label }} + </el-radio> + </template> + </el-radio-group> + </el-form-item> + <el-form-item v-if="bizType === BizTypeEnum.CRM_CUSTOMER" label="同时转移"> + <el-checkbox-group v-model="formData.toBizTypes"> + <el-checkbox :label="BizTypeEnum.CRM_CONTACT">联系人</el-checkbox> + <el-checkbox :label="BizTypeEnum.CRM_BUSINESS">商机</el-checkbox> + <el-checkbox :label="BizTypeEnum.CRM_CONTRACT">合同</el-checkbox> + </el-checkbox-group> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as UserApi from '@/api/system/user' +import * as BusinessApi from '@/api/crm/business' +import * as ClueApi from '@/api/crm/clue' +import * as ContactApi from '@/api/crm/contact' +import * as CustomerApi from '@/api/crm/customer' +import * as ContractApi from '@/api/crm/contract' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { BizTypeEnum, PermissionLevelEnum, TransferReqVO } from '@/api/crm/permission' + +defineOptions({ name: 'CrmTransferForm' }) + +const props = defineProps<{ + bizType: number +}>() + +const message = useMessage() // 消息弹窗 +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const oldOwnerHandler = ref(false) // 老负责人的处理方式 +const formData = ref<TransferReqVO>({} as TransferReqVO) +const formRules = reactive({ + newOwnerUserId: [{ required: true, message: '新负责人不能为空', trigger: 'blur' }], + oldOwnerPermissionLevel: [ + { required: true, message: '老负责人加入团队后的权限级别不能为空', trigger: 'blur' } + ] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (bizId: number) => { + dialogVisible.value = true + dialogTitle.value = getDialogTitle() + resetForm() + formData.value.id = bizId +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +// 老负责人负责方式 +const handleOwnerChange = (val: boolean) => { + if (!val) { + // 移除的话提交不带 oldOwnerPermissionLevel 参数 + formData.value.oldOwnerPermissionLevel = undefined + } +} +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value + await transfer(unref(data)) + message.success(dialogTitle.value + '成功') + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} +const transfer = async (data: TransferReqVO) => { + switch (props.bizType) { + case BizTypeEnum.CRM_CLUE: + return await ClueApi.transferClue(data) + case BizTypeEnum.CRM_CUSTOMER: + return await CustomerApi.transferCustomer(data) + case BizTypeEnum.CRM_CONTACT: + return await ContactApi.transferContact(data) + case BizTypeEnum.CRM_BUSINESS: + return await BusinessApi.transferBusiness(data) + case BizTypeEnum.CRM_CONTRACT: + return await ContractApi.transferContract(data) + default: + message.error('【转移失败】没有转移接口') + throw new Error('【转移失败】没有转移接口') + } +} +const getDialogTitle = () => { + switch (props.bizType) { + case BizTypeEnum.CRM_CLUE: + return '线索转移' + case BizTypeEnum.CRM_CUSTOMER: + return '客户转移' + case BizTypeEnum.CRM_CONTACT: + return '联系人转移' + case BizTypeEnum.CRM_BUSINESS: + return '商机转移' + case BizTypeEnum.CRM_CONTRACT: + return '合同转移' + default: + return '转移' + } +} + +/** 重置表单 */ +const resetForm = () => { + formRef.value?.resetFields() + formData.value = {} as TransferReqVO +} +onMounted(async () => { + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/crm/product/ProductForm.vue b/src/views/crm/product/ProductForm.vue new file mode 100644 index 0000000..1bc5aac --- /dev/null +++ b/src/views/crm/product/ProductForm.vue @@ -0,0 +1,212 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-row> + <el-col :span="12"> + <el-form-item label="产品名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入产品名称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="负责人" prop="ownerUserId"> + <el-select + v-model="formData.ownerUserId" + placeholder="请选择负责人" + :disabled="formData.id" + class="w-1/1" + > + <el-option + v-for="user in userList" + :key="user.id" + :label="user.nickname" + :value="user.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="产品类型" prop="categoryId"> + <el-cascader + v-model="formData.categoryId" + :options="productCategoryList" + :props="defaultProps" + class="w-1/1" + clearable + placeholder="请选择产品类型" + filterable + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="产品单位" prop="unit"> + <el-select v-model="formData.unit" class="w-1/1" placeholder="请选择单位"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_PRODUCT_UNIT)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="产品编码" prop="no"> + <el-input v-model="formData.no" placeholder="请输入产品编码" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="价格" prop="price"> + <el-input-number + v-model="formData.price" + placeholder="请输入价格" + :min="0" + :precision="2" + :step="0.1" + class="w-full!" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="产品描述" prop="description"> + <el-input v-model="formData.description" placeholder="请输入产品描述" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="上架状态" prop="status"> + <el-select v-model="formData.status" placeholder="请选择状态" class="w-1/1"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_PRODUCT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as ProductApi from '@/api/crm/product' +import * as ProductCategoryApi from '@/api/crm/product/category' +import { defaultProps, handleTree } from '@/utils/tree' +import { getSimpleUserList, UserVO } from '@/api/system/user' +import { useUserStore } from '@/store/modules/user' + +defineOptions({ name: 'CrmProductForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const userId = useUserStore().getUser.id // 当前登录的编号 +const formData = ref({ + id: undefined, + name: undefined, + no: undefined, + unit: undefined, + price: Number(undefined), + status: undefined, + categoryId: undefined, + description: undefined, + ownerUserId: -1 +}) +const formRules = reactive({ + name: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }], + no: [{ required: true, message: '产品编码不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'change' }], + categoryId: [{ required: true, message: '产品分类ID不能为空', trigger: 'blur' }], + ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }], + price: [{ required: true, message: '价格不能为空', trigger: 'blur' }] +}) + +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ProductApi.getProduct(id) + } finally { + formLoading.value = false + } + } else { + formData.value.ownerUserId = userId + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ProductApi.ProductVO + if (formType.value === 'create') { + await ProductApi.createProduct(data) + message.success(t('common.createSuccess')) + } else { + await ProductApi.updateProduct(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + no: undefined, + unit: undefined, + price: Number(undefined), + status: undefined, + categoryId: undefined, + description: undefined, + ownerUserId: -1 + } + formRef.value?.resetFields() +} + +/** 初始化 */ +const productCategoryList = ref<any[]>([]) // 产品分类树 +const userList = ref<UserVO[]>([]) // 系统用户 +onMounted(async () => { + // 产品分类树 + const data = await ProductCategoryApi.getProductCategoryList({}) + productCategoryList.value = handleTree(data, 'id', 'parentId') + // 系统用户列表 + userList.value = await getSimpleUserList() +}) +</script> diff --git a/src/views/crm/product/category/ProductCategoryForm.vue b/src/views/crm/product/category/ProductCategoryForm.vue new file mode 100644 index 0000000..0373fc3 --- /dev/null +++ b/src/views/crm/product/category/ProductCategoryForm.vue @@ -0,0 +1,110 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="父级分类" prop="parentId"> + <el-select v-model="formData.parentId" placeholder="请选择上级分类"> + <el-option :key="0" label="顶级分类" :value="0" /> + <el-option + v-for="item in productCategoryList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as ProductCategoryApi from '@/api/crm/product/category' + +defineOptions({ name: 'CrmProductCategoryForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + parentId: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }], + parentId: [{ required: true, message: '父级分类不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const productCategoryList = ref<any[]>([]) // 产品分类树 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ProductCategoryApi.getProductCategory(id) + } finally { + formLoading.value = false + } + } + // 获得分类树 + productCategoryList.value = await ProductCategoryApi.getProductCategoryList({ parentId: 0 }) +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ProductCategoryApi.ProductCategoryVO + if (formType.value === 'create') { + await ProductCategoryApi.createProductCategory(data) + message.success(t('common.createSuccess')) + } else { + await ProductCategoryApi.updateProductCategory(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + parentId: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/crm/product/category/index.vue b/src/views/crm/product/category/index.vue new file mode 100644 index 0000000..631c170 --- /dev/null +++ b/src/views/crm/product/category/index.vue @@ -0,0 +1,139 @@ +<template> + <doc-alert title="【产品】产品管理、产品分类" url="https://doc.iocoder.cn/crm/product/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['crm:product-category:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" row-key="id" default-expand-all> + <el-table-column label="分类编号" align="center" prop="id" /> + <el-table-column label="分类名称" align="center" prop="name" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['crm:product-category:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['crm:product-category:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ProductCategoryForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as ProductCategoryApi from '@/api/crm/product/category' +import ProductCategoryForm from './ProductCategoryForm.vue' +import { handleTree } from '@/utils/tree' + +defineOptions({ name: 'CrmProductCategory' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<any[]>([]) // 列表的数据 +const queryParams = reactive({ + name: null +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductCategoryApi.getProductCategoryList(queryParams) + list.value = handleTree(data, 'id', 'parentId') + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ProductCategoryApi.deleteProductCategory(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/product/detail/ProductDetailsHeader.vue b/src/views/crm/product/detail/ProductDetailsHeader.vue new file mode 100644 index 0000000..11286d6 --- /dev/null +++ b/src/views/crm/product/detail/ProductDetailsHeader.vue @@ -0,0 +1,46 @@ +<template> + <div> + <div class="flex items-start justify-between"> + <div> + <el-col> + <el-row> + <span class="text-xl font-bold">{{ product.name }}</span> + </el-row> + </el-col> + </div> + <div> + <!-- 右上:按钮 --> + <el-button @click="openForm('update', product.id)" v-hasPermi="['crm:product:update']"> + 编辑 + </el-button> + </div> + </div> + </div> + <ContentWrap class="mt-10px"> + <el-descriptions :column="5" direction="vertical"> + <el-descriptions-item label="产品类别">{{ product.categoryName }}</el-descriptions-item> + <el-descriptions-item label="产品单位"> + <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="product.unit" /> + </el-descriptions-item> + <el-descriptions-item label="产品价格"> + {{ erpPriceInputFormatter(product.price) }} 元 + </el-descriptions-item> + <el-descriptions-item label="产品编码">{{ product.no }}</el-descriptions-item> + </el-descriptions> + </ContentWrap> + <!-- 表单弹窗:添加/修改 --> + <ProductForm ref="formRef" @success="emit('refresh')" /> +</template> +<script setup lang="ts"> +import ProductForm from '@/views/crm/product/ProductForm.vue' +import { DICT_TYPE } from '@/utils/dict' +import { erpPriceInputFormatter } from '@/utils' +import * as ProductApi from '@/api/crm/product' + +// 操作修改 +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} +const { product } = defineProps<{ product: ProductApi.ProductVO }>() +</script> diff --git a/src/views/crm/product/detail/ProductDetailsInfo.vue b/src/views/crm/product/detail/ProductDetailsInfo.vue new file mode 100644 index 0000000..52a11e9 --- /dev/null +++ b/src/views/crm/product/detail/ProductDetailsInfo.vue @@ -0,0 +1,38 @@ +<template> + <ContentWrap> + <el-collapse v-model="activeNames"> + <el-collapse-item name="basicInfo"> + <template #title> + <span class="text-base font-bold">基本信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item> + <el-descriptions-item label="产品编码">{{ product.no }}</el-descriptions-item> + <el-descriptions-item label="价格"> + {{ erpPriceInputFormatter(product.price) }} 元 + </el-descriptions-item> + <el-descriptions-item label="产品描述">{{ product.description }}</el-descriptions-item> + <el-descriptions-item label="产品类型">{{ product.categoryName }}</el-descriptions-item> + <el-descriptions-item label="是否上下架"> + <dict-tag :type="DICT_TYPE.CRM_PRODUCT_STATUS" :value="product.status" /> + </el-descriptions-item> + <el-descriptions-item label="单位"> + <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="product.unit" /> + </el-descriptions-item> + </el-descriptions> + </el-collapse-item> + </el-collapse> + </ContentWrap> +</template> +<script setup lang="ts"> +import { DICT_TYPE } from '@/utils/dict' +import * as ProductApi from '@/api/crm/product' +import { erpPriceInputFormatter } from '@/utils' + +const { product } = defineProps<{ + product: ProductApi.ProductVO +}>() + +// 展示的折叠面板 +const activeNames = ref(['basicInfo']) +</script> diff --git a/src/views/crm/product/detail/index.vue b/src/views/crm/product/detail/index.vue new file mode 100644 index 0000000..ff9efd9 --- /dev/null +++ b/src/views/crm/product/detail/index.vue @@ -0,0 +1,66 @@ +<template> + <ProductDetailsHeader :loading="loading" :product="product" @refresh="getProductData(id)" /> + <el-col> + <el-tabs> + <el-tab-pane label="详细资料"> + <ProductDetailsInfo :product="product" /> + </el-tab-pane> + <el-tab-pane label="操作日志"> + <OperateLogV2 :log-list="logList" /> + </el-tab-pane> + </el-tabs> + </el-col> +</template> +<script lang="ts" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import { OperateLogVO } from '@/api/system/operatelog' +import * as ProductApi from '@/api/crm/product' +import ProductDetailsHeader from '@/views/crm/product/detail/ProductDetailsHeader.vue' +import ProductDetailsInfo from '@/views/crm/product/detail/ProductDetailsInfo.vue' +import { BizTypeEnum } from '@/api/crm/permission' +import { getOperateLogPage } from '@/api/crm/operateLog' + +defineOptions({ name: 'CrmProductDetail' }) + +const route = useRoute() +const message = useMessage() +const id = Number(route.params.id) // 编号 +const loading = ref(true) // 加载中 +const product = ref<ProductApi.ProductVO>({} as ProductApi.ProductVO) // 详情 + +/** 获取详情 */ +const getProductData = async (id: number) => { + loading.value = true + try { + product.value = await ProductApi.getProduct(id) + await getOperateLog(id) + } finally { + loading.value = false + } +} + +/** 获取操作日志 */ +const logList = ref<OperateLogVO[]>([]) // 操作日志列表 +const getOperateLog = async (productId: number) => { + if (!productId) { + return + } + const data = await getOperateLogPage({ + bizType: BizTypeEnum.CRM_PRODUCT, + bizId: productId + }) + logList.value = data.list +} + +/** 初始化 */ +const { delView } = useTagsViewStore() // 视图操作 +const { currentRoute } = useRouter() // 路由 +onMounted(async () => { + if (!id) { + message.warning('参数错误,产品不能为空!') + delView(unref(currentRoute)) + return + } + await getProductData(id) +}) +</script> diff --git a/src/views/crm/product/index.vue b/src/views/crm/product/index.vue new file mode 100644 index 0000000..5d656df --- /dev/null +++ b/src/views/crm/product/index.vue @@ -0,0 +1,230 @@ +<template> + <doc-alert title="【产品】产品管理、产品分类" url="https://doc.iocoder.cn/crm/product/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="产品名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入产品名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_PRODUCT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> <Icon icon="ep:search" class="mr-5px" /> 搜索 </el-button> + <el-button @click="resetQuery"> <Icon icon="ep:refresh" class="mr-5px" /> 重置 </el-button> + <el-button type="primary" @click="openForm('create')" v-hasPermi="['crm:product:create']"> + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['crm:product:export']" + > + <Icon icon="ep:download" class="mr-5px" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="产品名称" align="center" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column label="产品类型" align="center" prop="categoryName" width="160" /> + <el-table-column label="产品单位" align="center" prop="unit"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_PRODUCT_UNIT" :value="scope.row.unit" /> + </template> + </el-table-column> + <el-table-column label="产品编码" align="center" prop="no" /> + <el-table-column + label="价格(元)" + align="center" + prop="price" + :formatter="erpPriceTableColumnFormatter" + width="100" + /> + <el-table-column label="产品描述" align="center" prop="description" width="150" /> + <el-table-column label="上架状态" align="center" prop="status" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_PRODUCT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="负责人" align="center" prop="ownerUserName" width="120" /> + <el-table-column + label="更新时间" + align="center" + prop="updateTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" width="120" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" fixed="right" width="160"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['crm:product:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['crm:product:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ProductForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as ProductApi from '@/api/crm/product' +import ProductForm from './ProductForm.vue' +import { erpPriceTableColumnFormatter } from '@/utils' + +defineOptions({ name: 'CrmProduct' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductApi.getProductPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 打开详情 */ +const { currentRoute, push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmProductDetail', params: { id } }) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ProductApi.deleteProduct(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ProductApi.exportProduct(queryParams) + download.excel(data, '产品.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 激活时 */ +onActivated(() => { + getList() +}) + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/crm/receivable/ReceivableForm.vue b/src/views/crm/receivable/ReceivableForm.vue new file mode 100644 index 0000000..a44164a --- /dev/null +++ b/src/views/crm/receivable/ReceivableForm.vue @@ -0,0 +1,293 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-row> + <el-col :span="12"> + <el-form-item label="回款编号" prop="no"> + <el-input v-model="formData.no" disabled placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="负责人" prop="ownerUserId"> + <el-select + v-model="formData.ownerUserId" + :disabled="formType !== 'create'" + class="w-1/1" + > + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="客户名称" prop="customerId"> + <el-select + v-model="formData.customerId" + :disabled="formType !== 'create'" + class="w-1/1" + filterable + placeholder="请选择客户" + @change="handleCustomerChange" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="合同名称" prop="contractId"> + <el-select + v-model="formData.contractId" + :disabled="formType !== 'create' || !formData.customerId" + class="w-1/1" + filterable + placeholder="请选择合同" + @change="handleContractChange" + > + <el-option + v-for="data in contractList" + :key="data.id" + :disabled="data.auditStatus !== 20" + :label="data.name" + :value="data.id!" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="回款期数" prop="planId"> + <el-select + v-model="formData.planId" + :disabled="formType !== 'create' || !formData.contractId" + class="!w-1/1" + placeholder="请选择回款期数" + @change="handleReceivablePlanChange" + > + <el-option + v-for="data in receivablePlanList" + :key="data.id" + :disabled="data.receivableId" + :label="'第 ' + data.period + ' 期'" + :value="data.id!" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="回款方式" prop="returnType"> + <el-select v-model="formData.returnType" class="w-1/1" placeholder="请选择回款方式"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="回款金额" prop="price"> + <el-input-number + v-model="formData.price" + :min="0.01" + :precision="2" + class="!w-100%" + controls-position="right" + placeholder="请输入回款金额" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="回款日期" prop="returnTime"> + <el-date-picker + v-model="formData.returnTime" + placeholder="选择回款日期" + type="date" + value-format="x" + /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="24"> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as ReceivablePlanApi from '@/api/crm/receivable/plan' +import * as ReceivableApi from '@/api/crm/receivable' +import { ReceivableVO } from '@/api/crm/receivable' +import * as UserApi from '@/api/system/user' +import * as CustomerApi from '@/api/crm/customer' +import * as ContractApi from '@/api/crm/contract' +import { useUserStore } from '@/store/modules/user' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref<ReceivableApi.ReceivableVO>({} as ReceivableApi.ReceivableVO) +const formRules = reactive({ + customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }], + contractId: [{ required: true, message: '合同不能为空', trigger: 'blur' }], + returnTime: [{ required: true, message: '回款日期不能为空', trigger: 'blur' }], + price: [{ required: true, message: '回款金额不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const customerList = ref<CustomerApi.CustomerVO[]>([]) // 客户列表 +const contractList = ref<ContractApi.ContractVO[]>([]) // 合同列表 +const receivablePlanList = ref<ReceivablePlanApi.ReceivablePlanVO[]>([]) // 回款计划列表 + +/** 打开弹窗 */ +const open = async ( + type: string, + id?: number, + receivablePlan?: ReceivablePlanApi.ReceivablePlanVO +) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = (await ReceivableApi.getReceivable(id)) as ReceivableVO + formData.value = data + await handleCustomerChange(data.customerId!) + formData.value.contractId = data?.contract?.id + } finally { + formLoading.value = false + } + } + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() + // 获得客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() + // 默认新建时选中自己 + if (formType.value === 'create') { + formData.value.ownerUserId = useUserStore().getUser.id + } + // 从回款计划创建回款 + if (receivablePlan) { + formData.value.customerId = receivablePlan.customerId + await handleCustomerChange(receivablePlan.customerId) + formData.value.contractId = receivablePlan.contractId + await handleContractChange(receivablePlan.contractId) + if (receivablePlan.id) { + formData.value.planId = receivablePlan.id + formData.value.price = receivablePlan.price + formData.value.returnType = receivablePlan.returnType + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ReceivableApi.ReceivableVO + if (formType.value === 'create') { + await ReceivableApi.createReceivable(data) + message.success(t('common.createSuccess')) + } else { + await ReceivableApi.updateReceivable(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = {} as ReceivableApi.ReceivableVO + formRef.value?.resetFields() +} + +/** 处理切换客户 */ +const handleCustomerChange = async (customerId: number) => { + // 重置合同编号 + formData.value.contractId = undefined + // 获得合同列表 + if (customerId) { + contractList.value = [] + contractList.value = await ContractApi.getContractSimpleList(customerId) + } +} + +/** 处理切换合同 */ +const handleContractChange = async (contractId: number) => { + // 重置回款计划编号 + formData.value.planId = undefined + if (contractId) { + // 获得回款计划列表 + receivablePlanList.value = [] + receivablePlanList.value = await ReceivablePlanApi.getReceivablePlanSimpleList( + formData.value.customerId!, + contractId + ) + // 设置金额 + const contract = contractList.value.find((item) => item.id === contractId) + if (contract) { + formData.value.price = contract.totalPrice - contract.totalReceivablePrice + } + } +} + +/** 处理切换回款计划 */ +const handleReceivablePlanChange = (planId: number) => { + if (!planId) { + return + } + const receivablePlan = receivablePlanList.value.find((item) => item.id === planId) + if (!receivablePlan) { + return + } + formData.value.price = receivablePlan.price + formData.value.returnType = receivablePlan.returnType +} +</script> diff --git a/src/views/crm/receivable/components/ReceivableList.vue b/src/views/crm/receivable/components/ReceivableList.vue new file mode 100644 index 0000000..67287ea --- /dev/null +++ b/src/views/crm/receivable/components/ReceivableList.vue @@ -0,0 +1,164 @@ +<template> + <!-- 操作栏 --> + <el-row justify="end"> + <el-button @click="openForm('create')"> + <Icon class="mr-5px" icon="icon-park:income-one" /> + 创建回款 + </el-button> + </el-row> + + <!-- 列表 --> + <ContentWrap class="mt-10px"> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="回款编号" prop="no" /> + <el-table-column align="center" label="客户" prop="customerName" /> + <el-table-column align="center" label="合同" prop="contract.no" /> + <el-table-column + :formatter="dateFormatter2" + align="center" + label="回款日期" + prop="returnTime" + width="150px" + /> + <el-table-column align="center" label="回款方式" prop="returnType" width="130px"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE" :value="scope.row.returnType" /> + </template> + </el-table-column> + <el-table-column + align="center" + label="回款金额(元)" + prop="price" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column align="center" label="负责人" prop="ownerUserName" /> + <el-table-column align="center" label="备注" prop="remark" /> + <el-table-column align="center" fixed="right" label="操作" width="130px"> + <template #default="scope"> + <el-button + v-hasPermi="['crm:receivable:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['crm:receivable:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加 --> + <ReceivableForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import * as ReceivablePlanApi from '@/api/crm/receivable/plan' +import * as ReceivableApi from '@/api/crm/receivable' +import ReceivableForm from './../ReceivableForm.vue' +import { dateFormatter2 } from '@/utils/formatTime' +import { DICT_TYPE } from '@/utils/dict' +import { erpPriceTableColumnFormatter } from '@/utils' + +defineOptions({ name: 'CrmReceivableList' }) +const props = defineProps<{ + customerId?: number // 客户编号 + contractId?: number // 合同编号 +}>() + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + customerId: undefined as unknown, // 允许 undefined + number + contractId: undefined as unknown // 允许 undefined + number +}) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + if (props.customerId && !props.contractId) { + queryParams.customerId = props.customerId + } else if (props.customerId && props.contractId) { + // 如果是合同的话客户编号也需要带上因为权限基于客户 + queryParams.customerId = props.customerId + queryParams.contractId = props.contractId + } + const data = await ReceivableApi.getReceivablePageByCustomer(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + // 置空参数 + queryParams.customerId = undefined + queryParams.contractId = undefined + getList() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id, { + customerId: props.customerId, + contractId: props.contractId + }) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ReceivableApi.deleteReceivable(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 从回款计划创建回款 */ +const createReceivable = (planData: any) => { + const data = planData as unknown as ReceivablePlanApi.ReceivablePlanVO + formRef.value.open('create', undefined, data) +} +defineExpose({ createReceivable }) + +/** 监听打开的 customerId + contractId,从而加载最新的列表 */ +watch( + () => [props.customerId, props.contractId], + (newVal) => { + // 保证至少客户编号有值 + if (!newVal[0]) { + return + } + handleQuery() + }, + { immediate: true, deep: true } +) +</script> diff --git a/src/views/crm/receivable/detail/ReceivableDetailsHeader.vue b/src/views/crm/receivable/detail/ReceivableDetailsHeader.vue new file mode 100644 index 0000000..62201de --- /dev/null +++ b/src/views/crm/receivable/detail/ReceivableDetailsHeader.vue @@ -0,0 +1,43 @@ +<template> + <div> + <div class="flex items-start justify-between"> + <div> + <el-col> + <el-row> + <span class="text-xl font-bold">{{ receivable.no }}</span> + </el-row> + </el-col> + </div> + <div> + <!-- 右上:按钮 --> + <slot></slot> + </div> + </div> + </div> + <ContentWrap class="mt-10px"> + <el-descriptions :column="5" direction="vertical"> + <el-descriptions-item label="客户名称"> + {{ receivable.customerName }} + </el-descriptions-item> + <el-descriptions-item label="合同金额"> + {{ erpPriceInputFormatter(receivable.contract?.totalPrice) }} + </el-descriptions-item> + <el-descriptions-item label="回款日期"> + {{ formatDate(receivable.returnTime) }} + </el-descriptions-item> + <el-descriptions-item label="回款金额"> + {{ erpPriceInputFormatter(receivable.price) }} + </el-descriptions-item> + <el-descriptions-item label="负责人"> + {{ receivable.ownerUserName }} + </el-descriptions-item> + </el-descriptions> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ReceivableApi from '@/api/crm/receivable' +import { formatDate } from '@/utils/formatTime' +import { erpPriceInputFormatter } from '@/utils' + +const { receivable } = defineProps<{ receivable: ReceivableApi.ReceivableVO }>() +</script> diff --git a/src/views/crm/receivable/detail/ReceivableDetailsInfo.vue b/src/views/crm/receivable/detail/ReceivableDetailsInfo.vue new file mode 100644 index 0000000..003029f --- /dev/null +++ b/src/views/crm/receivable/detail/ReceivableDetailsInfo.vue @@ -0,0 +1,62 @@ +<template> + <ContentWrap> + <el-collapse v-model="activeNames"> + <el-collapse-item name="basicInfo"> + <template #title> + <span class="text-base font-bold">基本信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="回款编号">{{ receivable.no }}</el-descriptions-item> + <el-descriptions-item label="客户名称"> + {{ receivable.customerName }} + </el-descriptions-item> + <el-descriptions-item label="合同编号"> + {{ receivable.contract?.no }} + </el-descriptions-item> + <el-descriptions-item label="回款日期"> + {{ formatDate(receivable.returnTime, 'YYYY-MM-DD') }} + </el-descriptions-item> + <el-descriptions-item label="回款金额"> + {{ erpPriceInputFormatter(receivable.price) }} + </el-descriptions-item> + <el-descriptions-item label="回款方式"> + <dict-tag :type="DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE" :value="receivable.returnType" /> + </el-descriptions-item> + <el-descriptions-item label="备注">{{ receivable.remark }}</el-descriptions-item> + </el-descriptions> + </el-collapse-item> + <el-collapse-item name="systemInfo"> + <template #title> + <span class="text-base font-bold">系统信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="负责人"> + {{ receivable.ownerUserName }} + </el-descriptions-item> + <el-descriptions-item label="创建人"> + {{ receivable.creatorName }} + </el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(receivable.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="更新时间"> + {{ formatDate(receivable.updateTime) }} + </el-descriptions-item> + </el-descriptions> + </el-collapse-item> + </el-collapse> + </ContentWrap> +</template> +<script setup lang="ts"> +import * as ReceivableApi from '@/api/crm/receivable' +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import { erpPriceInputFormatter } from '@/utils' + +const { receivable } = defineProps<{ + receivable: ReceivableApi.ReceivableVO +}>() + +// 展示的折叠面板 +const activeNames = ref(['basicInfo', 'systemInfo']) +</script> diff --git a/src/views/crm/receivable/detail/index.vue b/src/views/crm/receivable/detail/index.vue new file mode 100644 index 0000000..3603572 --- /dev/null +++ b/src/views/crm/receivable/detail/index.vue @@ -0,0 +1,100 @@ +<template> + <ReceivableDetailsHeader v-loading="loading" :receivable="receivable"> + <el-button v-if="permissionListRef?.validateWrite" @click="openForm('update', receivable.id)"> + 编辑 + </el-button> + </ReceivableDetailsHeader> + <el-col> + <el-tabs> + <el-tab-pane label="详细资料"> + <ReceivableDetailsInfo :receivable="receivable" /> + </el-tab-pane> + <el-tab-pane label="操作日志"> + <OperateLogV2 :log-list="logList" /> + </el-tab-pane> + <el-tab-pane label="团队成员"> + <PermissionList + ref="permissionListRef" + :biz-id="receivable.id!" + :biz-type="BizTypeEnum.CRM_RECEIVABLE" + :show-action="true" + @quit-team="close" + /> + </el-tab-pane> + </el-tabs> + </el-col> + + <!-- 表单弹窗:添加/修改 --> + <ReceivableForm ref="formRef" @success="getReceivable(receivable.id)" /> +</template> +<script lang="ts" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import * as ReceivableApi from '@/api/crm/receivable' +import ReceivableDetailsHeader from './ReceivableDetailsHeader.vue' +import ReceivableDetailsInfo from './ReceivableDetailsInfo.vue' +import PermissionList from '@/views/crm/permission/components/PermissionList.vue' // 团队成员列表(权限) +import { BizTypeEnum } from '@/api/crm/permission' +import { OperateLogVO } from '@/api/system/operatelog' +import { getOperateLogPage } from '@/api/crm/operateLog' +import ReceivableForm from '@/views/crm/receivable/ReceivableForm.vue' + +defineOptions({ name: 'CrmReceivablePlanDetail' }) +const props = defineProps<{ id?: number }>() + +const route = useRoute() +const message = useMessage() +const receivableId = ref(0) // 回款编号 +const loading = ref(true) // 加载中 +const receivable = ref<ReceivableApi.ReceivableVO>({} as ReceivableApi.ReceivableVO) // 回款详情 +const permissionListRef = ref<InstanceType<typeof PermissionList>>() // 团队成员列表 Ref + +/** 获取详情 */ +const getReceivable = async (id: number) => { + loading.value = true + try { + receivable.value = await ReceivableApi.getReceivable(id) + await getOperateLog(id) + } finally { + loading.value = false + } +} + +/** 编辑 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 获取操作日志 */ +const logList = ref<OperateLogVO[]>([]) // 操作日志列表 +const getOperateLog = async (receivableId: number) => { + if (!receivableId) { + return + } + const data = await getOperateLogPage({ + bizType: BizTypeEnum.CRM_RECEIVABLE, + bizId: receivableId + }) + logList.value = data.list +} + +/** 关闭窗口 */ +const { delView } = useTagsViewStore() // 视图操作 +const { currentRoute } = useRouter() // 路由 +const close = () => { + delView(unref(currentRoute)) +} + +/** 初始化 */ +const { params } = useRoute() +onMounted(async () => { + const id = props.id || route.params.id + if (!id) { + message.warning('参数错误,回款不能为空!') + close() + return + } + receivableId.value = id + await getReceivable(receivableId.value) +}) +</script> diff --git a/src/views/crm/receivable/index.vue b/src/views/crm/receivable/index.vue new file mode 100644 index 0000000..6928942 --- /dev/null +++ b/src/views/crm/receivable/index.vue @@ -0,0 +1,335 @@ +<template> + <doc-alert title="【回款】回款管理、回款计划" url="https://doc.iocoder.cn/crm/receivable/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="回款编号" prop="no"> + <el-input + v-model="queryParams.no" + class="!w-240px" + clearable + placeholder="请输入回款编号" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="客户名称" prop="customerId"> + <el-select + v-model="queryParams.customerId" + class="!w-240px" + placeholder="请选择客户" + @keyup.enter="handleQuery" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['crm:receivable:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button + v-hasPermi="['crm:receivable:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-tabs v-model="activeName" @tab-click="handleTabClick"> + <el-tab-pane label="我负责的" name="1" /> + <el-tab-pane label="我参与的" name="2" /> + <el-tab-pane label="下属负责的" name="3" /> + </el-tabs> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" fixed="left" label="回款编号" prop="no" width="180"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.no }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="客户名称" prop="customerName" width="120"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="合同编号" prop="contractNo" width="180"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openContractDetail(scope.row.contractId)" + > + {{ scope.row.contract.no }} + </el-link> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter2" + align="center" + label="回款日期" + prop="returnTime" + width="150px" + /> + <el-table-column + align="center" + label="回款金额(元)" + prop="price" + width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column align="center" label="回款方式" prop="returnType" width="130px"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE" :value="scope.row.returnType" /> + </template> + </el-table-column> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column + align="center" + label="合同金额(元)" + prop="contract.totalPrice" + width="140" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="120" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="120" /> + <el-table-column align="center" fixed="right" label="回款状态" prop="auditStatus" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_AUDIT_STATUS" :value="scope.row.auditStatus" /> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="180px"> + <template #default="scope"> + <el-button + v-hasPermi="['crm:receivable:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-if="scope.row.auditStatus === 0" + v-hasPermi="['crm:receivable:update']" + link + type="primary" + @click="handleSubmit(scope.row)" + > + 提交审核 + </el-button> + <el-button + v-else + v-hasPermi="['crm:receivable:update']" + link + type="primary" + @click="handleProcessDetail(scope.row)" + > + 查看审批 + </el-button> + <el-button + v-hasPermi="['crm:receivable:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ReceivableForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter, dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import * as ReceivableApi from '@/api/crm/receivable' +import ReceivableForm from './ReceivableForm.vue' +import * as CustomerApi from '@/api/crm/customer' +import { TabsPaneContext } from 'element-plus' +import { erpPriceTableColumnFormatter } from '@/utils' + +defineOptions({ name: 'Receivable' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + sceneType: '1', // 默认和 activeName 相等 + no: undefined, + customerId: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const activeName = ref('1') // 列表 tab +const customerList = ref<CustomerApi.CustomerVO[]>([]) // 客户列表 + +/** tab 切换 */ +const handleTabClick = (tab: TabsPaneContext) => { + queryParams.sceneType = tab.paneName + handleQuery() +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ReceivableApi.getReceivablePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ReceivableApi.deleteReceivable(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 提交审核 **/ +const handleSubmit = async (row: ReceivableApi.ReceivableVO) => { + await message.confirm(`您确定提交编号为【${row.no}】的回款审核吗?`) + await ReceivableApi.submitReceivable(row.id) + message.success('提交审核成功!') + await getList() +} + +/** 查看审批 */ +const handleProcessDetail = (row: ReceivableApi.ReceivableVO) => { + push({ name: 'BpmProcessInstanceDetail', query: { id: row.processInstanceId } }) +} + +/** 打开回款详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmReceivableDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 打开合同详情 */ +const openContractDetail = (id: number) => { + push({ name: 'CrmContractDetail', params: { id } }) +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ReceivableApi.exportReceivable(queryParams) + download.excel(data, '回款.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 获得客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() +}) +</script> diff --git a/src/views/crm/receivable/plan/ReceivablePlanForm.vue b/src/views/crm/receivable/plan/ReceivablePlanForm.vue new file mode 100644 index 0000000..0d4ef17 --- /dev/null +++ b/src/views/crm/receivable/plan/ReceivablePlanForm.vue @@ -0,0 +1,239 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="110px" + > + <el-row> + <el-col :span="12"> + <el-form-item label="还款期数" prop="period"> + <el-input v-model="formData.period" disabled placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="负责人" prop="ownerUserId"> + <el-select + v-model="formData.ownerUserId" + :disabled="formType !== 'create'" + class="w-1/1" + > + <el-option + v-for="item in userOptions" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="客户名称" prop="customerId"> + <el-select + v-model="formData.customerId" + :disabled="formType !== 'create'" + class="w-1/1" + filterable + placeholder="请选择客户" + @change="handleCustomerChange" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="合同名称" prop="contractId"> + <el-select + v-model="formData.contractId" + :disabled="formType !== 'create' || !formData.customerId" + class="w-1/1" + filterable + placeholder="请选择合同" + > + <el-option + v-for="data in contractList" + :key="data.id" + :label="data.name" + :value="data.id!" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="计划回款金额" prop="price"> + <el-input-number + v-model="formData.price" + :min="0.01" + :precision="2" + class="!w-100%" + controls-position="right" + placeholder="请输入计划回款金额" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="计划回款日期" prop="returnTime"> + <el-date-picker + v-model="formData.returnTime" + placeholder="选择计划回款日期" + type="date" + value-format="x" + /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="提前几天提醒" prop="remindDays"> + <el-input-number + v-model="formData.remindDays" + class="!w-100%" + controls-position="right" + placeholder="请输入提前几天提醒" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="回款方式" prop="returnType"> + <el-select v-model="formData.returnType" class="w-1/1" placeholder="请选择回款方式"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as ReceivablePlanApi from '@/api/crm/receivable/plan' +import * as UserApi from '@/api/system/user' +import * as CustomerApi from '@/api/crm/customer' +import * as ContractApi from '@/api/crm/contract' +import { useUserStore } from '@/store/modules/user' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { cloneDeep } from 'lodash-es' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表 +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref<ReceivablePlanApi.ReceivablePlanVO>({} as ReceivablePlanApi.ReceivablePlanVO) +const formRules = reactive({ + price: [{ required: true, message: '计划回款金额不能为空', trigger: 'blur' }], + returnTime: [{ required: true, message: '计划回款日期不能为空', trigger: 'blur' }], + customerId: [{ required: true, message: '客户编号不能为空', trigger: 'blur' }], + contractId: [{ required: true, message: '合同编号不能为空', trigger: 'blur' }], + ownerUserId: [{ required: true, message: '负责人不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const customerList = ref<CustomerApi.CustomerVO[]>([]) // 客户列表 +const contractList = ref<ContractApi.ContractVO[]>([]) // 合同列表 + +/** 打开弹窗 */ +const open = async (type: string, id?: number, customerId?: number, contractId?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = await ReceivablePlanApi.getReceivablePlan(id) + formData.value = cloneDeep(data) + await handleCustomerChange(data.customerId!) + formData.value.contractId = data?.contractId + } finally { + formLoading.value = false + } + } + // 获得用户列表 + userOptions.value = await UserApi.getSimpleUserList() + // 获得客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() + // 默认新建时选中自己 + if (formType.value === 'create') { + formData.value.ownerUserId = useUserStore().getUser.id + } + // 设置 customerId 和 contractId 默认值 + if (customerId) { + formData.value.customerId = customerId + await handleCustomerChange(customerId) + } + if (contractId) { + formData.value.contractId = contractId + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ReceivablePlanApi.ReceivablePlanVO + if (formType.value === 'create') { + await ReceivablePlanApi.createReceivablePlan(data) + message.success(t('common.createSuccess')) + } else { + await ReceivablePlanApi.updateReceivablePlan(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = {} as ReceivablePlanApi.ReceivablePlanVO + formRef.value?.resetFields() +} + +/** 处理切换客户 */ +const handleCustomerChange = async (customerId: number) => { + // 重置合同编号 + formData.value.contractId = undefined + // 获得合同列表 + if (customerId) { + contractList.value = [] + contractList.value = await ContractApi.getContractSimpleList(customerId) + } +} +</script> diff --git a/src/views/crm/receivable/plan/components/ReceivablePlanList.vue b/src/views/crm/receivable/plan/components/ReceivablePlanList.vue new file mode 100644 index 0000000..3b80526 --- /dev/null +++ b/src/views/crm/receivable/plan/components/ReceivablePlanList.vue @@ -0,0 +1,173 @@ +<template> + <!-- 操作栏 --> + <el-row justify="end"> + <el-button @click="openForm('create', undefined)"> + <Icon class="mr-5px" icon="icon-park:income" /> + 创建回款计划 + </el-button> + </el-row> + + <!-- 列表 --> + <ContentWrap class="mt-10px"> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="客户名称" prop="customerName" width="150px" /> + <el-table-column align="center" label="合同编号" prop="contractNo" width="200px" /> + <el-table-column align="center" label="期数" prop="period" /> + <el-table-column + align="center" + label="计划回款(元)" + prop="price" + width="120" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + :formatter="dateFormatter2" + align="center" + label="计划回款日期" + prop="returnTime" + width="180px" + /> + <el-table-column align="center" label="提前几天提醒" prop="remindDays" width="150" /> + <el-table-column + :formatter="dateFormatter2" + align="center" + label="提醒日期" + prop="remindTime" + width="180px" + /> + <el-table-column label="负责人" prop="ownerUserName" width="120" /> + <el-table-column align="center" label="备注" prop="remark" /> + <el-table-column align="center" fixed="right" label="操作" width="200px"> + <template #default="scope"> + <el-button + v-hasPermi="['crm:receivable:create']" + link + type="primary" + @click="createReceivable(scope.row)" + :disabled="scope.row.receivableId" + > + 创建回款 + </el-button> + <el-button + v-hasPermi="['crm:receivable-plan:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['crm:receivable-plan:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加 --> + <ReceivableForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import * as ReceivablePlanApi from '@/api/crm/receivable/plan' +import ReceivableForm from './../ReceivablePlanForm.vue' +import { dateFormatter2 } from '@/utils/formatTime' +import { erpPriceTableColumnFormatter } from '@/utils' + +defineOptions({ name: 'CrmReceivablePlanList' }) +const props = defineProps<{ + customerId?: number // 客户编号 + contractId?: number // 合同编号 +}>() + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + customerId: undefined as unknown, // 允许 undefined + number + contractId: undefined as unknown // 允许 undefined + number +}) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + if (props.customerId && !props.contractId) { + queryParams.customerId = props.customerId + } else if (props.customerId && props.contractId) { + // 如果是合同的话客户编号也需要带上因为权限基于客户 + queryParams.customerId = props.customerId + queryParams.contractId = props.contractId + } + const data = await ReceivablePlanApi.getReceivablePlanPageByCustomer(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + // 置空参数 + queryParams.customerId = undefined + queryParams.contractId = undefined + getList() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id, props.customerId, props.contractId) +} + +/** 创建回款 */ +const emits = defineEmits<{ + (e: 'createReceivable', v: ReceivablePlanApi.ReceivablePlanVO) +}>() +const createReceivable = (row: ReceivablePlanApi.ReceivablePlanVO) => { + emits('createReceivable', row) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ReceivablePlanApi.deleteReceivablePlan(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 监听打开的 customerId + contractId,从而加载最新的列表 */ +watch( + () => [props.customerId, props.contractId], + (newVal) => { + // 保证至少客户编号有值 + if (!newVal[0]) { + return + } + handleQuery() + }, + { immediate: true, deep: true } +) +</script> diff --git a/src/views/crm/receivable/plan/detail/ReceivablePlanDetailsHeader.vue b/src/views/crm/receivable/plan/detail/ReceivablePlanDetailsHeader.vue new file mode 100644 index 0000000..b0e0044 --- /dev/null +++ b/src/views/crm/receivable/plan/detail/ReceivablePlanDetailsHeader.vue @@ -0,0 +1,44 @@ +<template> + <div> + <div class="flex items-start justify-between"> + <div> + <el-col> + <el-row> + <span class="text-xl font-bold">第 {{ receivablePlan.period }} 期</span> + </el-row> + </el-col> + </div> + <div> + <!-- 右上:按钮 --> + <slot></slot> + </div> + </div> + </div> + <ContentWrap class="mt-10px"> + <el-descriptions :column="5" direction="vertical"> + <el-descriptions-item label="客户名称"> + {{ receivablePlan.customerName }} + </el-descriptions-item> + <el-descriptions-item label="合同编号">{{ receivablePlan.contractNo }}</el-descriptions-item> + <el-descriptions-item label="计划回款金额"> + {{ erpPriceInputFormatter(receivablePlan.price) }} + </el-descriptions-item> + <el-descriptions-item label="计划回款日期"> + {{ formatDate(receivablePlan.returnTime) }} + </el-descriptions-item> + <el-descriptions-item label="实际回款金额"> + <el-text v-if="receivablePlan.receivable"> + {{ erpPriceInputFormatter(receivablePlan.receivable.price) }} + </el-text> + <el-text v-else>{{ erpPriceInputFormatter(0) }}</el-text> + </el-descriptions-item> + </el-descriptions> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ReceivablePlanApi from '@/api/crm/receivable/plan' +import { formatDate } from '@/utils/formatTime' +import { erpPriceInputFormatter } from '@/utils' + +const { receivablePlan } = defineProps<{ receivablePlan: ReceivablePlanApi.ReceivablePlanVO }>() +</script> diff --git a/src/views/crm/receivable/plan/detail/ReceivablePlanDetailsInfo.vue b/src/views/crm/receivable/plan/detail/ReceivablePlanDetailsInfo.vue new file mode 100644 index 0000000..c25259b --- /dev/null +++ b/src/views/crm/receivable/plan/detail/ReceivablePlanDetailsInfo.vue @@ -0,0 +1,83 @@ +<template> + <ContentWrap> + <el-collapse v-model="activeNames"> + <el-collapse-item name="basicInfo"> + <template #title> + <span class="text-base font-bold">基本信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="期数">{{ receivablePlan.period }}</el-descriptions-item> + <el-descriptions-item label="客户名称"> + {{ receivablePlan.customerName }} + </el-descriptions-item> + <el-descriptions-item label="合同编号"> + {{ receivablePlan.contractNo }} + </el-descriptions-item> + <el-descriptions-item label="计划回款金额"> + {{ erpPriceInputFormatter(receivablePlan.price) }} + </el-descriptions-item> + <el-descriptions-item label="计划回款日期"> + {{ formatDate(receivablePlan.returnTime, 'YYYY-MM-DD') }} + </el-descriptions-item> + <el-descriptions-item label="计划回款方式"> + <dict-tag + :type="DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE" + :value="receivablePlan.returnType" + /> + </el-descriptions-item> + <el-descriptions-item label="提前几天提醒"> + {{ receivablePlan.remindDays }} + </el-descriptions-item> + <el-descriptions-item label="备注">{{ receivablePlan.remark }}</el-descriptions-item> + <el-descriptions-item label="实际回款金额"> + <el-text v-if="receivablePlan.receivable"> + {{ erpPriceInputFormatter(receivablePlan.receivable.price) }} + </el-text> + <el-text v-else>{{ erpPriceInputFormatter(0) }}</el-text> + </el-descriptions-item> + <el-descriptions-item label="未回款金额"> + <el-text v-if="receivablePlan.receivable"> + {{ erpPriceInputFormatter(receivablePlan.price - receivablePlan.receivable.price) }} + </el-text> + <el-text v-else>{{ erpPriceInputFormatter(receivablePlan.price) }}</el-text> + </el-descriptions-item> + <el-descriptions-item label="实际回款日期"> + {{ formatDate(receivablePlan.receivable?.returnTime, 'YYYY-MM-DD') }} + </el-descriptions-item> + </el-descriptions> + </el-collapse-item> + <el-collapse-item name="systemInfo"> + <template #title> + <span class="text-base font-bold">系统信息</span> + </template> + <el-descriptions :column="4"> + <el-descriptions-item label="负责人"> + {{ receivablePlan.ownerUserName }} + </el-descriptions-item> + <el-descriptions-item label="创建人"> + {{ receivablePlan.creatorName }} + </el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(receivablePlan.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="更新时间"> + {{ formatDate(receivablePlan.updateTime) }} + </el-descriptions-item> + </el-descriptions> + </el-collapse-item> + </el-collapse> + </ContentWrap> +</template> +<script setup lang="ts"> +import * as ReceivablePlanApi from '@/api/crm/receivable/plan' +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import { erpPriceInputFormatter } from '@/utils' + +const { receivablePlan } = defineProps<{ + receivablePlan: ReceivablePlanApi.ReceivablePlanVO +}>() + +// 展示的折叠面板 +const activeNames = ref(['basicInfo', 'systemInfo']) +</script> diff --git a/src/views/crm/receivable/plan/detail/index.vue b/src/views/crm/receivable/plan/detail/index.vue new file mode 100644 index 0000000..fba8694 --- /dev/null +++ b/src/views/crm/receivable/plan/detail/index.vue @@ -0,0 +1,103 @@ +<template> + <ReceivablePlanDetailsHeader v-loading="loading" :receivable-plan="receivablePlan"> + <el-button + v-if="permissionListRef?.validateWrite" + @click="openForm('update', receivablePlan.id)" + > + 编辑 + </el-button> + </ReceivablePlanDetailsHeader> + <el-col> + <el-tabs> + <el-tab-pane label="详细资料"> + <ReceivablePlanDetailsInfo :receivable-plan="receivablePlan" /> + </el-tab-pane> + <el-tab-pane label="操作日志"> + <OperateLogV2 :log-list="logList" /> + </el-tab-pane> + <el-tab-pane label="团队成员"> + <PermissionList + ref="permissionListRef" + :biz-id="receivablePlan.id!" + :biz-type="BizTypeEnum.CRM_RECEIVABLE_PLAN" + :show-action="true" + @quit-team="close" + /> + </el-tab-pane> + </el-tabs> + </el-col> + + <!-- 表单弹窗:添加/修改 --> + <ReceivablePlanForm ref="formRef" @success="getReceivablePlan(receivablePlan.id)" /> +</template> +<script lang="ts" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import * as ReceivablePlanApi from '@/api/crm/receivable/plan' +import ReceivablePlanDetailsHeader from './ReceivablePlanDetailsHeader.vue' +import ReceivablePlanDetailsInfo from './ReceivablePlanDetailsInfo.vue' +import PermissionList from '@/views/crm/permission/components/PermissionList.vue' // 团队成员列表(权限) +import { BizTypeEnum } from '@/api/crm/permission' +import { OperateLogVO } from '@/api/system/operatelog' +import { getOperateLogPage } from '@/api/crm/operateLog' +import ReceivablePlanForm from '@/views/crm/receivable/plan/ReceivablePlanForm.vue' + +defineOptions({ name: 'CrmReceivablePlanDetail' }) + +const message = useMessage() + +const receivablePlanId = ref(0) // 回款计划编号 +const loading = ref(true) // 加载中 +const receivablePlan = ref<ReceivablePlanApi.ReceivablePlanVO>( + {} as ReceivablePlanApi.ReceivablePlanVO +) // 回款计划详情 +const permissionListRef = ref<InstanceType<typeof PermissionList>>() // 团队成员列表 Ref + +/** 获取详情 */ +const getReceivablePlan = async (id: number) => { + loading.value = true + try { + receivablePlan.value = await ReceivablePlanApi.getReceivablePlan(id) + await getOperateLog(id) + } finally { + loading.value = false + } +} + +/** 编辑 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 获取操作日志 */ +const logList = ref<OperateLogVO[]>([]) // 操作日志列表 +const getOperateLog = async (receivablePlanId: number) => { + if (!receivablePlanId) { + return + } + const data = await getOperateLogPage({ + bizType: BizTypeEnum.CRM_RECEIVABLE_PLAN, + bizId: receivablePlanId + }) + logList.value = data.list +} + +/** 关闭窗口 */ +const { delView } = useTagsViewStore() // 视图操作 +const { currentRoute } = useRouter() // 路由 +const close = () => { + delView(unref(currentRoute)) +} + +/** 初始化 */ +const { params } = useRoute() +onMounted(async () => { + if (!params.id) { + message.warning('参数错误,回款计划不能为空!') + close() + return + } + receivablePlanId.value = params.id as unknown as number + await getReceivablePlan(receivablePlanId.value) +}) +</script> diff --git a/src/views/crm/receivable/plan/index.vue b/src/views/crm/receivable/plan/index.vue new file mode 100644 index 0000000..43abe15 --- /dev/null +++ b/src/views/crm/receivable/plan/index.vue @@ -0,0 +1,335 @@ +<template> + <doc-alert title="【回款】回款管理、回款计划" url="https://doc.iocoder.cn/crm/receivable/" /> + <doc-alert title="【通用】数据权限" url="https://doc.iocoder.cn/crm/permission/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="客户名称" prop="customerId"> + <el-select + v-model="queryParams.customerId" + class="!w-240px" + placeholder="请选择客户" + @keyup.enter="handleQuery" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="合同编号" prop="contractNo"> + <el-input + v-model="queryParams.contractNo" + class="!w-240px" + clearable + placeholder="请输入合同编号" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['crm:receivable-plan:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button + v-hasPermi="['crm:receivable-plan:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-tabs v-model="activeName" @tab-click="handleTabClick"> + <el-tab-pane label="我负责的" name="1" /> + <el-tab-pane label="下属负责的" name="3" /> + </el-tabs> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" fixed="left" label="客户名称" prop="customerName" width="150"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" label="合同编号" prop="contractNo" width="200px" /> + <el-table-column align="center" label="期数" prop="period"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.period }} + </el-link> + </template> + </el-table-column> + <el-table-column + align="center" + label="计划回款金额(元)" + prop="price" + width="160" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + :formatter="dateFormatter2" + align="center" + label="计划回款日期" + prop="returnTime" + width="180px" + /> + <el-table-column align="center" label="提前几天提醒" prop="remindDays" width="150" /> + <el-table-column + align="center" + label="提醒日期" + prop="remindTime" + width="180px" + :formatter="dateFormatter2" + /> + <el-table-column align="center" label="回款方式" prop="returnType" width="130px"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_RECEIVABLE_RETURN_TYPE" :value="scope.row.returnType" /> + </template> + </el-table-column> + <el-table-column align="center" label="备注" prop="remark" /> + <el-table-column label="负责人" prop="ownerUserName" width="120" /> + <el-table-column + align="center" + label="实际回款金额(元)" + prop="receivable.price" + width="160" + > + <template #default="scope"> + <el-text v-if="scope.row.receivable"> + {{ erpPriceInputFormatter(scope.row.receivable.price) }} + </el-text> + <el-text v-else>{{ erpPriceInputFormatter(0) }}</el-text> + </template> + </el-table-column> + <el-table-column + align="center" + label="实际回款日期" + prop="receivable.returnTime" + width="180px" + :formatter="dateFormatter2" + /> + <el-table-column + align="center" + label="实际回款金额(元)" + prop="receivable.price" + width="160" + > + <template #default="scope"> + <el-text v-if="scope.row.receivable"> + {{ erpPriceInputFormatter(scope.row.price - scope.row.receivable.price) }} + </el-text> + <el-text v-else>{{ erpPriceInputFormatter(scope.row.price) }}</el-text> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + <el-table-column align="center" fixed="right" label="操作" width="180px"> + <template #default="scope"> + <el-button + v-hasPermi="['crm:receivable:create']" + link + type="success" + @click="openReceivableForm(scope.row)" + :disabled="scope.row.receivableId" + > + 创建回款 + </el-button> + <el-button + v-hasPermi="['crm:receivable-plan:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['crm:receivable-plan:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ReceivablePlanForm ref="formRef" @success="getList" /> + <ReceivableForm ref="receivableFormRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter, dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import * as ReceivablePlanApi from '@/api/crm/receivable/plan' +import ReceivablePlanForm from './ReceivablePlanForm.vue' +import * as CustomerApi from '@/api/crm/customer' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { TabsPaneContext } from 'element-plus' +import ReceivableForm from '@/views/crm/receivable/ReceivableForm.vue' + +defineOptions({ name: 'ReceivablePlan' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + sceneType: '1', // 默认和 activeName 相等 + customerId: undefined, + contractNo: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const activeName = ref('1') // 列表 tab +const customerList = ref<CustomerApi.CustomerVO[]>([]) // 客户列表 + +/** tab 切换 */ +const handleTabClick = (tab: TabsPaneContext) => { + queryParams.sceneType = tab.paneName + handleQuery() +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ReceivablePlanApi.getReceivablePlanPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 创建回款操作 */ +const receivableFormRef = ref() +const openReceivableForm = (row: ReceivablePlanApi.ReceivablePlanVO) => { + receivableFormRef.value.open('create', undefined, row) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ReceivablePlanApi.deleteReceivablePlan(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ReceivablePlanApi.exportReceivablePlan(queryParams) + download.excel(data, '回款计划.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 打开详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmReceivablePlanDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 获得客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() +}) +</script> diff --git a/src/views/crm/statistics/customer/components/CustomerConversionStat.vue b/src/views/crm/statistics/customer/components/CustomerConversionStat.vue new file mode 100644 index 0000000..4f5c50c --- /dev/null +++ b/src/views/crm/statistics/customer/components/CustomerConversionStat.vue @@ -0,0 +1,170 @@ +<!-- 客户转化率分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="序号" align="center" type="index" width="80" fixed="left" /> + <el-table-column + label="客户名称" + align="center" + prop="customerName" + min-width="200" + fixed="left" + /> + <el-table-column label="合同名称" align="center" prop="contractName" min-width="200" /> + <el-table-column + label="合同总金额" + align="center" + prop="totalPrice" + min-width="200" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="回款金额" + align="center" + prop="receivablePrice" + min-width="200" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column align="center" label="客户来源" prop="source" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户行业" prop="industryId" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" /> + </template> + </el-table-column> + <el-table-column label="负责人" align="center" prop="ownerUserName" min-width="200" /> + <el-table-column label="创建人" align="center" prop="creatorUserName" min-width="200" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + min-width="200" + /> + <el-table-column + label="下单日期" + align="center" + prop="orderDate" + :formatter="dateFormatter" + min-width="200" + fixed="right" + /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { + StatisticsCustomerApi, + CrmStatisticsCustomerSummaryByDateRespVO +} from '@/api/crm/statistics/customer' +import { EChartsOption } from 'echarts' +import { dateFormatter } from '@/utils/formatTime' +import { erpPriceTableColumnFormatter } from '@/utils' +import { DICT_TYPE } from '@/utils/dict' + +defineOptions({ name: 'CustomerConversionStat' }) + +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticsCustomerSummaryByDateRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 20, + right: 40, // 让 X 轴右侧显示完整 + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '客户转化率', + type: 'line', + data: [] + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '客户转化率分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: { + type: 'value', + name: '转化率(%)' + }, + xAxis: { + type: 'category', + name: '日期', + data: [] + } +}) as EChartsOption + +/** 获取数据并填充图表 */ +const fetchAndFill = async () => { + // 1. 加载统计数据 + const customerCount = await StatisticsCustomerApi.getCustomerSummaryByDate(props.queryParams) + const contractSummary = await StatisticsCustomerApi.getContractSummary(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = customerCount.map( + (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.time + ) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = customerCount.map( + (item: CrmStatisticsCustomerSummaryByDateRespVO) => { + return { + name: item.time, + value: item.customerCreateCount + ? ((item.customerDealCount / item.customerCreateCount) * 100).toFixed(2) + : 0 + } + } + ) + } + // 2.2 更新列表数据 + list.value = contractSummary +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + try { + await fetchAndFill() + } finally { + loading.value = false + } +} + +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/customer/components/CustomerDealCycleByArea.vue b/src/views/crm/statistics/customer/components/CustomerDealCycleByArea.vue new file mode 100644 index 0000000..9aa6d5e --- /dev/null +++ b/src/views/crm/statistics/customer/components/CustomerDealCycleByArea.vue @@ -0,0 +1,153 @@ +<!-- 成交周期分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="序号" align="center" type="index" width="80" /> + <el-table-column label="区域" align="center" prop="areaName" min-width="200" /> + <el-table-column + label="成交周期(天)" + align="center" + prop="customerDealCycle" + min-width="200" + /> + <el-table-column label="成交客户数" align="center" prop="customerDealCount" min-width="200" /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { + StatisticsCustomerApi, + CrmStatisticsCustomerDealCycleByAreaRespVO +} from '@/api/crm/statistics/customer' +import { EChartsOption } from 'echarts' + +defineOptions({ name: 'CustomerDealCycleByArea' }) + +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticsCustomerDealCycleByAreaRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 20, + right: 40, // 让 X 轴右侧显示完整 + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '成交周期(天)', + type: 'bar', + data: [], + yAxisIndex: 0 + }, + { + name: '成交客户数', + type: 'bar', + data: [], + yAxisIndex: 1 + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '成交周期分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: [ + { + type: 'value', + name: '成交周期(天)', + min: 0, + minInterval: 1 // 显示整数刻度 + }, + { + type: 'value', + name: '成交客户数', + min: 0, + minInterval: 1, // 显示整数刻度 + splitLine: { + lineStyle: { + type: 'dotted', // 右侧网格线虚化, 减少混乱 + opacity: 0.7 + } + } + } + ], + xAxis: { + type: 'category', + name: '区域', + data: [] + } +}) as EChartsOption + +/** 获取数据并填充图表 */ +const fetchAndFill = async () => { + // 1. 加载统计数据 + const customerDealCycleByArea = ( + await StatisticsCustomerApi.getCustomerDealCycleByArea(props.queryParams) + ).map((s: CrmStatisticsCustomerDealCycleByAreaRespVO) => { + return { + areaName: s.areaName, + customerDealCycle: s.customerDealCycle, + customerDealCount: s.customerDealCount + } + }) + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = customerDealCycleByArea.map( + (s: CrmStatisticsCustomerDealCycleByAreaRespVO) => s.areaName + ) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = customerDealCycleByArea.map( + (s: CrmStatisticsCustomerDealCycleByAreaRespVO) => s.customerDealCycle + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = customerDealCycleByArea.map( + (s: CrmStatisticsCustomerDealCycleByAreaRespVO) => s.customerDealCount + ) + } + // 2.2 更新列表数据 + list.value = customerDealCycleByArea +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + try { + await fetchAndFill() + } finally { + loading.value = false + } +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/customer/components/CustomerDealCycleByProduct.vue b/src/views/crm/statistics/customer/components/CustomerDealCycleByProduct.vue new file mode 100644 index 0000000..74558d1 --- /dev/null +++ b/src/views/crm/statistics/customer/components/CustomerDealCycleByProduct.vue @@ -0,0 +1,153 @@ +<!-- 成交周期分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="序号" align="center" type="index" width="80" /> + <el-table-column label="产品名称" align="center" prop="productName" min-width="200" /> + <el-table-column + label="成交周期(天)" + align="center" + prop="customerDealCycle" + min-width="200" + /> + <el-table-column label="成交客户数" align="center" prop="customerDealCount" min-width="200" /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { + StatisticsCustomerApi, + CrmStatisticsCustomerDealCycleByProductRespVO +} from '@/api/crm/statistics/customer' +import { EChartsOption } from 'echarts' + +defineOptions({ name: 'CustomerDealCycleByProduct' }) + +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticsCustomerDealCycleByProductRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 20, + right: 40, // 让 X 轴右侧显示完整 + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '成交周期(天)', + type: 'bar', + data: [], + yAxisIndex: 0 + }, + { + name: '成交客户数', + type: 'bar', + data: [], + yAxisIndex: 1 + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '成交周期分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: [ + { + type: 'value', + name: '成交周期(天)', + min: 0, + minInterval: 1 // 显示整数刻度 + }, + { + type: 'value', + name: '成交客户数', + min: 0, + minInterval: 1, // 显示整数刻度 + splitLine: { + lineStyle: { + type: 'dotted', // 右侧网格线虚化, 减少混乱 + opacity: 0.7 + } + } + } + ], + xAxis: { + type: 'category', + name: '产品名称', + data: [] + } +}) as EChartsOption + +/** 获取数据并填充图表 */ +const fetchAndFill = async () => { + // 1. 加载统计数据 + const customerDealCycleByProduct = ( + await StatisticsCustomerApi.getCustomerDealCycleByProduct(props.queryParams) + ).map((s: CrmStatisticsCustomerDealCycleByProductRespVO) => { + return { + productName: s.productName ?? '未知', + customerDealCycle: s.customerDealCount, + customerDealCount: s.customerDealCount + } + }) + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = customerDealCycleByProduct.map( + (s: CrmStatisticsCustomerDealCycleByProductRespVO) => s.productName + ) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = customerDealCycleByProduct.map( + (s: CrmStatisticsCustomerDealCycleByProductRespVO) => s.customerDealCycle + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = customerDealCycleByProduct.map( + (s: CrmStatisticsCustomerDealCycleByProductRespVO) => s.customerDealCount + ) + } + // 2.2 更新列表数据 + list.value = customerDealCycleByProduct +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + try { + await fetchAndFill() + } finally { + loading.value = false + } +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/customer/components/CustomerDealCycleByUser.vue b/src/views/crm/statistics/customer/components/CustomerDealCycleByUser.vue new file mode 100644 index 0000000..e3d877e --- /dev/null +++ b/src/views/crm/statistics/customer/components/CustomerDealCycleByUser.vue @@ -0,0 +1,154 @@ +<!-- 成交周期分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="序号" align="center" type="index" width="80" /> + <el-table-column label="日期" align="center" prop="ownerUserName" min-width="200" /> + <el-table-column + label="成交周期(天)" + align="center" + prop="customerDealCycle" + min-width="200" + /> + <el-table-column label="成交客户数" align="center" prop="customerDealCount" min-width="200" /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { + StatisticsCustomerApi, + CrmStatisticsCustomerDealCycleByDateRespVO, + CrmStatisticsCustomerSummaryByDateRespVO +} from '@/api/crm/statistics/customer' +import { EChartsOption } from 'echarts' + +defineOptions({ name: 'CustomerDealCycleByUser' }) + +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticsCustomerDealCycleByDateRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 20, + right: 40, // 让 X 轴右侧显示完整 + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '成交周期(天)', + type: 'bar', + data: [], + yAxisIndex: 0 + }, + { + name: '成交客户数', + type: 'bar', + data: [], + yAxisIndex: 1 + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '成交周期分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: [ + { + type: 'value', + name: '成交周期(天)', + min: 0, + minInterval: 1 // 显示整数刻度 + }, + { + type: 'value', + name: '成交客户数', + min: 0, + minInterval: 1, // 显示整数刻度 + splitLine: { + lineStyle: { + type: 'dotted', // 右侧网格线虚化, 减少混乱 + opacity: 0.7 + } + } + } + ], + xAxis: { + type: 'category', + name: '日期', + data: [] + } +}) as EChartsOption + +/** 获取数据并填充图表 */ +const fetchAndFill = async () => { + // 1. 加载统计数据 + const customerDealCycleByDate = await StatisticsCustomerApi.getCustomerDealCycleByDate( + props.queryParams + ) + const customerSummaryByDate = await StatisticsCustomerApi.getCustomerSummaryByDate( + props.queryParams + ) + const customerDealCycleByUser = await StatisticsCustomerApi.getCustomerDealCycleByUser( + props.queryParams + ) + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = customerDealCycleByDate.map( + (s: CrmStatisticsCustomerDealCycleByDateRespVO) => s.time + ) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = customerDealCycleByDate.map( + (s: CrmStatisticsCustomerDealCycleByDateRespVO) => s.customerDealCycle + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = customerSummaryByDate.map( + (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.customerDealCount + ) + } + // 2.2 更新列表数据 + list.value = customerDealCycleByUser +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + try { + await fetchAndFill() + } finally { + loading.value = false + } +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/customer/components/CustomerFollowUpSummary.vue b/src/views/crm/statistics/customer/components/CustomerFollowUpSummary.vue new file mode 100644 index 0000000..eeb0ff0 --- /dev/null +++ b/src/views/crm/statistics/customer/components/CustomerFollowUpSummary.vue @@ -0,0 +1,156 @@ +<!-- 客户跟进次数分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="序号" align="center" type="index" width="80" /> + <el-table-column label="员工姓名" align="center" prop="ownerUserName" min-width="200" /> + <el-table-column label="跟进次数" align="right" prop="followUpRecordCount" min-width="200" /> + <el-table-column + label="跟进客户数" + align="right" + prop="followUpCustomerCount" + min-width="200" + /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { + StatisticsCustomerApi, + CrmStatisticsFollowUpSummaryByDateRespVO, + CrmStatisticsFollowUpSummaryByUserRespVO +} from '@/api/crm/statistics/customer' +import Echart from '@/components/Echart/src/Echart.vue' +import { EChartsOption } from 'echarts' + +defineOptions({ name: 'CustomerFollowupSummary' }) + +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticsFollowUpSummaryByUserRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 20, + right: 30, // 让 X 轴右侧显示完整 + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '跟进客户数', + type: 'bar', + yAxisIndex: 0, + data: [] + }, + { + name: '跟进次数', + type: 'bar', + yAxisIndex: 1, + data: [] + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '客户跟进次数分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: [ + { + type: 'value', + name: '跟进客户数', + min: 0, + minInterval: 1 // 显示整数刻度 + }, + { + type: 'value', + name: '跟进次数', + min: 0, + minInterval: 1, // 显示整数刻度 + splitLine: { + lineStyle: { + type: 'dotted', // 右侧网格线虚化, 减少混乱 + opacity: 0.7 + } + } + } + ], + xAxis: { + type: 'category', + name: '日期', + axisTick: { + alignWithLabel: true + }, + data: [] + } +}) as EChartsOption + +/** 获取数据并填充图表 */ +const fetchAndFill = async () => { + // 1. 加载统计数据 + loading.value = true + const followUpSummaryByDate = await StatisticsCustomerApi.getFollowUpSummaryByDate( + props.queryParams + ) + const followUpSummaryByUser = await StatisticsCustomerApi.getFollowUpSummaryByUser( + props.queryParams + ) + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = followUpSummaryByDate.map( + (s: CrmStatisticsFollowUpSummaryByDateRespVO) => s.time + ) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = followUpSummaryByDate.map( + (s: CrmStatisticsFollowUpSummaryByDateRespVO) => s.followUpCustomerCount + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = followUpSummaryByDate.map( + (s: CrmStatisticsFollowUpSummaryByDateRespVO) => s.followUpRecordCount + ) + } + // 2.2 更新列表数据 + list.value = followUpSummaryByUser +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + try { + await fetchAndFill() + } finally { + loading.value = false + } +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/customer/components/CustomerFollowUpType.vue b/src/views/crm/statistics/customer/components/CustomerFollowUpType.vue new file mode 100644 index 0000000..3d8d873 --- /dev/null +++ b/src/views/crm/statistics/customer/components/CustomerFollowUpType.vue @@ -0,0 +1,120 @@ +<!-- 客户跟进方式分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="序号" align="center" type="index" width="80" /> + <el-table-column label="跟进方式" align="center" prop="followUpType" min-width="200"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_FOLLOW_UP_TYPE" :value="scope.row.followUpType" /> + </template> + </el-table-column> + <el-table-column label="个数" align="center" prop="followUpRecordCount" min-width="200" /> + <el-table-column label="占比(%)" align="center" prop="portion" min-width="200" /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { + StatisticsCustomerApi, + CrmStatisticsFollowUpSummaryByTypeRespVO +} from '@/api/crm/statistics/customer' +import { EChartsOption } from 'echarts' +import { sumBy } from 'lodash-es' +import { DICT_TYPE, getDictLabel } from '@/utils/dict' +import { erpCalculatePercentage } from '@/utils' + +defineOptions({ name: 'CustomerFollowupType' }) + +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticsFollowUpSummaryByTypeRespVO[]>([]) // 列表的数据 + +/** 饼图配置 */ +const echartsOption = reactive<EChartsOption>({ + title: { + text: '客户跟进方式分析', + left: 'center' + }, + legend: { + orient: 'vertical', + left: 'left' + }, + tooltip: { + trigger: 'item', + formatter: '{b} : {c}% ' + }, + toolbox: { + feature: { + saveAsImage: { show: true, name: '客户跟进方式分析' } // 保存为图片 + } + }, + series: [ + { + name: '跟进方式', + type: 'pie', + radius: '50%', + data: [], + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)' + } + } + } + ] +}) as EChartsOption + +/** 获取数据并填充图表 */ +const fetchAndFill = async () => { + // 1. 加载统计数据 + const followUpSummaryByType = await StatisticsCustomerApi.getFollowUpSummaryByType( + props.queryParams + ) + // 2.1 更新 Echarts 数据 + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = followUpSummaryByType.map( + (row: CrmStatisticsFollowUpSummaryByTypeRespVO) => { + return { + name: getDictLabel(DICT_TYPE.CRM_FOLLOW_UP_TYPE, row.followUpType), + value: row.followUpRecordCount + } + } + ) + } + // 2.2 更新列表数据 + const totalCount = sumBy(followUpSummaryByType, 'followUpRecordCount') + list.value = followUpSummaryByType.map((row: CrmStatisticsFollowUpSummaryByTypeRespVO) => { + return { + ...row, + portion: erpCalculatePercentage(row.followUpRecordCount, totalCount) + } + }) +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + try { + await fetchAndFill() + } finally { + loading.value = false + } +} + +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/customer/components/CustomerPoolSummary.vue b/src/views/crm/statistics/customer/components/CustomerPoolSummary.vue new file mode 100644 index 0000000..5f0606a --- /dev/null +++ b/src/views/crm/statistics/customer/components/CustomerPoolSummary.vue @@ -0,0 +1,154 @@ +<!-- 客户总量统计 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="序号" align="center" type="index" width="80" fixed="left" /> + <el-table-column label="员工姓名" prop="ownerUserName" min-width="100" fixed="left" /> + <el-table-column + label="进入公海客户数" + align="right" + prop="customerPutCount" + min-width="200" + /> + <el-table-column + label="公海领取客户数" + align="right" + prop="customerTakeCount" + min-width="200" + /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { + StatisticsCustomerApi, + CrmStatisticsPoolSummaryByDateRespVO, + CrmStatisticsPoolSummaryByUserRespVO +} from '@/api/crm/statistics/customer' +import { EChartsOption } from 'echarts' + +defineOptions({ name: 'CustomerPoolSummary' }) + +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticsPoolSummaryByUserRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 20, + right: 40, // 让 X 轴右侧显示完整 + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '进入公海客户数', + type: 'bar', + yAxisIndex: 0, + data: [] + }, + { + name: '公海领取客户数', + type: 'bar', + yAxisIndex: 1, + data: [] + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '公海客户分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: [ + { + type: 'value', + name: '进入公海客户数', + min: 0, + minInterval: 1 // 显示整数刻度 + }, + { + type: 'value', + name: '公海领取客户数', + min: 0, + minInterval: 1, // 显示整数刻度 + splitLine: { + lineStyle: { + type: 'dotted', // 右侧网格线虚化, 减少混乱 + opacity: 0.7 + } + } + } + ], + xAxis: { + type: 'category', + name: '日期', + data: [] + } +}) as EChartsOption + +/** 获取数据并填充图表 */ +const fetchAndFill = async () => { + // 1. 加载统计数据 + const poolSummaryByDate = await StatisticsCustomerApi.getPoolSummaryByDate(props.queryParams) + const poolSummaryByUser = await StatisticsCustomerApi.getPoolSummaryByUser(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = poolSummaryByDate.map( + (s: CrmStatisticsPoolSummaryByDateRespVO) => s.time + ) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = poolSummaryByDate.map( + (s: CrmStatisticsPoolSummaryByDateRespVO) => s.customerPutCount + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = poolSummaryByDate.map( + (s: CrmStatisticsPoolSummaryByDateRespVO) => s.customerTakeCount + ) + } + + // 2.2 更新列表数据 + list.value = poolSummaryByUser +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + try { + await fetchAndFill() + } finally { + loading.value = false + } +} + +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/customer/components/CustomerSummary.vue b/src/views/crm/statistics/customer/components/CustomerSummary.vue new file mode 100644 index 0000000..d1429c2 --- /dev/null +++ b/src/views/crm/statistics/customer/components/CustomerSummary.vue @@ -0,0 +1,183 @@ +<!-- 客户总量统计 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="序号" align="center" type="index" width="80" fixed="left" /> + <el-table-column label="员工姓名" prop="ownerUserName" min-width="100" fixed="left" /> + <el-table-column + label="新增客户数" + align="right" + prop="customerCreateCount" + min-width="200" + /> + <el-table-column label="成交客户数" align="right" prop="customerDealCount" min-width="200" /> + <el-table-column label="客户成交率(%)" align="right" min-width="200"> + <template #default="scope"> + {{ erpCalculatePercentage(scope.row.customerDealCount, scope.row.customerCreateCount) }} + </template> + </el-table-column> + <el-table-column + label="合同总金额" + align="right" + prop="contractPrice" + min-width="200" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="回款金额" + align="right" + prop="receivablePrice" + min-width="200" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="未回款金额" align="right" min-width="200"> + <template #default="scope"> + {{ erpCalculatePercentage(scope.row.receivablePrice, scope.row.contractPrice) }} + </template> + </el-table-column> + <el-table-column label="回款完成率(%)" align="right" min-width="200" fixed="right"> + <template #default="scope"> + {{ erpCalculatePercentage(scope.row.receivablePrice, scope.row.contractPrice) }} + </template> + </el-table-column> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { + StatisticsCustomerApi, + CrmStatisticsCustomerSummaryByDateRespVO, + CrmStatisticsCustomerSummaryByUserRespVO +} from '@/api/crm/statistics/customer' +import { EChartsOption } from 'echarts' +import { erpCalculatePercentage, erpPriceTableColumnFormatter } from '@/utils' + +defineOptions({ name: 'CustomerSummary' }) + +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticsCustomerSummaryByUserRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 20, + right: 30, // 让 X 轴右侧显示完整 + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '新增客户数', + type: 'bar', + yAxisIndex: 0, + data: [] + }, + { + name: '成交客户数', + type: 'bar', + yAxisIndex: 1, + data: [] + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '客户总量分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: [ + { + type: 'value', + name: '新增客户数', + min: 0, + minInterval: 1 // 显示整数刻度 + }, + { + type: 'value', + name: '成交客户数', + min: 0, + minInterval: 1, // 显示整数刻度 + splitLine: { + lineStyle: { + type: 'dotted', // 右侧网格线虚化, 减少混乱 + opacity: 0.7 + } + } + } + ], + xAxis: { + type: 'category', + name: '日期', + data: [] + } +}) as EChartsOption + +/** 获取数据并填充图表 */ +const fetchAndFill = async () => { + // 1. 加载统计数据 + const customerSummaryByDate = await StatisticsCustomerApi.getCustomerSummaryByDate( + props.queryParams + ) + const customerSummaryByUser = await StatisticsCustomerApi.getCustomerSummaryByUser( + props.queryParams + ) + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = customerSummaryByDate.map( + (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.time + ) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = customerSummaryByDate.map( + (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.customerCreateCount + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = customerSummaryByDate.map( + (s: CrmStatisticsCustomerSummaryByDateRespVO) => s.customerDealCount + ) + } + + // 2.2 更新列表数据 + list.value = customerSummaryByUser +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + try { + await fetchAndFill() + } finally { + loading.value = false + } +} + +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/customer/index.vue b/src/views/crm/statistics/customer/index.vue new file mode 100644 index 0000000..207dc35 --- /dev/null +++ b/src/views/crm/statistics/customer/index.vue @@ -0,0 +1,214 @@ +<!-- 数据统计 - 员工客户分析 --> +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="时间范围" prop="orderDate"> + <el-date-picker + v-model="queryParams.times" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + :shortcuts="defaultShortcuts" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + @change="handleQuery" + /> + </el-form-item> + <el-form-item label="时间间隔" prop="interval"> + <el-select + v-model="queryParams.interval" + class="!w-240px" + placeholder="间隔类型" + @change="handleQuery" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.DATE_INTERVAL)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="归属部门" prop="deptId"> + <el-tree-select + v-model="queryParams.deptId" + :data="deptList" + :props="defaultProps" + check-strictly + class="!w-240px" + node-key="id" + placeholder="请选择归属部门" + @change="(queryParams.userId = undefined), handleQuery()" + /> + </el-form-item> + <el-form-item label="员工" prop="userId"> + <el-select + v-model="queryParams.userId" + class="!w-240px" + clearable + placeholder="员工" + @change="handleQuery" + > + <el-option + v-for="(user, index) in userListByDeptId" + :key="index" + :label="user.nickname" + :value="user.id" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 查询 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 客户统计 --> + <el-col> + <el-tabs v-model="activeTab"> + <!-- 客户总量分析 --> + <el-tab-pane label="客户总量分析" lazy name="customerSummary"> + <CustomerSummary ref="customerSummaryRef" :query-params="queryParams" /> + </el-tab-pane> + <!-- 客户跟进次数分析 --> + <el-tab-pane label="客户跟进次数分析" lazy name="followUpSummary"> + <CustomerFollowUpSummary ref="followUpSummaryRef" :query-params="queryParams" /> + </el-tab-pane> + <!-- 客户跟进方式分析 --> + <el-tab-pane label="客户跟进方式分析" lazy name="followUpType"> + <CustomerFollowUpType ref="followUpTypeRef" :query-params="queryParams" /> + </el-tab-pane> + <!-- 客户转化率分析 --> + <el-tab-pane label="客户转化率分析" lazy name="conversionStat"> + <CustomerConversionStat ref="conversionStatRef" :query-params="queryParams" /> + </el-tab-pane> + <!-- 公海客户分析 --> + <el-tab-pane label="公海客户分析" lazy name="poolSummary"> + <CustomerPoolSummary ref="customerPoolSummaryRef" :query-params="queryParams" /> + </el-tab-pane> + <!-- 成交周期分析 --> + <el-tab-pane label="员工客户成交周期分析" lazy name="dealCycleByUser"> + <CustomerDealCycleByUser ref="dealCycleByUserRef" :query-params="queryParams" /> + </el-tab-pane> + <el-tab-pane label="地区客户成交周期分析" lazy name="dealCycleByArea"> + <CustomerDealCycleByArea ref="dealCycleByAreaRef" :query-params="queryParams" /> + </el-tab-pane> + <el-tab-pane label="产品客户成交周期分析" lazy name="dealCycleByProduct"> + <CustomerDealCycleByProduct ref="dealCycleByProductRef" :query-params="queryParams" /> + </el-tab-pane> + </el-tabs> + </el-col> +</template> + +<script lang="ts" setup> +import * as DeptApi from '@/api/system/dept' +import * as UserApi from '@/api/system/user' +import { useUserStore } from '@/store/modules/user' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime' +import { defaultProps, handleTree } from '@/utils/tree' +import CustomerConversionStat from './components/CustomerConversionStat.vue' +import CustomerDealCycleByUser from './components/CustomerDealCycleByUser.vue' +import CustomerDealCycleByArea from './components/CustomerDealCycleByArea.vue' +import CustomerDealCycleByProduct from './components/CustomerDealCycleByProduct.vue' +import CustomerFollowUpSummary from './components/CustomerFollowUpSummary.vue' +import CustomerFollowUpType from './components/CustomerFollowUpType.vue' +import CustomerSummary from './components/CustomerSummary.vue' +import CustomerPoolSummary from './components/CustomerPoolSummary.vue' + +defineOptions({ name: 'CrmStatisticsCustomer' }) + +const queryParams = reactive({ + interval: 2, // WEEK, 周 + deptId: useUserStore().getUser.deptId, + userId: undefined, + times: [ + // 默认显示最近一周的数据 + formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))), + formatDate(endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24))) + ] +}) + +const queryFormRef = ref() // 搜索的表单 +const deptList = ref<Tree[]>([]) // 部门树形结构 +const userList = ref<UserApi.UserVO[]>([]) // 全量用户清单 + +/** 根据选择的部门筛选员工清单 */ +const userListByDeptId = computed(() => + queryParams.deptId + ? userList.value.filter((u: UserApi.UserVO) => u.deptId === queryParams.deptId) + : [] +) + +const activeTab = ref('customerSummary') // 活跃标签 +const customerSummaryRef = ref() // 1. 客户总量分析 +const followUpSummaryRef = ref() // 2. 客户跟进次数分析 +const followUpTypeRef = ref() // 3. 客户跟进方式分析 +const conversionStatRef = ref() // 4. 客户转化率分析 +const customerPoolSummaryRef = ref() // 5. 客户公海分析 +const dealCycleByUserRef = ref() // 6. 成交周期分析(按员工) +const dealCycleByAreaRef = ref() // 7. 成交周期分析(按地区) +const dealCycleByProductRef = ref() // 8. 成交周期分析(按产品) + +/** 搜索按钮操作 */ +const handleQuery = () => { + switch (activeTab.value) { + case 'customerSummary': // 客户总量分析 + customerSummaryRef.value?.loadData?.() + break + case 'followUpSummary': // 客户跟进次数分析 + followUpSummaryRef.value?.loadData?.() + break + case 'followUpType': // 客户跟进方式分析 + followUpTypeRef.value?.loadData?.() + break + case 'conversionStat': // 客户转化率分析 + conversionStatRef.value?.loadData?.() + break + case 'poolSummary': // 公海客户分析 + customerPoolSummaryRef.value?.loadData?.() + break + case 'dealCycleByUser': // 成交周期分析 + dealCycleByUserRef.value?.loadData?.() + break + case 'dealCycleByArea': // 成交周期分析 + dealCycleByAreaRef.value?.loadData?.() + break + case 'dealCycleByProduct': // 成交周期分析 + dealCycleByProductRef.value?.loadData?.() + break + } +} + +/** 当 activeTab 改变时,刷新当前活动的 tab */ +watch(activeTab, () => { + handleQuery() +}) + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 初始化 */ +onMounted(async () => { + deptList.value = handleTree(await DeptApi.getSimpleDeptList()) + userList.value = handleTree(await UserApi.getSimpleUserList()) +}) +</script> diff --git a/src/views/crm/statistics/funnel/components/BusinessInversionRateSummary.vue b/src/views/crm/statistics/funnel/components/BusinessInversionRateSummary.vue new file mode 100644 index 0000000..541d6fc --- /dev/null +++ b/src/views/crm/statistics/funnel/components/BusinessInversionRateSummary.vue @@ -0,0 +1,307 @@ +<!-- 客户总量统计 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card class="mt-16px" shadow="never"> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" fixed="left" label="序号" type="index" width="80" /> + <el-table-column align="center" fixed="left" label="商机名称" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" fixed="left" label="客户名称" prop="customerName" width="120"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column + :formatter="erpPriceTableColumnFormatter" + align="center" + label="商机金额(元)" + prop="totalPrice" + width="140" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="预计成交日期" + prop="dealTime" + width="180px" + /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + <el-table-column + align="center" + fixed="right" + label="商机状态组" + prop="statusTypeName" + width="140" + /> + <el-table-column + align="center" + fixed="right" + label="商机阶段" + prop="statusName" + width="120" + /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams0.pageSize" + v-model:page="queryParams0.pageNo" + :total="total" + @pagination="getList" + /> + </el-card> +</template> +<script lang="ts" setup> +import { + CrmStatisticsBusinessInversionRateSummaryByDateRespVO, + StatisticFunnelApi +} from '@/api/crm/statistics/funnel' +import { EChartsOption } from 'echarts' +import { erpCalculatePercentage, erpPriceTableColumnFormatter } from '@/utils' +import { dateFormatter } from '@/utils/formatTime' + +defineOptions({ name: 'BusinessSummary' }) + +const props = defineProps<{ queryParams: any }>() // 搜索参数 +const queryParams0 = reactive({ + pageNo: 1, + pageSize: 10 +}) +const loading = ref(false) // 加载中 +const list = ref([]) // 列表的数据 +const total = ref(0) +/** 将传进来的值赋值给 queryParams0 */ +watch( + () => props.queryParams, + (data) => { + if (!data) { + return + } + const newObj = { ...queryParams0, ...data } + Object.assign(queryParams0, newObj) + }, + { + immediate: true + } +) +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + color: ['#6ca2ff', '#6ac9d7', '#ff7474'], + tooltip: { + trigger: 'axis', + axisPointer: { + // 坐标轴指示器,坐标轴触发有效 + type: 'shadow' // 默认为直线,可选为:'line' | 'shadow' + } + }, + legend: { + data: ['赢单转化率', '商机总数', '赢单商机数'], + bottom: '0px', + itemWidth: 14 + }, + grid: { + top: '40px', + left: '40px', + right: '40px', + bottom: '40px', + containLabel: true, + borderColor: '#fff' + }, + xAxis: [ + { + type: 'category', + data: [], + axisTick: { + alignWithLabel: true, + lineStyle: { width: 0 } + }, + axisLabel: { + color: '#BDBDBD' + }, + /** 坐标轴轴线相关设置 */ + axisLine: { + lineStyle: { color: '#BDBDBD' } + }, + splitLine: { + show: false + } + } + ], + yAxis: [ + { + type: 'value', + name: '赢单转化率', + axisTick: { + alignWithLabel: true, + lineStyle: { width: 0 } + }, + axisLabel: { + color: '#BDBDBD', + formatter: '{value}%' + }, + /** 坐标轴轴线相关设置 */ + axisLine: { + lineStyle: { color: '#BDBDBD' } + }, + splitLine: { + show: false + } + }, + { + type: 'value', + name: '商机数', + axisTick: { + alignWithLabel: true, + lineStyle: { width: 0 } + }, + axisLabel: { + color: '#BDBDBD', + formatter: '{value}个' + }, + /** 坐标轴轴线相关设置 */ + axisLine: { + lineStyle: { color: '#BDBDBD' } + }, + splitLine: { + show: false + } + } + ], + series: [ + { + name: '赢单转化率', + type: 'line', + yAxisIndex: 0, + data: [] + }, + { + name: '商机总数', + type: 'bar', + yAxisIndex: 1, + barWidth: 15, + data: [] + }, + { + name: '赢单商机数', + type: 'bar', + yAxisIndex: 1, + barWidth: 15, + data: [] + } + ] +}) as EChartsOption + +/** 获取数据并填充图表 */ +const fetchAndFill = async () => { + // 1. 加载统计数据 + const businessSummaryByDate = await StatisticFunnelApi.getBusinessInversionRateSummaryByDate( + props.queryParams + ) + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis[0] && echartsOption.xAxis[0]['data']) { + echartsOption.xAxis[0]['data'] = businessSummaryByDate.map( + (s: CrmStatisticsBusinessInversionRateSummaryByDateRespVO) => s.time + ) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = businessSummaryByDate.map( + (s: CrmStatisticsBusinessInversionRateSummaryByDateRespVO) => + erpCalculatePercentage(s.businessWinCount, s.businessCount) + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = businessSummaryByDate.map( + (s: CrmStatisticsBusinessInversionRateSummaryByDateRespVO) => s.businessCount + ) + } + if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) { + echartsOption.series[2]['data'] = businessSummaryByDate.map( + (s: CrmStatisticsBusinessInversionRateSummaryByDateRespVO) => s.businessWinCount + ) + } + + // 2.2 更新列表数据 + await getList() +} +/** 获取商机列表 */ +const getList = async () => { + const data = await StatisticFunnelApi.getBusinessPageByDate(props.queryParams) + list.value = data.list + total.value = data.total +} +/** 打开客户详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmBusinessDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + try { + await fetchAndFill() + } finally { + loading.value = false + } +} + +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/funnel/components/BusinessSummary.vue b/src/views/crm/statistics/funnel/components/BusinessSummary.vue new file mode 100644 index 0000000..942a712 --- /dev/null +++ b/src/views/crm/statistics/funnel/components/BusinessSummary.vue @@ -0,0 +1,259 @@ +<!-- 客户总量统计 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card class="mt-16px" shadow="never"> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" fixed="left" label="序号" type="index" width="80" /> + <el-table-column align="center" fixed="left" label="商机名称" prop="name" width="160"> + <template #default="scope"> + <el-link :underline="false" type="primary" @click="openDetail(scope.row.id)"> + {{ scope.row.name }} + </el-link> + </template> + </el-table-column> + <el-table-column align="center" fixed="left" label="客户名称" prop="customerName" width="120"> + <template #default="scope"> + <el-link + :underline="false" + type="primary" + @click="openCustomerDetail(scope.row.customerId)" + > + {{ scope.row.customerName }} + </el-link> + </template> + </el-table-column> + <el-table-column + :formatter="erpPriceTableColumnFormatter" + align="center" + label="商机金额(元)" + prop="totalPrice" + width="140" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="预计成交日期" + prop="dealTime" + width="180px" + /> + <el-table-column align="center" label="备注" prop="remark" width="200" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="下次联系时间" + prop="contactNextTime" + width="180px" + /> + <el-table-column align="center" label="负责人" prop="ownerUserName" width="100px" /> + <el-table-column align="center" label="所属部门" prop="ownerUserDeptName" width="100px" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="最后跟进时间" + prop="contactLastTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="创建人" prop="creatorName" width="100px" /> + <el-table-column + align="center" + fixed="right" + label="商机状态组" + prop="statusTypeName" + width="140" + /> + <el-table-column + align="center" + fixed="right" + label="商机阶段" + prop="statusName" + width="120" + /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams0.pageSize" + v-model:page="queryParams0.pageNo" + :total="total" + @pagination="getList" + /> + </el-card> +</template> +<script lang="ts" setup> +import { + CrmStatisticsBusinessSummaryByDateRespVO, + StatisticFunnelApi +} from '@/api/crm/statistics/funnel' +import { EChartsOption } from 'echarts' +import { erpPriceTableColumnFormatter } from '@/utils' +import { dateFormatter } from '@/utils/formatTime' + +defineOptions({ name: 'BusinessSummary' }) + +const props = defineProps<{ queryParams: any }>() // 搜索参数 +const queryParams0 = reactive({ + pageNo: 1, + pageSize: 10 +}) +const loading = ref(false) // 加载中 +const list = ref([]) // 列表的数据 +const total = ref(0) +/** 将传进来的值赋值给 queryParams0 */ +watch( + () => props.queryParams, + (data) => { + if (!data) { + return + } + const newObj = { ...queryParams0, ...data } + Object.assign(queryParams0, newObj) + }, + { + immediate: true + } +) +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 30, + right: 30, // 让 X 轴右侧显示完整 + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '新增商机数量', + type: 'bar', + yAxisIndex: 0, + data: [] + }, + { + name: '新增商机金额', + type: 'bar', + yAxisIndex: 1, + data: [] + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '新增商机分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: [ + { + type: 'value', + name: '新增商机数量', + min: 0, + minInterval: 1 // 显示整数刻度 + }, + { + type: 'value', + name: '新增商机金额', + min: 0, + minInterval: 1, // 显示整数刻度 + splitLine: { + lineStyle: { + type: 'dotted', // 右侧网格线虚化, 减少混乱 + opacity: 0.7 + } + } + } + ], + xAxis: { + type: 'category', + name: '日期', + data: [] + } +}) as EChartsOption + +/** 获取数据并填充图表 */ +const fetchAndFill = async () => { + // 1. 加载统计数据 + const businessSummaryByDate = await StatisticFunnelApi.getBusinessSummaryByDate(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = businessSummaryByDate.map( + (s: CrmStatisticsBusinessSummaryByDateRespVO) => s.time + ) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = businessSummaryByDate.map( + (s: CrmStatisticsBusinessSummaryByDateRespVO) => s.businessCreateCount + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = businessSummaryByDate.map( + (s: CrmStatisticsBusinessSummaryByDateRespVO) => s.totalPrice + ) + } + + // 2.2 更新列表数据 + await getList() +} +/** 获取商机列表 */ +const getList = async () => { + const data = await StatisticFunnelApi.getBusinessPageByDate(props.queryParams) + list.value = data.list + total.value = data.total +} +/** 打开客户详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'CrmBusinessDetail', params: { id } }) +} + +/** 打开客户详情 */ +const openCustomerDetail = (id: number) => { + push({ name: 'CrmCustomerDetail', params: { id } }) +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + try { + await fetchAndFill() + } finally { + loading.value = false + } +} + +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/funnel/components/FunnelBusiness.vue b/src/views/crm/statistics/funnel/components/FunnelBusiness.vue new file mode 100644 index 0000000..c4e4bf6 --- /dev/null +++ b/src/views/crm/statistics/funnel/components/FunnelBusiness.vue @@ -0,0 +1,149 @@ +<!-- 销售漏斗分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-row> + <el-col :span="24"> + <el-button-group class="mb-10px"> + <el-button type="primary" @click="handleActive(true)">客户视角</el-button> + <el-button type="primary" @click="handleActive(false)">动态视角</el-button> + </el-button-group> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-col> + </el-row> + </el-card> + + <!-- 统计列表 --> + <el-card class="mt-16px" shadow="never"> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="序号" type="index" width="80" /> + <el-table-column align="center" label="阶段" prop="endStatus" width="200"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_BUSINESS_END_STATUS_TYPE" :value="scope.row.endStatus" /> + </template> + </el-table-column> + <el-table-column align="center" label="商机数" min-width="200" prop="businessCount" /> + <el-table-column align="center" label="商机总金额(元)" min-width="200" prop="totalPrice" /> + </el-table> + </el-card> +</template> +<script lang="ts" setup> +import { CrmStatisticFunnelRespVO, StatisticFunnelApi } from '@/api/crm/statistics/funnel' +import { EChartsOption } from 'echarts' +import { DICT_TYPE } from '@/utils/dict' + +defineOptions({ name: 'FunnelBusiness' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const active = ref(true) +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticFunnelRespVO[]>([]) // 列表的数据 + +/** 销售漏斗 */ +const echartsOption = reactive<EChartsOption>({ + title: { + text: '销售漏斗' + }, + tooltip: { + trigger: 'item', + formatter: '{a} <br/>{b}' + }, + toolbox: { + feature: { + dataView: { readOnly: false }, + restore: {}, + saveAsImage: {} + } + }, + legend: { + data: ['客户', '商机', '赢单'] + }, + series: [ + { + name: '销售漏斗', + type: 'funnel', + left: '10%', + top: 60, + bottom: 60, + width: '80%', + min: 0, + max: 100, + minSize: '0%', + maxSize: '100%', + sort: 'descending', + gap: 2, + label: { + show: true, + position: 'inside' + }, + labelLine: { + length: 10, + lineStyle: { + width: 1, + type: 'solid' + } + }, + itemStyle: { + borderColor: '#fff', + borderWidth: 1 + }, + emphasis: { + label: { + fontSize: 20 + } + }, + data: [ + { value: 60, name: '客户-0个' }, + { value: 40, name: '商机-0个' }, + { value: 20, name: '赢单-0个' } + ] + } + ] +}) as EChartsOption + +const handleActive = async (val: boolean) => { + active.value = val + await loadData() +} + +/** 获取统计数据 */ +const loadData = async () => { + loading.value = true + // 1. 加载漏斗数据 + const data = (await StatisticFunnelApi.getFunnelSummary( + props.queryParams + )) as CrmStatisticFunnelRespVO + // 2.1 更新 Echarts 数据 + if ( + !!data && + echartsOption.series && + echartsOption.series[0] && + echartsOption.series[0]['data'] + ) { + // tips:写死 value 值是为了保持漏斗顺序不变 + const list: { value: number; name: string }[] = [] + if (active.value) { + list.push({ value: 60, name: `客户-${data.customerCount || 0}个` }) + list.push({ value: 40, name: `商机-${data.businessCount || 0}个` }) + list.push({ value: 20, name: `赢单-${data.businessWinCount || 0}个` }) + } else { + list.push({ value: data.customerCount || 0, name: `客户-${data.customerCount || 0}个` }) + list.push({ value: data.businessCount || 0, name: `商机-${data.businessCount || 0}个` }) + list.push({ value: data.businessWinCount || 0, name: `赢单-${data.businessWinCount || 0}个` }) + } + + echartsOption.series[0]['data'] = list + } + // 2.2 获取商机结束状态统计 + list.value = await StatisticFunnelApi.getBusinessSummaryByEndStatus(props.queryParams) + loading.value = false +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/funnel/index.vue b/src/views/crm/statistics/funnel/index.vue new file mode 100644 index 0000000..804cb49 --- /dev/null +++ b/src/views/crm/statistics/funnel/index.vue @@ -0,0 +1,171 @@ +<!-- 数据统计 - 客户画像 --> +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="时间范围" prop="orderDate"> + <el-date-picker + v-model="queryParams.times" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + :shortcuts="defaultShortcuts" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + @change="handleQuery" + /> + </el-form-item> + <el-form-item label="时间间隔" prop="interval"> + <el-select + v-model="queryParams.interval" + class="!w-240px" + placeholder="间隔类型" + @change="handleQuery" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.DATE_INTERVAL)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="归属部门" prop="deptId"> + <el-tree-select + v-model="queryParams.deptId" + :data="deptList" + :props="defaultProps" + check-strictly + class="!w-240px" + node-key="id" + placeholder="请选择归属部门" + @change="(queryParams.userId = undefined), handleQuery()" + /> + </el-form-item> + <el-form-item label="员工" prop="userId"> + <el-select + v-model="queryParams.userId" + class="!w-240px" + clearable + placeholder="员工" + @change="handleQuery" + > + <el-option + v-for="(user, index) in userListByDeptId" + :key="index" + :label="user.nickname" + :value="user.id" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 查询 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 客户统计 --> + <el-col> + <el-tabs v-model="activeTab"> + <el-tab-pane label="销售漏斗分析" lazy name="funnelRef"> + <FunnelBusiness ref="funnelRef" :query-params="queryParams" /> + </el-tab-pane> + <el-tab-pane label="新增商机分析" lazy name="businessSummaryRef"> + <BusinessSummary ref="businessSummaryRef" :query-params="queryParams" /> + </el-tab-pane> + <el-tab-pane label="商机转化率分析" lazy name="businessInversionRateSummaryRef"> + <BusinessInversionRateSummary + ref="businessInversionRateSummaryRef" + :query-params="queryParams" + /> + </el-tab-pane> + </el-tabs> + </el-col> +</template> + +<script lang="ts" setup> +import * as DeptApi from '@/api/system/dept' +import * as UserApi from '@/api/system/user' +import { useUserStore } from '@/store/modules/user' +import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime' +import { defaultProps, handleTree } from '@/utils/tree' +import FunnelBusiness from './components/FunnelBusiness.vue' +import BusinessSummary from './components/BusinessSummary.vue' +import BusinessInversionRateSummary from './components/BusinessInversionRateSummary.vue' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' + +defineOptions({ name: 'CrmStatisticsFunnel' }) + +const queryParams = reactive({ + interval: 2, // WEEK, 周 + deptId: useUserStore().getUser.deptId, + userId: undefined, + times: [ + // 默认显示最近一周的数据 + formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))), + formatDate(endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24))) + ] +}) + +const queryFormRef = ref() // 搜索的表单 +const deptList = ref<Tree[]>([]) // 部门树形结构 +const userList = ref<UserApi.UserVO[]>([]) // 全量用户清单 + +/** 根据选择的部门筛选员工清单 */ +const userListByDeptId = computed(() => + queryParams.deptId + ? userList.value.filter((u: UserApi.UserVO) => u.deptId === queryParams.deptId) + : [] +) + +const activeTab = ref('funnelRef') // 活跃标签 +const funnelRef = ref() // 销售漏斗 +const businessSummaryRef = ref() // 新增商机分析 +const businessInversionRateSummaryRef = ref() // 商机转化率分析 + +/** 搜索按钮操作 */ +const handleQuery = () => { + switch (activeTab.value) { + case 'funnelRef': + funnelRef.value?.loadData?.() + break + case 'businessSummaryRef': + businessSummaryRef.value?.loadData?.() + break + case 'businessInversionRateSummaryRef': + businessInversionRateSummaryRef.value?.loadData?.() + break + } +} + +/** 当 activeTab 改变时,刷新当前活动的 tab */ +watch(activeTab, () => { + handleQuery() +}) + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 初始化 */ +onMounted(async () => { + deptList.value = handleTree(await DeptApi.getSimpleDeptList()) + userList.value = handleTree(await UserApi.getSimpleUserList()) +}) +</script> diff --git a/src/views/crm/statistics/performance/components/ContractCountPerformance.vue b/src/views/crm/statistics/performance/components/ContractCountPerformance.vue new file mode 100644 index 0000000..fa5a897 --- /dev/null +++ b/src/views/crm/statistics/performance/components/ContractCountPerformance.vue @@ -0,0 +1,236 @@ +<!-- 员工业绩统计 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="tableData"> + <el-table-column + v-for="item in columnsData" + :key="item.prop" + :label="item.label" + :prop="item.prop" + align="center" + > + <template #default="scope"> + {{ scope.row[item.prop] }} + </template> + </el-table-column> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { EChartsOption } from 'echarts' +import { + StatisticsPerformanceApi, + StatisticsPerformanceRespVO +} from '@/api/crm/statistics/performance' + +defineOptions({ name: 'ContractCountPerformance' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsPerformanceRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '当月合同数量(个)', + type: 'line', + data: [] + }, + { + name: '上月合同数量(个)', + type: 'line', + data: [] + }, + { + name: '去年同月合同数量(个)', + type: 'line', + data: [] + }, + { + name: '环比增长率(%)', + type: 'line', + yAxisIndex: 1, + data: [] + }, + { + name: '同比增长率(%)', + type: 'line', + yAxisIndex: 1, + data: [] + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '客户总量分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: [ + { + type: 'value', + name: '数量(个)', + axisTick: { + show: false + }, + axisLabel: { + color: '#BDBDBD', + formatter: '{value}' + }, + /** 坐标轴轴线相关设置 */ + axisLine: { + lineStyle: { + color: '#BDBDBD' + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#e6e6e6' + } + } + }, + { + type: 'value', + name: '', + axisTick: { + alignWithLabel: true, + lineStyle: { + width: 0 + } + }, + axisLabel: { + color: '#BDBDBD', + formatter: '{value}%' + }, + /** 坐标轴轴线相关设置 */ + axisLine: { + lineStyle: { + color: '#BDBDBD' + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#e6e6e6' + } + } + } + ], + xAxis: { + type: 'category', + name: '日期', + data: [] + } +}) as EChartsOption + +/** 获取统计数据 */ +const loadData = async () => { + // 1. 加载统计数据 + loading.value = true + const performanceList = await StatisticsPerformanceApi.getContractCountPerformance( + props.queryParams + ) + + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => s.time) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = performanceList.map( + (s: StatisticsPerformanceRespVO) => s.currentMonthCount + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = performanceList.map( + (s: StatisticsPerformanceRespVO) => s.lastMonthCount + ) + echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => + s.lastMonthCount !== 0 + ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2) + : 'NULL' + ) + } + if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) { + echartsOption.series[2]['data'] = performanceList.map( + (s: StatisticsPerformanceRespVO) => s.lastYearCount + ) + echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => + s.lastYearCount !== 0 + ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2) + : 'NULL' + ) + } + + // 2.2 更新列表数据 + list.value = performanceList + convertListData() + loading.value = false +} + +// 初始化数据 +const columnsData = reactive([]) +const tableData = reactive([ + { title: '当月合同数量统计(个)' }, + { title: '上月合同数量统计(个)' }, + { title: '去年当月合同数量统计(个)' }, + { title: '环比增长率(%)' }, + { title: '同比增长率(%)' } +]) + +// 定义 convertListData 方法,数据行列转置,展示每月数据 +const convertListData = () => { + const columnObj = { label: '日期', prop: 'title' } + columnsData.splice(0, columnsData.length) //清空数组 + columnsData.push(columnObj) + + list.value.forEach((item, index) => { + const columnObj = { label: item.time, prop: 'prop' + index } + columnsData.push(columnObj) + tableData[0]['prop' + index] = item.currentMonthCount + tableData[1]['prop' + index] = item.lastMonthCount + tableData[2]['prop' + index] = item.lastYearCount + tableData[3]['prop' + index] = + item.lastMonthCount !== 0 + ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2) + : 'NULL' + tableData[4]['prop' + index] = + item.lastYearCount !== 0 + ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2) + : 'NULL' + }) +} + +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(async () => { + await loadData() +}) +</script> diff --git a/src/views/crm/statistics/performance/components/ContractPricePerformance.vue b/src/views/crm/statistics/performance/components/ContractPricePerformance.vue new file mode 100644 index 0000000..dd52d9f --- /dev/null +++ b/src/views/crm/statistics/performance/components/ContractPricePerformance.vue @@ -0,0 +1,236 @@ +<!-- 员工业绩统计 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="tableData"> + <el-table-column + v-for="item in columnsData" + :key="item.prop" + :label="item.label" + :prop="item.prop" + align="center" + > + <template #default="scope"> + {{ scope.row[item.prop] }} + </template> + </el-table-column> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { EChartsOption } from 'echarts' +import { + StatisticsPerformanceApi, + StatisticsPerformanceRespVO +} from '@/api/crm/statistics/performance' + +defineOptions({ name: 'ContractPricePerformance' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsPerformanceRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '当月合同金额(元)', + type: 'line', + data: [] + }, + { + name: '上月合同金额(元)', + type: 'line', + data: [] + }, + { + name: '去年同月合同金额(元)', + type: 'line', + data: [] + }, + { + name: '环比增长率(%)', + type: 'line', + yAxisIndex: 1, + data: [] + }, + { + name: '同比增长率(%)', + type: 'line', + yAxisIndex: 1, + data: [] + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '客户总量分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: [ + { + type: 'value', + name: '金额(元)', + axisTick: { + show: false + }, + axisLabel: { + color: '#BDBDBD', + formatter: '{value}' + }, + /** 坐标轴轴线相关设置 */ + axisLine: { + lineStyle: { + color: '#BDBDBD' + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#e6e6e6' + } + } + }, + { + type: 'value', + name: '', + axisTick: { + alignWithLabel: true, + lineStyle: { + width: 0 + } + }, + axisLabel: { + color: '#BDBDBD', + formatter: '{value}%' + }, + /** 坐标轴轴线相关设置 */ + axisLine: { + lineStyle: { + color: '#BDBDBD' + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#e6e6e6' + } + } + } + ], + xAxis: { + type: 'category', + name: '日期', + data: [] + } +}) as EChartsOption + +/** 获取统计数据 */ +const loadData = async () => { + // 1. 加载统计数据 + loading.value = true + const performanceList = await StatisticsPerformanceApi.getContractPricePerformance( + props.queryParams + ) + + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => s.time) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = performanceList.map( + (s: StatisticsPerformanceRespVO) => s.currentMonthCount + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = performanceList.map( + (s: StatisticsPerformanceRespVO) => s.lastMonthCount + ) + echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => + s.lastMonthCount !== 0 + ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2) + : 'NULL' + ) + } + if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) { + echartsOption.series[2]['data'] = performanceList.map( + (s: StatisticsPerformanceRespVO) => s.lastYearCount + ) + echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => + s.lastYearCount !== 0 + ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2) + : 'NULL' + ) + } + + // 2.2 更新列表数据 + list.value = performanceList + convertListData() + loading.value = false +} + +// 初始化数据 +const columnsData = reactive([]) +const tableData = reactive([ + { title: '当月合同金额统计(元)' }, + { title: '上月合同金额统计(元)' }, + { title: '去年当月合同金额统计(元)' }, + { title: '环比增长率(%)' }, + { title: '同比增长率(%)' } +]) + +// 定义 init 方法 +const convertListData = () => { + const columnObj = { label: '日期', prop: 'title' } + columnsData.splice(0, columnsData.length) //清空数组 + columnsData.push(columnObj) + + list.value.forEach((item, index) => { + const columnObj = { label: item.time, prop: 'prop' + index } + columnsData.push(columnObj) + tableData[0]['prop' + index] = item.currentMonthCount + tableData[1]['prop' + index] = item.lastMonthCount + tableData[2]['prop' + index] = item.lastYearCount + tableData[3]['prop' + index] = + item.lastMonthCount !== 0 + ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2) + : 'NULL' + tableData[4]['prop' + index] = + item.lastYearCount !== 0 + ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2) + : 'NULL' + }) +} + +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(async () => { + await loadData() +}) +</script> diff --git a/src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue b/src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue new file mode 100644 index 0000000..169f074 --- /dev/null +++ b/src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue @@ -0,0 +1,236 @@ +<!-- 员工业绩统计 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 统计列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="tableData"> + <el-table-column + v-for="item in columnsData" + :key="item.prop" + :label="item.label" + :prop="item.prop" + align="center" + > + <template #default="scope"> + {{ scope.row[item.prop] }} + </template> + </el-table-column> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { EChartsOption } from 'echarts' +import { + StatisticsPerformanceApi, + StatisticsPerformanceRespVO +} from '@/api/crm/statistics/performance' + +defineOptions({ name: 'ContractPricePerformance' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsPerformanceRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:纵向 */ +const echartsOption = reactive<EChartsOption>({ + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: {}, + series: [ + { + name: '当月回款金额(元)', + type: 'line', + data: [] + }, + { + name: '上月回款金额(元)', + type: 'line', + data: [] + }, + { + name: '去年同月回款金额(元)', + type: 'line', + data: [] + }, + { + name: '环比增长率(%)', + type: 'line', + yAxisIndex: 1, + data: [] + }, + { + name: '同比增长率(%)', + type: 'line', + yAxisIndex: 1, + data: [] + } + ], + toolbox: { + feature: { + dataZoom: { + xAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '客户总量分析' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + yAxis: [ + { + type: 'value', + name: '金额(元)', + axisTick: { + show: false + }, + axisLabel: { + color: '#BDBDBD', + formatter: '{value}' + }, + /** 坐标轴轴线相关设置 */ + axisLine: { + lineStyle: { + color: '#BDBDBD' + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#e6e6e6' + } + } + }, + { + type: 'value', + name: '', + axisTick: { + alignWithLabel: true, + lineStyle: { + width: 0 + } + }, + axisLabel: { + color: '#BDBDBD', + formatter: '{value}%' + }, + /** 坐标轴轴线相关设置 */ + axisLine: { + lineStyle: { + color: '#BDBDBD' + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#e6e6e6' + } + } + } + ], + xAxis: { + type: 'category', + name: '日期', + data: [] + } +}) as EChartsOption + +/** 获取统计数据 */ +const loadData = async () => { + // 1. 加载统计数据 + loading.value = true + const performanceList = await StatisticsPerformanceApi.getReceivablePricePerformance( + props.queryParams + ) + + // 2.1 更新 Echarts 数据 + if (echartsOption.xAxis && echartsOption.xAxis['data']) { + echartsOption.xAxis['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => s.time) + } + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = performanceList.map( + (s: StatisticsPerformanceRespVO) => s.currentMonthCount + ) + } + if (echartsOption.series && echartsOption.series[1] && echartsOption.series[1]['data']) { + echartsOption.series[1]['data'] = performanceList.map( + (s: StatisticsPerformanceRespVO) => s.lastMonthCount + ) + echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => + s.lastMonthCount !== 0 + ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2) + : 'NULL' + ) + } + if (echartsOption.series && echartsOption.series[2] && echartsOption.series[1]['data']) { + echartsOption.series[2]['data'] = performanceList.map( + (s: StatisticsPerformanceRespVO) => s.lastYearCount + ) + echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => + s.lastYearCount !== 0 + ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2) + : 'NULL' + ) + } + + // 2.2 更新列表数据 + list.value = performanceList + convertListData() + loading.value = false +} + +// 初始化数据 +const columnsData = reactive([]) +const tableData = reactive([ + { title: '当月回款金额统计(元)' }, + { title: '上月回款金额统计(元)' }, + { title: '去年当月回款金额统计(元)' }, + { title: '环比增长率(%)' }, + { title: '同比增长率(%)' } +]) + +// 定义 init 方法 +const convertListData = () => { + const columnObj = { label: '日期', prop: 'title' } + columnsData.splice(0, columnsData.length) //清空数组 + columnsData.push(columnObj) + + list.value.forEach((item, index) => { + const columnObj = { label: item.time, prop: 'prop' + index } + columnsData.push(columnObj) + tableData[0]['prop' + index] = item.currentMonthCount + tableData[1]['prop' + index] = item.lastMonthCount + tableData[2]['prop' + index] = item.lastYearCount + tableData[3]['prop' + index] = + item.lastMonthCount !== 0 + ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2) + : 'NULL' + tableData[4]['prop' + index] = + item.lastYearCount !== 0 + ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2) + : 'NULL' + }) +} + +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(async () => { + await loadData() +}) +</script> diff --git a/src/views/crm/statistics/performance/index.vue b/src/views/crm/statistics/performance/index.vue new file mode 100644 index 0000000..822afec --- /dev/null +++ b/src/views/crm/statistics/performance/index.vue @@ -0,0 +1,146 @@ +<!-- 数据统计 - 员工业绩分析 --> +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="选择年份" prop="orderDate"> + <el-date-picker + v-model="queryParams.times[0]" + class="!w-240px" + type="year" + value-format="YYYY" + :default-time="[new Date().getFullYear()]" + /> + </el-form-item> + <el-form-item label="归属部门" prop="deptId"> + <el-tree-select + v-model="queryParams.deptId" + class="!w-240px" + :data="deptList" + :props="defaultProps" + check-strictly + node-key="id" + placeholder="请选择归属部门" + @change="queryParams.userId = undefined" + /> + </el-form-item> + <el-form-item label="员工" prop="userId"> + <el-select v-model="queryParams.userId" class="!w-240px" placeholder="员工" clearable> + <el-option + v-for="(user, index) in userListByDeptId" + :label="user.nickname" + :value="user.id" + :key="index" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> <Icon icon="ep:search" class="mr-5px" /> 搜索 </el-button> + <el-button @click="resetQuery"> <Icon icon="ep:refresh" class="mr-5px" /> 重置 </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 员工业绩统计 --> + <el-col> + <el-tabs v-model="activeTab"> + <!-- 员工合同统计 --> + <el-tab-pane label="员工合同数量统计" name="ContractCountPerformance" lazy> + <ContractCountPerformance :query-params="queryParams" ref="ContractCountPerformanceRef" /> + </el-tab-pane> + <!-- 员工合同金额统计 --> + <el-tab-pane label="员工合同金额统计" name="ContractPricePerformance" lazy> + <ContractPricePerformance :query-params="queryParams" ref="ContractPricePerformanceRef" /> + </el-tab-pane> + <!-- 员工回款金额统计 --> + <el-tab-pane label="员工回款金额统计" name="ReceivablePricePerformance" lazy> + <ReceivablePricePerformance + :query-params="queryParams" + ref="ReceivablePricePerformanceRef" + /> + </el-tab-pane> + </el-tabs> + </el-col> +</template> + +<script lang="ts" setup> +import * as DeptApi from '@/api/system/dept' +import * as UserApi from '@/api/system/user' +import { useUserStore } from '@/store/modules/user' +import { beginOfDay, endOfDay, formatDate } from '@/utils/formatTime' +import { defaultProps, handleTree } from '@/utils/tree' +import ContractCountPerformance from './components/ContractCountPerformance.vue' +import ContractPricePerformance from './components/ContractPricePerformance.vue' +import ReceivablePricePerformance from './components/ReceivablePricePerformance.vue' + +defineOptions({ name: 'CrmStatisticsCustomer' }) + +const queryParams = reactive({ + deptId: useUserStore().getUser.deptId, + userId: undefined, + times: [ + formatDate(beginOfDay(new Date(new Date().getFullYear(), 0, 1))), + formatDate(endOfDay(new Date(new Date().getFullYear(), 11, 31))) + ] +}) + +const queryFormRef = ref() // 搜索的表单 +const deptList = ref<Tree[]>([]) // 部门树形结构 +const userList = ref<UserApi.UserVO[]>([]) // 全量用户清单 +// 根据选择的部门筛选员工清单 +const userListByDeptId = computed(() => + queryParams.deptId + ? userList.value.filter((u: UserApi.UserVO) => u.deptId === queryParams.deptId) + : [] +) + +// 活跃标签 +const activeTab = ref('ContractCountPerformance') +const ContractCountPerformanceRef = ref() // 员工合同数量统计 +const ContractPricePerformanceRef = ref() // 员工合同金额统计 +const ReceivablePricePerformanceRef = ref() // 员工回款金额统计 + +/** 搜索按钮操作 */ +const handleQuery = () => { + // 从 queryParams.times[0] 中获取到了年份 + const selectYear = parseInt(queryParams.times[0]) + queryParams.times[0] = formatDate(beginOfDay(new Date(selectYear, 0, 1))) + queryParams.times[1] = formatDate(endOfDay(new Date(selectYear, 11, 31))) + + // 执行查询 + switch (activeTab.value) { + case 'ContractCountPerformance': + ContractCountPerformanceRef.value?.loadData?.() + break + case 'ContractPricePerformance': + ContractPricePerformanceRef.value?.loadData?.() + break + case 'ReceivablePricePerformance': + ReceivablePricePerformanceRef.value?.loadData?.() + break + } +} + +// 当 activeTab 改变时,刷新当前活动的 tab +watch(activeTab, () => { + handleQuery() +}) + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +// 加载部门树 +onMounted(async () => { + deptList.value = handleTree(await DeptApi.getSimpleDeptList()) + userList.value = handleTree(await UserApi.getSimpleUserList()) +}) +</script> diff --git a/src/views/crm/statistics/portrait/components/PortraitCustomerArea.vue b/src/views/crm/statistics/portrait/components/PortraitCustomerArea.vue new file mode 100644 index 0000000..513936c --- /dev/null +++ b/src/views/crm/statistics/portrait/components/PortraitCustomerArea.vue @@ -0,0 +1,147 @@ +<!-- 客户城市分布 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-row :gutter="20"> + <el-col :span="12"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-col> + <el-col :span="12"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption2" /> + </el-skeleton> + </el-col> + </el-row> + </el-card> +</template> +<script lang="ts" setup> +import { EChartsOption } from 'echarts' +import china from '@/assets/map/json/china.json' +import echarts from '@/plugins/echarts' +import { + CrmStatisticCustomerAreaRespVO, + StatisticsPortraitApi +} from '@/api/crm/statistics/portrait' +import { areaReplace } from '@/utils' + +defineOptions({ name: 'PortraitCustomerArea' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +// 注册地图 +echarts?.registerMap('china', china as any) + +const loading = ref(false) // 加载中 +const areaStatisticsList = ref<CrmStatisticCustomerAreaRespVO[]>([]) // 列表的数据 + +/** 地图配置(全部客户) */ +const echartsOption = reactive<EChartsOption>({ + title: { + text: '全部客户', + left: 'center' + }, + tooltip: { + trigger: 'item', + showDelay: 0, + transitionDuration: 0.2 + }, + visualMap: { + text: ['高', '低'], + realtime: false, + calculable: true, + top: 'middle', + inRange: { + color: ['#fff', '#3b82f6'] + } + }, + series: [ + { + name: '客户地域分布', + type: 'map', + map: 'china', + roam: false, + selectedMode: false, + data: [] + } + ] +}) as EChartsOption + +/** 地图配置(成交客户) */ +const echartsOption2 = reactive<EChartsOption>({ + title: { + text: '成交客户', + left: 'center' + }, + tooltip: { + trigger: 'item', + showDelay: 0, + transitionDuration: 0.2 + }, + visualMap: { + text: ['高', '低'], + realtime: false, + calculable: true, + top: 'middle', + inRange: { + color: ['#fff', '#3b82f6'] + } + }, + series: [ + { + name: '客户地域分布', + type: 'map', + map: 'china', + roam: false, + selectedMode: false, + data: [] + } + ] +}) as EChartsOption + +/** 获取统计数据 */ +const loadData = async () => { + // 1. 加载统计数据 + loading.value = true + const areaList = await StatisticsPortraitApi.getCustomerArea(props.queryParams) + areaStatisticsList.value = areaList.map((item: CrmStatisticCustomerAreaRespVO) => { + return { + ...item, + areaName: areaReplace(item.areaName) + } + }) + buildLeftMap() + buildRightMap() + loading.value = false +} +defineExpose({ loadData }) + +const buildLeftMap = () => { + let min = 0 + let max = 0 + echartsOption.series![0].data = areaStatisticsList.value.map((item) => { + min = Math.min(min, item.customerCount || 0) + max = Math.max(max, item.customerCount || 0) + return { ...item, name: item.areaName, value: item.customerCount || 0 } + }) + echartsOption.visualMap!['min'] = min + echartsOption.visualMap!['max'] = max +} + +const buildRightMap = () => { + let min = 0 + let max = 0 + echartsOption2.series![0].data = areaStatisticsList.value.map((item) => { + min = Math.min(min, item.dealCount || 0) + max = Math.max(max, item.dealCount || 0) + return { ...item, name: item.areaName, value: item.dealCount || 0 } + }) + echartsOption2.visualMap!['min'] = min + echartsOption2.visualMap!['max'] = max +} + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/portrait/components/PortraitCustomerIndustry.vue b/src/views/crm/statistics/portrait/components/PortraitCustomerIndustry.vue new file mode 100644 index 0000000..d426993 --- /dev/null +++ b/src/views/crm/statistics/portrait/components/PortraitCustomerIndustry.vue @@ -0,0 +1,198 @@ +<!-- 客户行业分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-row :gutter="20"> + <el-col :span="12"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-col> + <el-col :span="12"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption2" /> + </el-skeleton> + </el-col> + </el-row> + </el-card> + + <!-- 统计列表 --> + <el-card class="mt-16px" shadow="never"> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="序号" type="index" width="80" /> + <el-table-column align="center" label="客户行业" prop="industryId" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户个数" min-width="200" prop="customerCount" /> + <el-table-column align="center" label="成交个数" min-width="200" prop="dealCount" /> + <el-table-column align="center" label="行业占比(%)" min-width="200" prop="industryPortion" /> + <el-table-column align="center" label="成交占比(%)" min-width="200" prop="dealPortion" /> + </el-table> + </el-card> +</template> +<script lang="ts" setup> +import { + CrmStatisticCustomerIndustryRespVO, + StatisticsPortraitApi +} from '@/api/crm/statistics/portrait' +import { EChartsOption } from 'echarts' +import { DICT_TYPE, getDictLabel } from '@/utils/dict' +import { erpCalculatePercentage, getSumValue } from '@/utils' +import { isEmpty } from '@/utils/is' + +defineOptions({ name: 'PortraitCustomerIndustry' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticCustomerIndustryRespVO[]>([]) // 列表的数据 + +/** 饼图配置(全部客户) */ +const echartsOption = reactive<EChartsOption>({ + title: { + text: '全部客户', + left: 'center' + }, + tooltip: { + trigger: 'item' + }, + legend: { + orient: 'vertical', + left: 'left' + }, + toolbox: { + feature: { + saveAsImage: { show: true, name: '全部客户' } // 保存为图片 + } + }, + series: [ + { + name: '全部客户', + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 10, + borderColor: '#fff', + borderWidth: 2 + }, + label: { + show: false, + position: 'center' + }, + emphasis: { + label: { + show: true, + fontSize: 40, + fontWeight: 'bold' + } + }, + labelLine: { + show: false + }, + data: [] + } + ] +}) as EChartsOption + +/** 饼图配置(成交客户) */ +const echartsOption2 = reactive<EChartsOption>({ + title: { + text: '成交客户', + left: 'center' + }, + tooltip: { + trigger: 'item' + }, + legend: { + orient: 'vertical', + left: 'left' + }, + toolbox: { + feature: { + saveAsImage: { show: true, name: '成交客户' } // 保存为图片 + } + }, + series: [ + { + name: '成交客户', + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 10, + borderColor: '#fff', + borderWidth: 2 + }, + label: { + show: false, + position: 'center' + }, + emphasis: { + label: { + show: true, + fontSize: 40, + fontWeight: 'bold' + } + }, + labelLine: { + show: false + }, + data: [] + } + ] +}) as EChartsOption + +/** 获取统计数据 */ +const loadData = async () => { + // 1. 加载统计数据 + loading.value = true + const industryList = await StatisticsPortraitApi.getCustomerIndustry(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = industryList.map((r: CrmStatisticCustomerIndustryRespVO) => { + return { + name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, r.industryId), + value: r.customerCount + } + }) + } + // 2.2 更新 Echarts2 数据 + if (echartsOption2.series && echartsOption2.series[0] && echartsOption2.series[0]['data']) { + echartsOption2.series[0]['data'] = industryList.map((r: CrmStatisticCustomerIndustryRespVO) => { + return { + name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, r.industryId), + value: r.dealCount + } + }) + } + // 3. 计算比例 + calculateProportion(industryList) + list.value = industryList + loading.value = false +} +defineExpose({ loadData }) + +/** 计算比例 */ +const calculateProportion = (sourceList: CrmStatisticCustomerIndustryRespVO[]) => { + if (isEmpty(sourceList)) { + return + } + // 这里类型丢失了所以重新搞个变量 + const list = sourceList as unknown as CrmStatisticCustomerIndustryRespVO[] + const sumCustomerCount = getSumValue(list.map((item) => item.customerCount)) + const sumDealCount = getSumValue(list.map((item) => item.dealCount)) + list.forEach((item) => { + item.industryPortion = + item.customerCount === 0 ? 0 : erpCalculatePercentage(item.customerCount, sumCustomerCount) + item.dealPortion = + item.dealCount === 0 ? 0 : erpCalculatePercentage(item.dealCount, sumDealCount) + }) +} + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/portrait/components/PortraitCustomerLevel.vue b/src/views/crm/statistics/portrait/components/PortraitCustomerLevel.vue new file mode 100644 index 0000000..653feef --- /dev/null +++ b/src/views/crm/statistics/portrait/components/PortraitCustomerLevel.vue @@ -0,0 +1,198 @@ +<!-- 客户来源分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-row :gutter="20"> + <el-col :span="12"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-col> + <el-col :span="12"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption2" /> + </el-skeleton> + </el-col> + </el-row> + </el-card> + + <!-- 统计列表 --> + <el-card class="mt-16px" shadow="never"> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="序号" type="index" width="80" /> + <el-table-column align="center" label="客户级别" prop="level" width="200"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户个数" min-width="200" prop="customerCount" /> + <el-table-column align="center" label="成交个数" min-width="200" prop="dealCount" /> + <el-table-column align="center" label="级别占比(%)" min-width="200" prop="levelPortion" /> + <el-table-column align="center" label="成交占比(%)" min-width="200" prop="dealPortion" /> + </el-table> + </el-card> +</template> +<script lang="ts" setup> +import { + CrmStatisticCustomerLevelRespVO, + StatisticsPortraitApi +} from '@/api/crm/statistics/portrait' +import { EChartsOption } from 'echarts' +import { DICT_TYPE, getDictLabel } from '@/utils/dict' +import { erpCalculatePercentage, getSumValue } from '@/utils' +import { isEmpty } from '@/utils/is' + +defineOptions({ name: 'PortraitCustomerLevel' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticCustomerLevelRespVO[]>([]) // 列表的数据 + +/** 饼图配置(全部客户) */ +const echartsOption = reactive<EChartsOption>({ + title: { + text: '全部客户', + left: 'center' + }, + tooltip: { + trigger: 'item' + }, + legend: { + orient: 'vertical', + left: 'left' + }, + toolbox: { + feature: { + saveAsImage: { show: true, name: '全部客户' } // 保存为图片 + } + }, + series: [ + { + name: '全部客户', + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 10, + borderColor: '#fff', + borderWidth: 2 + }, + label: { + show: false, + position: 'center' + }, + emphasis: { + label: { + show: true, + fontSize: 40, + fontWeight: 'bold' + } + }, + labelLine: { + show: false + }, + data: [] + } + ] +}) as EChartsOption + +/** 饼图配置(成交客户) */ +const echartsOption2 = reactive<EChartsOption>({ + title: { + text: '成交客户', + left: 'center' + }, + tooltip: { + trigger: 'item' + }, + legend: { + orient: 'vertical', + left: 'left' + }, + toolbox: { + feature: { + saveAsImage: { show: true, name: '成交客户' } // 保存为图片 + } + }, + series: [ + { + name: '成交客户', + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 10, + borderColor: '#fff', + borderWidth: 2 + }, + label: { + show: false, + position: 'center' + }, + emphasis: { + label: { + show: true, + fontSize: 40, + fontWeight: 'bold' + } + }, + labelLine: { + show: false + }, + data: [] + } + ] +}) as EChartsOption + +/** 获取统计数据 */ +const loadData = async () => { + // 1. 加载统计数据 + loading.value = true + const levelList = await StatisticsPortraitApi.getCustomerLevel(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = levelList.map((r: CrmStatisticCustomerLevelRespVO) => { + return { + name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level), + value: r.customerCount + } + }) + } + // 2.2 更新 Echarts2 数据 + if (echartsOption2.series && echartsOption2.series[0] && echartsOption2.series[0]['data']) { + echartsOption2.series[0]['data'] = levelList.map((r: CrmStatisticCustomerLevelRespVO) => { + return { + name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level), + value: r.dealCount + } + }) + } + // 3. 计算比例 + calculateProportion(levelList) + list.value = levelList + loading.value = false +} +defineExpose({ loadData }) + +/** 计算比例 */ +const calculateProportion = (levelList: CrmStatisticCustomerLevelRespVO[]) => { + if (isEmpty(levelList)) { + return + } + // 这里类型丢失了所以重新搞个变量 + const list = levelList as unknown as CrmStatisticCustomerLevelRespVO[] + const sumCustomerCount = getSumValue(list.map((item) => item.customerCount)) + const sumDealCount = getSumValue(list.map((item) => item.dealCount)) + list.forEach((item) => { + item.levelPortion = + item.customerCount === 0 ? 0 : erpCalculatePercentage(item.customerCount, sumCustomerCount) + item.dealPortion = + item.dealCount === 0 ? 0 : erpCalculatePercentage(item.dealCount, sumDealCount) + }) +} + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/portrait/components/PortraitCustomerSource.vue b/src/views/crm/statistics/portrait/components/PortraitCustomerSource.vue new file mode 100644 index 0000000..ade6445 --- /dev/null +++ b/src/views/crm/statistics/portrait/components/PortraitCustomerSource.vue @@ -0,0 +1,198 @@ +<!-- 客户来源分析 --> +<template> + <!-- Echarts图 --> + <el-card shadow="never"> + <el-row :gutter="20"> + <el-col :span="12"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-col> + <el-col :span="12"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption2" /> + </el-skeleton> + </el-col> + </el-row> + </el-card> + + <!-- 统计列表 --> + <el-card class="mt-16px" shadow="never"> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="序号" type="index" width="80" /> + <el-table-column align="center" label="客户来源" prop="source" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户个数" min-width="200" prop="customerCount" /> + <el-table-column align="center" label="成交个数" min-width="200" prop="dealCount" /> + <el-table-column align="center" label="来源占比(%)" min-width="200" prop="sourcePortion" /> + <el-table-column align="center" label="成交占比(%)" min-width="200" prop="dealPortion" /> + </el-table> + </el-card> +</template> +<script lang="ts" setup> +import { + CrmStatisticCustomerSourceRespVO, + StatisticsPortraitApi +} from '@/api/crm/statistics/portrait' +import { EChartsOption } from 'echarts' +import { DICT_TYPE, getDictLabel } from '@/utils/dict' +import { isEmpty } from '@/utils/is' +import { erpCalculatePercentage, getSumValue } from '@/utils' + +defineOptions({ name: 'PortraitCustomerSource' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<CrmStatisticCustomerSourceRespVO[]>([]) // 列表的数据 + +/** 饼图配置(全部客户) */ +const echartsOption = reactive<EChartsOption>({ + title: { + text: '全部客户', + left: 'center' + }, + tooltip: { + trigger: 'item' + }, + legend: { + orient: 'vertical', + left: 'left' + }, + toolbox: { + feature: { + saveAsImage: { show: true, name: '全部客户' } // 保存为图片 + } + }, + series: [ + { + name: '全部客户', + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 10, + borderColor: '#fff', + borderWidth: 2 + }, + label: { + show: false, + position: 'center' + }, + emphasis: { + label: { + show: true, + fontSize: 40, + fontWeight: 'bold' + } + }, + labelLine: { + show: false + }, + data: [] + } + ] +}) as EChartsOption + +/** 饼图配置(成交客户) */ +const echartsOption2 = reactive<EChartsOption>({ + title: { + text: '成交客户', + left: 'center' + }, + tooltip: { + trigger: 'item' + }, + legend: { + orient: 'vertical', + left: 'left' + }, + toolbox: { + feature: { + saveAsImage: { show: true, name: '成交客户' } // 保存为图片 + } + }, + series: [ + { + name: '成交客户', + type: 'pie', + radius: ['40%', '70%'], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 10, + borderColor: '#fff', + borderWidth: 2 + }, + label: { + show: false, + position: 'center' + }, + emphasis: { + label: { + show: true, + fontSize: 40, + fontWeight: 'bold' + } + }, + labelLine: { + show: false + }, + data: [] + } + ] +}) as EChartsOption + +/** 获取统计数据 */ +const loadData = async () => { + // 1. 加载统计数据 + loading.value = true + const sourceList = await StatisticsPortraitApi.getCustomerSource(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.series && echartsOption.series[0] && echartsOption.series[0]['data']) { + echartsOption.series[0]['data'] = sourceList.map((r: CrmStatisticCustomerSourceRespVO) => { + return { + name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source), + value: r.customerCount + } + }) + } + // 2.2 更新 Echarts2 数据 + if (echartsOption2.series && echartsOption2.series[0] && echartsOption2.series[0]['data']) { + echartsOption2.series[0]['data'] = sourceList.map((r: CrmStatisticCustomerSourceRespVO) => { + return { + name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source), + value: r.dealCount + } + }) + } + // 3. 计算比例 + calculateProportion(sourceList) + list.value = sourceList + loading.value = false +} +defineExpose({ loadData }) + +/** 计算比例 */ +const calculateProportion = (sourceList: CrmStatisticCustomerSourceRespVO[]) => { + if (isEmpty(sourceList)) { + return + } + // 这里类型丢失了所以重新搞个变量 + const list = sourceList as unknown as CrmStatisticCustomerSourceRespVO[] + const sumCustomerCount = getSumValue(list.map((item) => item.customerCount)) + const sumDealCount = getSumValue(list.map((item) => item.dealCount)) + list.forEach((item) => { + item.sourcePortion = + item.customerCount === 0 ? 0 : erpCalculatePercentage(item.customerCount, sumCustomerCount) + item.dealPortion = + item.dealCount === 0 ? 0 : erpCalculatePercentage(item.dealCount, sumDealCount) + }) +} + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/portrait/index.vue b/src/views/crm/statistics/portrait/index.vue new file mode 100644 index 0000000..71807e1 --- /dev/null +++ b/src/views/crm/statistics/portrait/index.vue @@ -0,0 +1,156 @@ +<!-- 数据统计 - 客户画像 --> +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="时间范围" prop="orderDate"> + <el-date-picker + v-model="queryParams.times" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + :shortcuts="defaultShortcuts" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item label="归属部门" prop="deptId"> + <el-tree-select + v-model="queryParams.deptId" + :data="deptList" + :props="defaultProps" + check-strictly + class="!w-240px" + node-key="id" + placeholder="请选择归属部门" + @change="queryParams.userId = undefined" + /> + </el-form-item> + <el-form-item label="员工" prop="userId"> + <el-select v-model="queryParams.userId" class="!w-240px" clearable placeholder="员工"> + <el-option + v-for="(user, index) in userListByDeptId" + :key="index" + :label="user.nickname" + :value="user.id" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 客户统计 --> + <el-col> + <el-tabs v-model="activeTab"> + <!-- 城市分布分析 --> + <el-tab-pane label="城市分布分析" lazy name="areaRef"> + <PortraitCustomerArea ref="areaRef" :query-params="queryParams" /> + </el-tab-pane> + <!-- 客户级别分析 --> + <el-tab-pane label="客户级别分析" lazy name="levelRef"> + <PortraitCustomerLevel ref="levelRef" :query-params="queryParams" /> + </el-tab-pane> + <!-- 客户来源分析 --> + <el-tab-pane label="客户来源分析" lazy name="sourceRef"> + <PortraitCustomerSource ref="sourceRef" :query-params="queryParams" /> + </el-tab-pane> + <!-- 客户行业分析 --> + <el-tab-pane label="客户行业分析" lazy name="industryRef"> + <PortraitCustomerIndustry ref="industryRef" :query-params="queryParams" /> + </el-tab-pane> + </el-tabs> + </el-col> +</template> + +<script lang="ts" setup> +import * as DeptApi from '@/api/system/dept' +import * as UserApi from '@/api/system/user' +import { useUserStore } from '@/store/modules/user' +import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime' +import { defaultProps, handleTree } from '@/utils/tree' +import PortraitCustomerArea from './components/PortraitCustomerArea.vue' +import PortraitCustomerIndustry from './components/PortraitCustomerIndustry.vue' +import PortraitCustomerSource from './components/PortraitCustomerSource.vue' +import PortraitCustomerLevel from './components/PortraitCustomerLevel.vue' + +defineOptions({ name: 'CrmStatisticsPortrait' }) + +const queryParams = reactive({ + deptId: useUserStore().getUser.deptId, + userId: undefined, + times: [ + // 默认显示最近一周的数据 + formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))), + formatDate(endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24))) + ] +}) + +const queryFormRef = ref() // 搜索的表单 +const deptList = ref<Tree[]>([]) // 部门树形结构 +const userList = ref<UserApi.UserVO[]>([]) // 全量用户清单 + +/** 根据选择的部门筛选员工清单 */ +const userListByDeptId = computed(() => + queryParams.deptId + ? userList.value.filter((u: UserApi.UserVO) => u.deptId === queryParams.deptId) + : [] +) + +const activeTab = ref('areaRef') // 活跃标签 +const areaRef = ref() // 客户地区分布 +const levelRef = ref() // 客户级别 +const sourceRef = ref() // 客户来源 +const industryRef = ref() // 客户行业 + +/** 搜索按钮操作 */ +const handleQuery = () => { + switch (activeTab.value) { + case 'areaRef': + areaRef.value?.loadData?.() + break + case 'levelRef': + levelRef.value?.loadData?.() + break + case 'sourceRef': + sourceRef.value?.loadData?.() + break + case 'industryRef': + industryRef.value?.loadData?.() + break + } +} + +/** 当 activeTab 改变时,刷新当前活动的 tab */ +watch(activeTab, () => { + handleQuery() +}) + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 初始化 */ +onMounted(async () => { + deptList.value = handleTree(await DeptApi.getSimpleDeptList()) + userList.value = handleTree(await UserApi.getSimpleUserList()) +}) +</script> diff --git a/src/views/crm/statistics/rank/components/ContactCountRank.vue b/src/views/crm/statistics/rank/components/ContactCountRank.vue new file mode 100644 index 0000000..5edc118 --- /dev/null +++ b/src/views/crm/statistics/rank/components/ContactCountRank.vue @@ -0,0 +1,98 @@ +<!-- 新增联系人数排行 --> +<template> + <!-- 柱状图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 排行列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="公司排名" align="center" type="index" width="80" /> + <el-table-column label="创建人" align="center" prop="nickname" min-width="200" /> + <el-table-column label="部门" align="center" prop="deptName" min-width="200" /> + <el-table-column label="新增联系人数(个)" align="center" prop="count" min-width="200" /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank' +import { EChartsOption } from 'echarts' +import { clone } from 'lodash-es' + +defineOptions({ name: 'ContactCountRank' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:横向 */ +const echartsOption = reactive<EChartsOption>({ + dataset: { + dimensions: ['nickname', 'count'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: { + top: 50 + }, + series: [ + { + name: '新增联系人数排行', + type: 'bar' + } + ], + toolbox: { + feature: { + dataZoom: { + yAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '新增联系人数排行' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + xAxis: { + type: 'value', + name: '新增联系人数(个)' + }, + yAxis: { + type: 'category', + name: '创建人' + } +}) as EChartsOption + +/** 获取新增联系人数排行 */ +const loadData = async () => { + // 1. 加载排行数据 + loading.value = true + const rankingList = await StatisticsRankApi.getContactsCountRank(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.dataset && echartsOption.dataset['source']) { + echartsOption.dataset['source'] = clone(rankingList).reverse() + } + // 2.2 更新列表数据 + list.value = rankingList + loading.value = false +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/rank/components/ContractCountRank.vue b/src/views/crm/statistics/rank/components/ContractCountRank.vue new file mode 100644 index 0000000..fc50a6d --- /dev/null +++ b/src/views/crm/statistics/rank/components/ContractCountRank.vue @@ -0,0 +1,98 @@ +<!-- 签约合同排行 --> +<template> + <!-- 柱状图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 排行列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="公司排名" align="center" type="index" width="80" /> + <el-table-column label="签订人" align="center" prop="nickname" min-width="200" /> + <el-table-column label="部门" align="center" prop="deptName" min-width="200" /> + <el-table-column label="签约合同数(个)" align="center" prop="count" min-width="200" /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank' +import { EChartsOption } from 'echarts' +import { clone } from 'lodash-es' + +defineOptions({ name: 'ContractCountRank' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:横向 */ +const echartsOption = reactive<EChartsOption>({ + dataset: { + dimensions: ['nickname', 'count'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: { + top: 50 + }, + series: [ + { + name: '签约合同排行', + type: 'bar' + } + ], + toolbox: { + feature: { + dataZoom: { + yAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '签约合同排行' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + xAxis: { + type: 'value', + name: '签约合同数(个)' + }, + yAxis: { + type: 'category', + name: '签订人' + } +}) as EChartsOption + +/** 获取签约合同排行 */ +const loadData = async () => { + // 1. 加载排行数据 + loading.value = true + const rankingList = await StatisticsRankApi.getContractCountRank(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.dataset && echartsOption.dataset['source']) { + echartsOption.dataset['source'] = clone(rankingList).reverse() + } + // 2.2 更新列表数据 + list.value = rankingList + loading.value = false +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/rank/components/ContractPriceRank.vue b/src/views/crm/statistics/rank/components/ContractPriceRank.vue new file mode 100644 index 0000000..b69ebd2 --- /dev/null +++ b/src/views/crm/statistics/rank/components/ContractPriceRank.vue @@ -0,0 +1,105 @@ +<!-- 合同金额排行 --> +<template> + <!-- 柱状图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 排行列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="公司排名" align="center" type="index" width="80" /> + <el-table-column label="签订人" align="center" prop="nickname" min-width="200" /> + <el-table-column label="部门" align="center" prop="deptName" min-width="200" /> + <el-table-column + label="合同金额(元)" + align="center" + prop="count" + min-width="200" + :formatter="erpPriceTableColumnFormatter" + /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank' +import { EChartsOption } from 'echarts' +import { clone } from 'lodash-es' +import { erpPriceTableColumnFormatter } from '@/utils' + +defineOptions({ name: 'ContractPriceRank' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:横向 */ +const echartsOption = reactive<EChartsOption>({ + dataset: { + dimensions: ['nickname', 'count'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: { + top: 50 + }, + series: [ + { + name: '合同金额排行', + type: 'bar' + } + ], + toolbox: { + feature: { + dataZoom: { + yAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '合同金额排行' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + xAxis: { + type: 'value', + name: '合同金额(元)' + }, + yAxis: { + type: 'category', + name: '签订人' + } +}) as EChartsOption + +/** 获取合同金额排行 */ +const loadData = async () => { + // 1. 加载排行数据 + loading.value = true + const rankingList = await StatisticsRankApi.getContractPriceRank(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.dataset && echartsOption.dataset['source']) { + echartsOption.dataset['source'] = clone(rankingList).reverse() + } + // 2.2 更新列表数据 + list.value = rankingList + loading.value = false +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/rank/components/CustomerCountRank.vue b/src/views/crm/statistics/rank/components/CustomerCountRank.vue new file mode 100644 index 0000000..b66a681 --- /dev/null +++ b/src/views/crm/statistics/rank/components/CustomerCountRank.vue @@ -0,0 +1,98 @@ +<!-- 新增客户数排行 --> +<template> + <!-- 柱状图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 排行列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="公司排名" align="center" type="index" width="80" /> + <el-table-column label="创建人" align="center" prop="nickname" min-width="200" /> + <el-table-column label="部门" align="center" prop="deptName" min-width="200" /> + <el-table-column label="新增客户数(个)" align="center" prop="count" min-width="200" /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank' +import { EChartsOption } from 'echarts' +import { clone } from 'lodash-es' + +defineOptions({ name: 'CustomerCountRank' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:横向 */ +const echartsOption = reactive<EChartsOption>({ + dataset: { + dimensions: ['nickname', 'count'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: { + top: 50 + }, + series: [ + { + name: '新增客户数排行', + type: 'bar' + } + ], + toolbox: { + feature: { + dataZoom: { + yAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '新增客户数排行' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + xAxis: { + type: 'value', + name: '新增客户数(个)' + }, + yAxis: { + type: 'category', + name: '创建人' + } +}) as EChartsOption + +/** 获取新增客户数排行 */ +const loadData = async () => { + // 1. 加载排行数据 + loading.value = true + const rankingList = await StatisticsRankApi.getCustomerCountRank(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.dataset && echartsOption.dataset['source']) { + echartsOption.dataset['source'] = clone(rankingList).reverse() + } + // 2.2 更新列表数据 + list.value = rankingList + loading.value = false +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/rank/components/FollowCountRank.vue b/src/views/crm/statistics/rank/components/FollowCountRank.vue new file mode 100644 index 0000000..43352ab --- /dev/null +++ b/src/views/crm/statistics/rank/components/FollowCountRank.vue @@ -0,0 +1,98 @@ +<!-- 跟进次数排行 --> +<template> + <!-- 柱状图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 排行列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="公司排名" align="center" type="index" width="80" /> + <el-table-column label="员工" align="center" prop="nickname" min-width="200" /> + <el-table-column label="部门" align="center" prop="deptName" min-width="200" /> + <el-table-column label="跟进次数(次)" align="center" prop="count" min-width="200" /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank' +import { EChartsOption } from 'echarts' +import { clone } from 'lodash-es' + +defineOptions({ name: 'FollowCountRank' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:横向 */ +const echartsOption = reactive<EChartsOption>({ + dataset: { + dimensions: ['nickname', 'count'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: { + top: 50 + }, + series: [ + { + name: '跟进次数排行', + type: 'bar' + } + ], + toolbox: { + feature: { + dataZoom: { + yAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '跟进次数排行' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + xAxis: { + type: 'value', + name: '跟进次数(次)' + }, + yAxis: { + type: 'category', + name: '员工' + } +}) as EChartsOption + +/** 获取跟进次数排行 */ +const loadData = async () => { + // 1. 加载排行数据 + loading.value = true + const rankingList = await StatisticsRankApi.getFollowCountRank(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.dataset && echartsOption.dataset['source']) { + echartsOption.dataset['source'] = clone(rankingList).reverse() + } + // 2.2 更新列表数据 + list.value = rankingList + loading.value = false +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/rank/components/FollowCustomerCountRank.vue b/src/views/crm/statistics/rank/components/FollowCustomerCountRank.vue new file mode 100644 index 0000000..92a2205 --- /dev/null +++ b/src/views/crm/statistics/rank/components/FollowCustomerCountRank.vue @@ -0,0 +1,98 @@ +<!-- 跟进客户数排行 --> +<template> + <!-- 柱状图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 排行列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="公司排名" align="center" type="index" width="80" /> + <el-table-column label="员工" align="center" prop="nickname" min-width="200" /> + <el-table-column label="部门" align="center" prop="deptName" min-width="200" /> + <el-table-column label="跟进客户数(个)" align="center" prop="count" min-width="200" /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank' +import { EChartsOption } from 'echarts' +import { clone } from 'lodash-es' + +defineOptions({ name: 'FollowCustomerCountRank' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:横向 */ +const echartsOption = reactive<EChartsOption>({ + dataset: { + dimensions: ['nickname', 'count'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: { + top: 50 + }, + series: [ + { + name: '跟进客户数排行', + type: 'bar' + } + ], + toolbox: { + feature: { + dataZoom: { + yAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '跟进客户数排行' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + xAxis: { + type: 'value', + name: '跟进客户数(个)' + }, + yAxis: { + type: 'category', + name: '员工' + } +}) as EChartsOption + +/** 获取跟进客户数排行 */ +const loadData = async () => { + // 1. 加载排行数据 + loading.value = true + const rankingList = await StatisticsRankApi.getFollowCustomerCountRank(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.dataset && echartsOption.dataset['source']) { + echartsOption.dataset['source'] = clone(rankingList).reverse() + } + // 2.2 更新列表数据 + list.value = rankingList + loading.value = false +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/rank/components/ProductSalesRank.vue b/src/views/crm/statistics/rank/components/ProductSalesRank.vue new file mode 100644 index 0000000..e2a02b7 --- /dev/null +++ b/src/views/crm/statistics/rank/components/ProductSalesRank.vue @@ -0,0 +1,98 @@ +<!-- 产品销量排行 --> +<template> + <!-- 柱状图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 排行列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="公司排名" align="center" type="index" width="80" /> + <el-table-column label="员工" align="center" prop="nickname" min-width="200" /> + <el-table-column label="部门" align="center" prop="deptName" min-width="200" /> + <el-table-column label="产品销量" align="center" prop="count" min-width="200" /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank' +import { EChartsOption } from 'echarts' +import { clone } from 'lodash-es' + +defineOptions({ name: 'ProductSalesRank' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:横向 */ +const echartsOption = reactive<EChartsOption>({ + dataset: { + dimensions: ['nickname', 'count'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: { + top: 50 + }, + series: [ + { + name: '产品销量排行', + type: 'bar' + } + ], + toolbox: { + feature: { + dataZoom: { + yAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '产品销量排行' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + xAxis: { + type: 'value', + name: '产品销量' + }, + yAxis: { + type: 'category', + name: '员工' + } +}) as EChartsOption + +/** 获取产品销量排行 */ +const loadData = async () => { + // 1. 加载排行数据 + loading.value = true + const rankingList = await StatisticsRankApi.getProductSalesRank(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.dataset && echartsOption.dataset['source']) { + echartsOption.dataset['source'] = clone(rankingList).reverse() + } + // 2.2 更新列表数据 + list.value = rankingList + loading.value = false +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/rank/components/ReceivablePriceRank.vue b/src/views/crm/statistics/rank/components/ReceivablePriceRank.vue new file mode 100644 index 0000000..06d7d9f --- /dev/null +++ b/src/views/crm/statistics/rank/components/ReceivablePriceRank.vue @@ -0,0 +1,106 @@ +<!-- 回款金额排行 --> +<template> + <!-- 柱状图 --> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <Echart :height="500" :options="echartsOption" /> + </el-skeleton> + </el-card> + + <!-- 排行列表 --> + <el-card shadow="never" class="mt-16px"> + <el-table v-loading="loading" :data="list"> + <el-table-column label="公司排名" align="center" type="index" width="80" /> + <el-table-column label="签订人" align="center" prop="nickname" min-width="200" /> + <el-table-column label="部门" align="center" prop="deptName" min-width="200" /> + <el-table-column + label="回款金额(元)" + align="center" + prop="count" + min-width="200" + :formatter="erpPriceTableColumnFormatter" + /> + </el-table> + </el-card> +</template> +<script setup lang="ts"> +import { StatisticsRankApi, StatisticsRankRespVO } from '@/api/crm/statistics/rank' +import { EChartsOption } from 'echarts' +import { clone } from 'lodash-es' +import { erpPriceTableColumnFormatter } from '@/utils' + +defineOptions({ name: 'ReceivablePriceRank' }) +const props = defineProps<{ queryParams: any }>() // 搜索参数 + +const loading = ref(false) // 加载中 +const list = ref<StatisticsRankRespVO[]>([]) // 列表的数据 + +/** 柱状图配置:横向 */ +const echartsOption = reactive<EChartsOption>({ + dataset: { + dimensions: ['nickname', 'count'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + containLabel: true + }, + legend: { + top: 50 + }, + series: [ + { + name: '回款金额排行', + type: 'bar' + } + ], + toolbox: { + feature: { + dataZoom: { + yAxisIndex: false // 数据区域缩放:Y 轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '回款金额排行' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + xAxis: { + type: 'value', + name: '回款金额(元)' + }, + yAxis: { + type: 'category', + name: '签订人', + nameGap: 30 + } +}) as EChartsOption + +/** 获取回款金额排行 */ +const loadData = async () => { + // 1. 加载排行数据 + loading.value = true + const rankingList = await StatisticsRankApi.getReceivablePriceRank(props.queryParams) + // 2.1 更新 Echarts 数据 + if (echartsOption.dataset && echartsOption.dataset['source']) { + echartsOption.dataset['source'] = clone(rankingList).reverse() + } + // 2.2 更新列表数据 + list.value = rankingList + loading.value = false +} +defineExpose({ loadData }) + +/** 初始化 */ +onMounted(() => { + loadData() +}) +</script> diff --git a/src/views/crm/statistics/rank/index.vue b/src/views/crm/statistics/rank/index.vue new file mode 100644 index 0000000..98340cc --- /dev/null +++ b/src/views/crm/statistics/rank/index.vue @@ -0,0 +1,163 @@ +<!-- BI 排行版 --> +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="时间范围" prop="orderDate"> + <el-date-picker + v-model="queryParams.times" + :shortcuts="defaultShortcuts" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + /> + </el-form-item> + <el-form-item label="归属部门" prop="deptId"> + <el-tree-select + v-model="queryParams.deptId" + :data="deptList" + :props="defaultProps" + check-strictly + node-key="id" + placeholder="请选择归属部门" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 排行数据 --> + <el-col> + <el-tabs v-model="activeTab"> + <!-- 合同金额排行 --> + <el-tab-pane label="合同金额排行" name="contractPriceRank" lazy> + <ContractPriceRank :query-params="queryParams" ref="contractPriceRankRef" /> + </el-tab-pane> + <!-- 回款金额排行 --> + <el-tab-pane label="回款金额排行" name="receivablePriceRank" lazy> + <ReceivablePriceRank :query-params="queryParams" ref="receivablePriceRankRef" /> + </el-tab-pane> + <!-- 签约合同排行 --> + <el-tab-pane label="签约合同排行" name="contractCountRank" lazy> + <ContractCountRank :query-params="queryParams" ref="contractCountRankRef" /> + </el-tab-pane> + <!-- 产品销量排行 --> + <el-tab-pane label="产品销量排行" name="productSalesRank" lazy> + <ProductSalesRank :query-params="queryParams" ref="productSalesRankRef" /> + </el-tab-pane> + <!-- 新增客户数排行 --> + <el-tab-pane label="新增客户数排行" name="customerCountRank" lazy> + <CustomerCountRank :query-params="queryParams" ref="customerCountRankRef" /> + </el-tab-pane> + <!-- 新增联系人数排行 --> + <el-tab-pane label="新增联系人数排行" name="contactCountRank" lazy> + <ContactCountRank :query-params="queryParams" ref="contactCountRankRef" /> + </el-tab-pane> + <!-- 跟进次数排行 --> + <el-tab-pane label="跟进次数排行" name="followCountRank" lazy> + <FollowCountRank :query-params="queryParams" ref="followCountRankRef" /> + </el-tab-pane> + <!-- 跟进客户数排行 --> + <el-tab-pane label="跟进客户数排行" name="followCustomerCountRank" lazy> + <FollowCustomerCountRank :query-params="queryParams" ref="followCustomerCountRankRef" /> + </el-tab-pane> + </el-tabs> + </el-col> +</template> +<script lang="ts" setup> +import ContractPriceRank from './components/ContractPriceRank.vue' +import ReceivablePriceRank from './components/ReceivablePriceRank.vue' +import ContractCountRank from './components/ContractCountRank.vue' +import ProductSalesRank from './components/ProductSalesRank.vue' +import CustomerCountRank from './components/CustomerCountRank.vue' +import ContactCountRank from './components/ContactCountRank.vue' +import FollowCountRank from './components/FollowCountRank.vue' +import FollowCustomerCountRank from './components/FollowCustomerCountRank.vue' +import { defaultProps, handleTree } from '@/utils/tree' +import * as DeptApi from '@/api/system/dept' +import { beginOfDay, defaultShortcuts, endOfDay, formatDate } from '@/utils/formatTime' +import { useUserStore } from '@/store/modules/user' + +defineOptions({ name: 'CrmStatisticsRank' }) + +const queryParams = reactive({ + deptId: useUserStore().getUser.deptId, + times: [ + // 默认显示最近一周的数据 + formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))), + formatDate(endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24))) + ] +}) + +const queryFormRef = ref() // 搜索的表单 +const deptList = ref<Tree[]>([]) // 树形结构 +const activeTab = ref('contractPriceRank') +const contractPriceRankRef = ref() // ContractPriceRank 组件的引用 +const receivablePriceRankRef = ref() // ReceivablePriceRank 组件的引用 +const contractCountRankRef = ref() // ContractCountRank 组件的引用 +const productSalesRankRef = ref() // ProductSalesRank 组件的引用 +const customerCountRankRef = ref() // CustomerCountRank 组件的引用 +const contactCountRankRef = ref() // ContactCountRank 组件的引用 +const followCountRankRef = ref() // FollowCountRank 组件的引用 +const followCustomerCountRankRef = ref() // FollowCustomerCountRank 组件的引用 + +/** 搜索按钮操作 */ +const handleQuery = () => { + switch (activeTab.value) { + case 'contractPriceRank': // 合同金额排行 + contractPriceRankRef.value?.loadData?.() + break + case 'receivablePriceRank': // 回款金额排行 + receivablePriceRankRef.value?.loadData?.() + break + case 'contractCountRank': // 签约合同排行 + contractCountRankRef.value?.loadData?.() + break + case 'productSalesRank': // 产品销量排行 + productSalesRankRef.value?.loadData?.() + break + case 'customerCountRank': // 新增客户数排行 + customerCountRankRef.value?.loadData?.() + break + case 'contactCountRank': // 新增联系人数排行 + contactCountRankRef.value?.loadData?.() + break + case 'followCountRank': // 跟进次数排行 + followCountRankRef.value?.loadData?.() + break + case 'followCustomerCountRank': // 跟进客户数排行 + followCustomerCountRankRef.value?.loadData?.() + break + } +} + +// 当 activeTab 改变时,刷新当前活动的 tab +watch(activeTab, () => { + handleQuery() +}) + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +// 加载部门树 +onMounted(async () => { + deptList.value = handleTree(await DeptApi.getSimpleDeptList()) +}) +</script> +<style lang="scss" scoped></style> diff --git a/src/views/erp/finance/account/AccountForm.vue b/src/views/erp/finance/account/AccountForm.vue new file mode 100644 index 0000000..2f2e6f4 --- /dev/null +++ b/src/views/erp/finance/account/AccountForm.vue @@ -0,0 +1,124 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + <el-form-item label="编码" prop="no"> + <el-input v-model="formData.no" placeholder="请输入编码" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="排序" prop="sort"> + <el-input v-model="formData.sort" placeholder="请输入排序" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' + +/** ERP 结算 表单 */ +defineOptions({ name: 'AccountForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + no: undefined, + remark: undefined, + status: undefined, + sort: undefined, + defaultStatus: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await AccountApi.getAccount(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as AccountVO + if (formType.value === 'create') { + await AccountApi.createAccount(data) + message.success(t('common.createSuccess')) + } else { + await AccountApi.updateAccount(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + no: undefined, + remark: undefined, + status: undefined, + sort: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/finance/account/index.vue b/src/views/erp/finance/account/index.vue new file mode 100644 index 0000000..8d85ef3 --- /dev/null +++ b/src/views/erp/finance/account/index.vue @@ -0,0 +1,235 @@ +<template> + <doc-alert + title="【财务】采购付款、销售收款" + url="https://doc.iocoder.cn/sale/finance-payment-receipt/" + /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="编码" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入编码" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:account:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:account:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="名称" align="center" prop="name" /> + <el-table-column label="编码" align="center" prop="no" /> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="排序" align="center" prop="sort" /> + <el-table-column label="是否默认" align="center" prop="defaultStatus"> + <template #default="scope"> + <el-switch + v-model="scope.row.defaultStatus" + :active-value="true" + :inactive-value="false" + @change="handleDefaultStatusChange(scope.row)" + /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:account:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['erp:account:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <AccountForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' +import AccountForm from './AccountForm.vue' + +/** ERP 结算账户 列表 */ +defineOptions({ name: 'ErpAccount' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<AccountVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + remark: undefined, + status: undefined, + name: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await AccountApi.getAccountPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await AccountApi.deleteAccount(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 修改默认状态 */ +const handleDefaultStatusChange = async (row: WarehouseVO) => { + try { + // 修改状态的二次确认 + const text = row.defaultStatus ? '设置' : '取消' + await message.confirm('确认要' + text + '"' + row.name + '"默认吗?') + // 发起修改状态 + await AccountApi.updateAccountDefaultStatus(row.id, row.defaultStatus) + // 刷新列表 + await getList() + } catch (e) { + // 取消后,进行恢复按钮 + row.defaultStatus = !row.defaultStatus + } +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await AccountApi.exportAccount(queryParams) + download.excel(data, 'ERP 结算账户.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/erp/finance/payment/FinancePaymentForm.vue b/src/views/erp/finance/payment/FinancePaymentForm.vue new file mode 100644 index 0000000..3da2e6e --- /dev/null +++ b/src/views/erp/finance/payment/FinancePaymentForm.vue @@ -0,0 +1,278 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1080"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="付款单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="付款时间" prop="paymentTime"> + <el-date-picker + v-model="formData.paymentTime" + type="date" + value-format="x" + placeholder="选择付款时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="formData.supplierId" + clearable + filterable + placeholder="请选择供应商" + class="!w-1/1" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="财务人员" prop="financeUserId"> + <el-select + v-model="formData.financeUserId" + clearable + filterable + placeholder="请选择财务人员" + class="!w-1/1" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="采购入库、退货单" name="item"> + <FinancePaymentItemForm + ref="itemFormRef" + :supplier-id="formData.supplierId" + :items="formData.items" + :disabled="disabled" + /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="付款账户" prop="accountId"> + <el-select + v-model="formData.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-1/1" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="合计付款" prop="totalPrice"> + <el-input disabled v-model="formData.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="优惠金额" prop="discountPrice"> + <el-input-number + v-model="formData.discountPrice" + controls-position="right" + :precision="2" + placeholder="请输入优惠金额" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="实际付款"> + <el-input + disabled + v-model="formData.paymentPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { FinancePaymentApi, FinancePaymentVO } from '@/api/erp/finance/payment' +import FinancePaymentItemForm from './components/FinancePaymentItemForm.vue' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' +import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils' +import * as UserApi from '@/api/system/user' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' + +/** ERP 付款单表单 */ +defineOptions({ name: 'FinancePaymentForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + supplierId: undefined, + accountId: undefined, + financeUserId: undefined, + paymentTime: undefined, + remark: undefined, + fileUrl: '', + totalPrice: 0, + discountPrice: 0, + paymentPrice: 0, + items: [], + no: undefined // 订单单号,后端返回 +}) +const formRules = reactive({ + supplierId: [{ required: true, message: '供应商不能为空', trigger: 'blur' }], + paymentTime: [{ required: true, message: '订单时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 计算 discountPrice、totalPrice 价格 */ +watch( + () => formData.value, + (val) => { + if (!val) { + return + } + const totalPrice = val.items.reduce((prev, curr) => prev + curr.paymentPrice, 0) + formData.value.totalPrice = totalPrice + formData.value.paymentPrice = totalPrice - val.discountPrice + }, + { deep: true } +) + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await FinancePaymentApi.getFinancePayment(id) + } finally { + formLoading.value = false + } + } + // 加载供应商列表 + supplierList.value = await SupplierApi.getSupplierSimpleList() + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() + // 加载账户列表 + accountList.value = await AccountApi.getAccountSimpleList() + const defaultAccount = accountList.value.find((item) => item.defaultStatus) + if (defaultAccount) { + formData.value.accountId = defaultAccount.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as FinancePaymentVO + if (formType.value === 'create') { + await FinancePaymentApi.createFinancePayment(data) + message.success(t('common.createSuccess')) + } else { + await FinancePaymentApi.updateFinancePayment(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + supplierId: undefined, + accountId: undefined, + financeUserId: undefined, + paymentTime: undefined, + remark: undefined, + fileUrl: undefined, + totalPrice: 0, + discountPrice: 0, + paymentPrice: 0, + items: [], + no: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/finance/payment/components/FinancePaymentItemForm.vue b/src/views/erp/finance/payment/components/FinancePaymentItemForm.vue new file mode 100644 index 0000000..ea0e085 --- /dev/null +++ b/src/views/erp/finance/payment/components/FinancePaymentItemForm.vue @@ -0,0 +1,182 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="采购单据编号" min-width="200"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.bizNo" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="应付金额" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="已付金额" prop="paidPrice" fixed="right" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.paidPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="本次付款" prop="paymentPrice" fixed="right" min-width="115"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.paymentPrice`" class="mb-0px!"> + <el-input-number + v-model="row.paymentPrice" + controls-position="right" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3" v-if="!disabled"> + <el-button @click="handleOpenPurchaseIn" round>+ 添加采购入库单</el-button> + <el-button @click="handleOpenPurchaseReturn" round>+ 添加采购退货单</el-button> + </el-row> + + <!-- 可付款的【采购入库单】列表 --> + <PurchaseInPaymentEnableList + ref="purchaseInPaymentEnableListRef" + @success="handleAddPurchaseIn" + /> + <!-- 可付款的【采购入库单】列表 --> + <PurchaseReturnRefundEnableList + ref="purchaseReturnRefundEnableListRef" + @success="handleAddPurchaseReturn" + /> +</template> +<script setup lang="ts"> +import { ProductVO } from '@/api/erp/product/product' +import { erpPriceInputFormatter, getSumValue } from '@/utils' +import PurchaseInPaymentEnableList from '@/views/erp/purchase/in/components/PurchaseInPaymentEnableList.vue' +import PurchaseReturnRefundEnableList from '@/views/erp/purchase/return/components/PurchaseReturnRefundEnableList.vue' +import { PurchaseInVO } from '@/api/erp/purchase/in' +import { ErpBizType } from '@/utils/constants' +import { PurchaseReturnVO } from '@/api/erp/purchase/return' + +const props = defineProps<{ + items: undefined + supplierId: undefined + disabled: false +}>() +const message = useMessage() + +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + paymentPrice: [{ required: true, message: '本次付款不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 初始化设置入库项 */ +watch( + () => props.items, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index: number) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['totalPrice', 'paidPrice', 'paymentPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + return sums +} + +/** 新增【采购入库】按钮操作 */ +const purchaseInPaymentEnableListRef = ref() +const handleOpenPurchaseIn = () => { + if (!props.supplierId) { + message.error('请选择供应商') + return + } + purchaseInPaymentEnableListRef.value.open(props.supplierId) +} +const handleAddPurchaseIn = (rows: PurchaseInVO[]) => { + rows.forEach((row) => { + formData.value.push({ + bizId: row.id, + bizType: ErpBizType.PURCHASE_IN, + bizNo: row.no, + totalPrice: row.totalPrice, + paidPrice: row.paymentPrice, + paymentPrice: row.totalPrice - row.paymentPrice + }) + }) +} + +/** 新增【采购退货】按钮操作 */ +const purchaseReturnRefundEnableListRef = ref() +const handleOpenPurchaseReturn = () => { + if (!props.supplierId) { + message.error('请选择供应商') + return + } + purchaseReturnRefundEnableListRef.value.open(props.supplierId) +} +const handleAddPurchaseReturn = (rows: PurchaseReturnVO[]) => { + rows.forEach((row) => { + formData.value.push({ + bizId: row.id, + bizType: ErpBizType.PURCHASE_RETURN, + bizNo: row.no, + totalPrice: -row.totalPrice, + paidPrice: -row.refundPrice, + paymentPrice: -row.totalPrice + row.refundPrice + }) + }) +} + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) +</script> diff --git a/src/views/erp/finance/payment/index.vue b/src/views/erp/finance/payment/index.vue new file mode 100644 index 0000000..56bc83d --- /dev/null +++ b/src/views/erp/finance/payment/index.vue @@ -0,0 +1,394 @@ +<template> + <doc-alert + title="【财务】采购付款、销售收款" + url="https://doc.iocoder.cn/sale/finance-payment-receipt/" + /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="付款单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入付款单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="付款时间" prop="paymentTime"> + <el-date-picker + v-model="queryParams.paymentTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="queryParams.supplierId" + clearable + filterable + placeholder="请选择供供应商" + class="!w-240px" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="财务人员" prop="financeUserId"> + <el-select + v-model="queryParams.financeUserId" + clearable + filterable + placeholder="请选择财务人员" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="付款账户" prop="accountId"> + <el-select + v-model="queryParams.accountId" + clearable + filterable + placeholder="请选择付款账户" + class="!w-240px" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="采购单号" prop="bizNo"> + <el-input + v-model="queryParams.bizNo" + placeholder="请输入采购单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:finance-payment:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:finance-payment:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:finance-payment:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="付款单号" align="center" prop="no" /> + <el-table-column label="供应商" align="center" prop="supplierName" /> + <el-table-column + label="付款时间" + align="center" + prop="paymentTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column label="财务人员" align="center" prop="financeUserName" /> + <el-table-column label="付款账户" align="center" prop="accountName" /> + <el-table-column + label="合计付款" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="优惠金额" + align="center" + prop="discountPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="实际付款" + align="center" + prop="paymentPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:finance-payment:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:finance-payment:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:finance-payment:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:finance-payment:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:finance-payment:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <FinancePaymentForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { FinancePaymentApi, FinancePaymentVO } from '@/api/erp/finance/payment' +import FinancePaymentForm from './FinancePaymentForm.vue' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { erpPriceTableColumnFormatter } from '@/utils' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' + +/** ERP 付款单列表 */ +defineOptions({ name: 'ErpPurchaseOrder' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<FinancePaymentVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + paymentTime: [], + supplierId: undefined, + creator: undefined, + financeUserId: undefined, + accountId: undefined, + status: undefined, + remark: undefined, + bizNo: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 +const userList = ref<UserVO[]>([]) // 用户列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await FinancePaymentApi.getFinancePaymentPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await FinancePaymentApi.deleteFinancePayment(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该付款单吗?`) + // 发起审批 + await FinancePaymentApi.updateFinancePaymentStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await FinancePaymentApi.exportFinancePayment(queryParams) + download.excel(data, '付款单.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<FinancePaymentVO[]>([]) +const handleSelectionChange = (rows: FinancePaymentVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载供应商、用户、账户 + supplierList.value = await SupplierApi.getSupplierSimpleList() + userList.value = await UserApi.getSimpleUserList() + accountList.value = await AccountApi.getAccountSimpleList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/finance/receipt/FinanceReceiptForm.vue b/src/views/erp/finance/receipt/FinanceReceiptForm.vue new file mode 100644 index 0000000..96826eb --- /dev/null +++ b/src/views/erp/finance/receipt/FinanceReceiptForm.vue @@ -0,0 +1,278 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1080"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="收款单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="收款时间" prop="receiptTime"> + <el-date-picker + v-model="formData.receiptTime" + type="date" + value-format="x" + placeholder="选择收款时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="formData.customerId" + clearable + filterable + placeholder="请选择客户" + class="!w-1/1" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="财务人员" prop="financeUserId"> + <el-select + v-model="formData.financeUserId" + clearable + filterable + placeholder="请选择财务人员" + class="!w-1/1" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="采购入库、退货单" name="item"> + <FinanceReceiptItemForm + ref="itemFormRef" + :customer-id="formData.customerId" + :items="formData.items" + :disabled="disabled" + /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="收款账户" prop="accountId"> + <el-select + v-model="formData.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-1/1" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="合计收款" prop="totalPrice"> + <el-input disabled v-model="formData.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="优惠金额" prop="discountPrice"> + <el-input-number + v-model="formData.discountPrice" + controls-position="right" + :precision="2" + placeholder="请输入优惠金额" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="实际收款"> + <el-input + disabled + v-model="formData.receiptPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { FinanceReceiptApi, FinanceReceiptVO } from '@/api/erp/finance/receipt' +import FinanceReceiptItemForm from './components/FinanceReceiptItemForm.vue' +import { erpPriceInputFormatter } from '@/utils' +import * as UserApi from '@/api/system/user' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' + +/** ERP 收款单表单 */ +defineOptions({ name: 'FinanceReceiptForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + customerId: undefined, + accountId: undefined, + financeUserId: undefined, + receiptTime: undefined, + remark: undefined, + fileUrl: '', + totalPrice: 0, + discountPrice: 0, + receiptPrice: 0, + items: [], + no: undefined // 订单单号,后端返回 +}) +const formRules = reactive({ + customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }], + receiptTime: [{ required: true, message: '订单时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref +const customerList = ref<CustomerVO[]>([]) // 客户列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 计算 discountPrice、totalPrice 价格 */ +watch( + () => formData.value, + (val) => { + if (!val) { + return + } + const totalPrice = val.items.reduce((prev, curr) => prev + curr.receiptPrice, 0) + formData.value.totalPrice = totalPrice + formData.value.receiptPrice = totalPrice - val.discountPrice + }, + { deep: true } +) + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await FinanceReceiptApi.getFinanceReceipt(id) + } finally { + formLoading.value = false + } + } + // 加载客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() + // 加载账户列表 + accountList.value = await AccountApi.getAccountSimpleList() + const defaultAccount = accountList.value.find((item) => item.defaultStatus) + if (defaultAccount) { + formData.value.accountId = defaultAccount.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as FinanceReceiptVO + if (formType.value === 'create') { + await FinanceReceiptApi.createFinanceReceipt(data) + message.success(t('common.createSuccess')) + } else { + await FinanceReceiptApi.updateFinanceReceipt(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + customerId: undefined, + accountId: undefined, + financeUserId: undefined, + receiptTime: undefined, + remark: undefined, + fileUrl: undefined, + totalPrice: 0, + discountPrice: 0, + receiptPrice: 0, + items: [], + no: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/finance/receipt/components/FinanceReceiptItemForm.vue b/src/views/erp/finance/receipt/components/FinanceReceiptItemForm.vue new file mode 100644 index 0000000..1a48b41 --- /dev/null +++ b/src/views/erp/finance/receipt/components/FinanceReceiptItemForm.vue @@ -0,0 +1,176 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="销售单据编号" min-width="200"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.bizNo" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="应付金额" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="已付金额" prop="receiptedPrice" fixed="right" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.receiptedPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="本次收款" prop="receiptPrice" fixed="right" min-width="115"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.receiptPrice`" class="mb-0px!"> + <el-input-number + v-model="row.receiptPrice" + controls-position="right" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3" v-if="!disabled"> + <el-button @click="handleOpenSaleOut" round>+ 添加销售出库单</el-button> + <el-button @click="handleOpenSaleReturn" round>+ 添加销售退货单</el-button> + </el-row> + + <!-- 可收款的【销售出库单】列表 --> + <SaleOutReceiptEnableList ref="saleOutReceiptEnableListRef" @success="handleAddSaleOut" /> + <!-- 可收款的【销售出库单】列表 --> + <SaleReturnRefundEnableList ref="saleReturnRefundEnableListRef" @success="handleAddSaleReturn" /> +</template> +<script setup lang="ts"> +import { ProductVO } from '@/api/erp/product/product' +import { erpPriceInputFormatter, getSumValue } from '@/utils' +import SaleOutReceiptEnableList from '@/views/erp/sale/out/components/SaleOutReceiptEnableList.vue' +import SaleReturnRefundEnableList from '@/views/erp/sale/return/components/SaleReturnRefundEnableList.vue' +import { SaleOutVO } from '@/api/erp/sale/out' +import { ErpBizType } from '@/utils/constants' +import { SaleReturnVO } from '@/api/erp/sale/return' + +const props = defineProps<{ + items: undefined + customerId: undefined + disabled: false +}>() +const message = useMessage() + +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + receiptPrice: [{ required: true, message: '本次收款不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 初始化设置出库项 */ +watch( + () => props.items, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index: number) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['totalPrice', 'receiptedPrice', 'receiptPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + return sums +} + +/** 新增【销售出库】按钮操作 */ +const saleOutReceiptEnableListRef = ref() +const handleOpenSaleOut = () => { + if (!props.customerId) { + message.error('请选择客户') + return + } + saleOutReceiptEnableListRef.value.open(props.customerId) +} +const handleAddSaleOut = (rows: SaleOutVO[]) => { + rows.forEach((row) => { + formData.value.push({ + bizId: row.id, + bizType: ErpBizType.SALE_OUT, + bizNo: row.no, + totalPrice: row.totalPrice, + receiptedPrice: row.receiptPrice, + receiptPrice: row.totalPrice - row.receiptPrice + }) + }) +} + +/** 新增【销售退货】按钮操作 */ +const saleReturnRefundEnableListRef = ref() +const handleOpenSaleReturn = () => { + if (!props.customerId) { + message.error('请选择客户') + return + } + saleReturnRefundEnableListRef.value.open(props.customerId) +} +const handleAddSaleReturn = (rows: SaleReturnVO[]) => { + rows.forEach((row) => { + formData.value.push({ + bizId: row.id, + bizType: ErpBizType.SALE_RETURN, + bizNo: row.no, + totalPrice: -row.totalPrice, + receiptedPrice: -row.refundPrice, + receiptPrice: -row.totalPrice + row.refundPrice + }) + }) +} + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) +</script> diff --git a/src/views/erp/finance/receipt/index.vue b/src/views/erp/finance/receipt/index.vue new file mode 100644 index 0000000..1c8f82f --- /dev/null +++ b/src/views/erp/finance/receipt/index.vue @@ -0,0 +1,394 @@ +<template> + <doc-alert + title="【财务】采购付款、销售收款" + url="https://doc.iocoder.cn/sale/finance-payment-receipt/" + /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="收款单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入收款单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="收款时间" prop="receiptTime"> + <el-date-picker + v-model="queryParams.receiptTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="queryParams.supplierId" + clearable + filterable + placeholder="请选择供供应商" + class="!w-240px" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="财务人员" prop="financeUserId"> + <el-select + v-model="queryParams.financeUserId" + clearable + filterable + placeholder="请选择财务人员" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="收款账户" prop="accountId"> + <el-select + v-model="queryParams.accountId" + clearable + filterable + placeholder="请选择收款账户" + class="!w-240px" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="采购单号" prop="bizNo"> + <el-input + v-model="queryParams.bizNo" + placeholder="请输入采购单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:finance-receipt:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:finance-receipt:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:finance-receipt:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="收款单号" align="center" prop="no" /> + <el-table-column label="供应商" align="center" prop="supplierName" /> + <el-table-column + label="收款时间" + align="center" + prop="receiptTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column label="财务人员" align="center" prop="financeUserName" /> + <el-table-column label="收款账户" align="center" prop="accountName" /> + <el-table-column + label="合计收款" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="优惠金额" + align="center" + prop="discountPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="实际收款" + align="center" + prop="receiptPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:finance-receipt:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:finance-receipt:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:finance-receipt:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:finance-receipt:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:finance-receipt:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <FinanceReceiptForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { FinanceReceiptApi, FinanceReceiptVO } from '@/api/erp/finance/receipt' +import FinanceReceiptForm from './FinanceReceiptForm.vue' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { erpPriceTableColumnFormatter } from '@/utils' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' + +/** ERP 收款单列表 */ +defineOptions({ name: 'ErpPurchaseOrder' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<FinanceReceiptVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + receiptTime: [], + supplierId: undefined, + creator: undefined, + financeUserId: undefined, + accountId: undefined, + status: undefined, + remark: undefined, + bizNo: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 +const userList = ref<UserVO[]>([]) // 用户列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await FinanceReceiptApi.getFinanceReceiptPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await FinanceReceiptApi.deleteFinanceReceipt(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该收款单吗?`) + // 发起审批 + await FinanceReceiptApi.updateFinanceReceiptStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await FinanceReceiptApi.exportFinanceReceipt(queryParams) + download.excel(data, '收款单.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<FinanceReceiptVO[]>([]) +const handleSelectionChange = (rows: FinanceReceiptVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载供应商、用户、账户 + supplierList.value = await SupplierApi.getSupplierSimpleList() + userList.value = await UserApi.getSimpleUserList() + accountList.value = await AccountApi.getAccountSimpleList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/home/components/SummaryCard.vue b/src/views/erp/home/components/SummaryCard.vue new file mode 100644 index 0000000..21a02e2 --- /dev/null +++ b/src/views/erp/home/components/SummaryCard.vue @@ -0,0 +1,21 @@ +<template> + <div class="flex flex-col gap-2 bg-[var(--el-bg-color-overlay)] p-6"> + <div class="flex items-center justify-between text-gray-500"> + <span>{{ title }}</span> + </div> + <div class="flex flex-row items-baseline justify-between"> + <CountTo prefix="¥" :end-val="value" :decimals="2" :duration="500" class="text-3xl" /> + </div> + </div> +</template> +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' + +/** 价格展示 Card */ +defineOptions({ name: 'ErpSummaryCard' }) + +defineProps({ + title: propTypes.string.def('').isRequired, + value: propTypes.number.def(0).isRequired +}) +</script> diff --git a/src/views/erp/home/components/TimeSummaryChart.vue b/src/views/erp/home/components/TimeSummaryChart.vue new file mode 100644 index 0000000..127fa87 --- /dev/null +++ b/src/views/erp/home/components/TimeSummaryChart.vue @@ -0,0 +1,86 @@ +<template> + <el-card shadow="never"> + <template #header> + <CardTitle :title="props.title" /> + </template> + <!-- 折线图 --> + <Echart :height="300" :options="lineChartOptions" /> + </el-card> +</template> +<script lang="ts" setup> +import { EChartsOption } from 'echarts' +import { formatDate } from '@/utils/formatTime' +import { CardTitle } from '@/components/Card' +import { propTypes } from '@/utils/propTypes' + +/** 会员用户统计卡片 */ +defineOptions({ name: 'MemberStatisticsCard' }) + +const props = defineProps({ + title: propTypes.string.def('').isRequired, + value: propTypes.object.isRequired +}) + +/** 折线图配置 */ +const lineChartOptions = reactive<EChartsOption>({ + dataset: { + dimensions: ['time', 'price'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + top: 80, + containLabel: true + }, + legend: { + top: 50 + }, + series: [{ name: '金额', type: 'line', smooth: true, areaStyle: {} }], + toolbox: { + feature: { + // 数据区域缩放 + dataZoom: { + yAxisIndex: false // Y轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: props.title } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross' + }, + padding: [5, 10] + }, + xAxis: { + type: 'category', + boundaryGap: false, + axisTick: { + show: false + } + }, + yAxis: { + axisTick: { + show: false + } + } +}) as EChartsOption + +watch( + () => props.value, + (val) => { + if (!val) { + return + } + // 更新 Echarts 数据 + if (lineChartOptions.dataset && lineChartOptions.dataset['source']) { + lineChartOptions.dataset['source'] = val + } + } +) +</script> diff --git a/src/views/erp/home/index.vue b/src/views/erp/home/index.vue new file mode 100644 index 0000000..e399f9a --- /dev/null +++ b/src/views/erp/home/index.vue @@ -0,0 +1,93 @@ +<template> + <doc-alert title="ERP 手册(功能开启)" url="https://doc.iocoder.cn/erp/build/" /> + + <div class="flex flex-col"> + <!-- 销售/采购的全局统计 --> + <el-row :gutter="16" class="row"> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <SummaryCard title="今日销售" :value="saleSummary?.todayPrice" /> + </el-col> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <SummaryCard title="昨日销售" :value="saleSummary?.yesterdayPrice" /> + </el-col> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <SummaryCard title="今日采购" :value="purchaseSummary?.todayPrice" /> + </el-col> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <SummaryCard title="昨日采购" :value="purchaseSummary?.yesterdayPrice" /> + </el-col> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <SummaryCard title="本月销售" :value="saleSummary?.monthPrice" /> + </el-col> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <SummaryCard title="今年销售" :value="saleSummary?.yearPrice" /> + </el-col> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <SummaryCard title="本月采购" :value="purchaseSummary?.monthPrice" /> + </el-col> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <SummaryCard title="今年采购" :value="purchaseSummary?.yearPrice" /> + </el-col> + </el-row> + <!-- 销售/采购的时段统计 --> + <el-row :gutter="16" class="row"> + <!-- 销售统计 --> + <el-col :md="12" :sm="12" :xs="24" :loading="loading"> + <TimeSummaryChart title="销售统计" :value="saleTimeSummaryList" /> + </el-col> + <!-- 采购统计 --> + <el-col :md="12" :sm="12" :xs="24" :loading="loading"> + <TimeSummaryChart title="采购统计" :value="purchaseTimeSummaryList" /> + </el-col> + </el-row> + </div> +</template> +<script lang="ts" setup> +import SummaryCard from './components/SummaryCard.vue' +import TimeSummaryChart from './components/TimeSummaryChart.vue' +import { + ErpSaleSummaryRespVO, + ErpSaleTimeSummaryRespVO, + SaleStatisticsApi +} from '@/api/erp/statistics/sale' +import { + ErpPurchaseSummaryRespVO, + ErpPurchaseTimeSummaryRespVO, + PurchaseStatisticsApi +} from '@/api/erp/statistics/purchase' + +/** 商城首页 */ +defineOptions({ name: 'ErpHome' }) + +const loading = ref(true) // 加载中 + +/** 获得销售统计 */ +const saleSummary = ref<ErpSaleSummaryRespVO>() // 销售概况统计 +const saleTimeSummaryList = ref<ErpSaleTimeSummaryRespVO[]>() // 销售时段统计 +const getSaleSummary = async () => { + saleSummary.value = await SaleStatisticsApi.getSaleSummary() + saleTimeSummaryList.value = await SaleStatisticsApi.getSaleTimeSummary() +} + +/** 获得采购统计 */ +const purchaseSummary = ref<ErpPurchaseSummaryRespVO>() // 采购概况统计 +const purchaseTimeSummaryList = ref<ErpPurchaseTimeSummaryRespVO[]>() // 采购时段统计 +const getPurchaseSummary = async () => { + purchaseSummary.value = await PurchaseStatisticsApi.getPurchaseSummary() + purchaseTimeSummaryList.value = await PurchaseStatisticsApi.getPurchaseTimeSummary() +} + +/** 初始化 **/ +onMounted(async () => { + loading.value = true + await Promise.all([getSaleSummary(), getPurchaseSummary()]) + loading.value = false +}) +</script> +<style lang="scss" scoped> +.row { + .el-col { + margin-bottom: 1rem; + } +} +</style> diff --git a/src/views/erp/product/category/ProductCategoryForm.vue b/src/views/erp/product/category/ProductCategoryForm.vue new file mode 100644 index 0000000..cef420c --- /dev/null +++ b/src/views/erp/product/category/ProductCategoryForm.vue @@ -0,0 +1,145 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="上级编号" prop="parentId"> + <el-tree-select + v-model="formData.parentId" + :data="productCategoryTree" + :props="defaultProps" + check-strictly + default-expand-all + placeholder="请选择上级编号" + /> + </el-form-item> + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + <el-form-item label="编码" prop="code"> + <el-input v-model="formData.code" placeholder="请输入编码" /> + </el-form-item> + <el-form-item label="排序" prop="sort"> + <el-input v-model="formData.sort" placeholder="请输入排序" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { ProductCategoryApi, ProductCategoryVO } from '@/api/erp/product/category' +import { defaultProps, handleTree } from '@/utils/tree' +import { CommonStatusEnum } from '@/utils/constants' + +/** ERP 产品分类 表单 */ +defineOptions({ name: 'ProductCategoryForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + parentId: undefined, + name: undefined, + code: undefined, + sort: undefined, + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive({ + parentId: [{ required: true, message: '上级编号不能为空', trigger: 'blur' }], + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }], + code: [{ required: true, message: '编码不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const productCategoryTree = ref() // 树形结构 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ProductCategoryApi.getProductCategory(id) + } finally { + formLoading.value = false + } + } + await getProductCategoryTree() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ProductCategoryVO + if (formType.value === 'create') { + await ProductCategoryApi.createProductCategory(data) + message.success(t('common.createSuccess')) + } else { + await ProductCategoryApi.updateProductCategory(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + parentId: undefined, + name: undefined, + code: undefined, + sort: undefined, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} + +/** 获得产品分类树 */ +const getProductCategoryTree = async () => { + productCategoryTree.value = [] + const data = await ProductCategoryApi.getProductCategoryList() + const root: Tree = { id: 0, name: '顶级产品分类', children: [] } + root.children = handleTree(data, 'id', 'parentId') + productCategoryTree.value.push(root) +} +</script> diff --git a/src/views/erp/product/category/index.vue b/src/views/erp/product/category/index.vue new file mode 100644 index 0000000..281835d --- /dev/null +++ b/src/views/erp/product/category/index.vue @@ -0,0 +1,218 @@ +<template> + <doc-alert title="【产品】产品信息、分类、单位" url="https://doc.iocoder.cn/erp/product/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="分类名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入分类名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择开启状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:product-category:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:product-category:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button type="danger" plain @click="toggleExpandAll"> + <Icon icon="ep:sort" class="mr-5px" /> 展开/折叠 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + row-key="id" + :default-expand-all="isExpandAll" + v-if="refreshTable" + > + <el-table-column label="编码" align="center" prop="code" /> + <el-table-column label="名称" align="center" prop="name" /> + <el-table-column label="排序" align="center" prop="sort" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:product-category:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['erp:product-category:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ProductCategoryForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { handleTree } from '@/utils/tree' +import download from '@/utils/download' +import { ProductCategoryApi, ProductCategoryVO } from '@/api/erp/product/category' +import ProductCategoryForm from './ProductCategoryForm.vue' + +/** ERP 产品分类 列表 */ +defineOptions({ name: 'ErpProductCategory' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ProductCategoryVO[]>([]) // 列表的数据 +const queryParams = reactive({ + name: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductCategoryApi.getProductCategoryList(queryParams) + list.value = handleTree(data, 'id', 'parentId') + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ProductCategoryApi.deleteProductCategory(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ProductCategoryApi.exportProductCategory(queryParams) + download.excel(data, '产品分类.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 展开/折叠操作 */ +const isExpandAll = ref(true) // 是否展开,默认全部展开 +const refreshTable = ref(true) // 重新渲染表格状态 +const toggleExpandAll = async () => { + refreshTable.value = false + isExpandAll.value = !isExpandAll.value + await nextTick() + refreshTable.value = true +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/erp/product/product/ProductForm.vue b/src/views/erp/product/product/ProductForm.vue new file mode 100644 index 0000000..3f9de0a --- /dev/null +++ b/src/views/erp/product/product/ProductForm.vue @@ -0,0 +1,242 @@ +<!-- ERP 产品的新增/修改 --> +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-row :gutter="20"> + <el-col :span="12"> + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="条码" prop="barCode"> + <el-input v-model="formData.barCode" placeholder="请输入条码" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="分类" prop="categoryId"> + <el-tree-select + v-model="formData.categoryId" + :data="categoryList" + :props="defaultProps" + check-strictly + default-expand-all + placeholder="请选择分类" + class="w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="单位" prop="unitId"> + <el-select v-model="formData.unitId" clearable placeholder="请选择单位" class="w-1/1"> + <el-option + v-for="unit in unitList" + :key="unit.id" + :label="unit.name" + :value="unit.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="规格" prop="standard"> + <el-input v-model="formData.standard" placeholder="请输入规格" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="保质期天数" prop="expiryDay"> + <el-input-number + v-model="formData.expiryDay" + placeholder="请输入保质期天数" + :min="0" + :precision="0" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="重量(kg)" prop="weight"> + <el-input-number + v-model="formData.weight" + placeholder="请输入重量(kg)" + :min="0" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="采购价格" prop="purchasePrice"> + <el-input-number + v-model="formData.purchasePrice" + placeholder="请输入采购价格,单位:元" + :min="0" + :precision="2" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="销售价格" prop="salePrice"> + <el-input-number + v-model="formData.salePrice" + placeholder="请输入销售价格,单位:元" + :min="0" + :precision="2" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="最低价格" prop="minPrice"> + <el-input-number + v-model="formData.minPrice" + placeholder="请输入最低价格,单位:元" + :min="0" + :precision="2" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="备注" prop="remark"> + <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { ProductCategoryApi, ProductCategoryVO } from '@/api/erp/product/category' +import { ProductUnitApi, ProductUnitVO } from '@/api/erp/product/unit' +import { CommonStatusEnum } from '@/utils/constants' +import { defaultProps, handleTree } from '@/utils/tree' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' + +/** ERP 产品 表单 */ +defineOptions({ name: 'ProductForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + barCode: undefined, + categoryId: undefined, + unitId: undefined, + status: undefined, + standard: undefined, + remark: undefined, + expiryDay: undefined, + weight: undefined, + purchasePrice: undefined, + salePrice: undefined, + minPrice: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }], + barCode: [{ required: true, message: '产品条码不能为空', trigger: 'blur' }], + categoryId: [{ required: true, message: '产品分类编号不能为空', trigger: 'blur' }], + unitId: [{ required: true, message: '单位编号不能为空', trigger: 'blur' }], + status: [{ required: true, message: '产品状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const categoryList = ref<ProductCategoryVO[]>([]) // 产品分类列表 +const unitList = ref<ProductUnitVO[]>([]) // 产品单位列表 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ProductApi.getProduct(id) + } finally { + formLoading.value = false + } + } + // 产品分类 + const categoryData = await ProductCategoryApi.getProductCategorySimpleList() + categoryList.value = handleTree(categoryData, 'id', 'parentId') + // 产品单位 + unitList.value = await ProductUnitApi.getProductUnitSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ProductVO + if (formType.value === 'create') { + await ProductApi.createProduct(data) + message.success(t('common.createSuccess')) + } else { + await ProductApi.updateProduct(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + barCode: undefined, + categoryId: undefined, + unitId: undefined, + status: CommonStatusEnum.ENABLE, + standard: undefined, + remark: undefined, + expiryDay: undefined, + weight: undefined, + purchasePrice: undefined, + salePrice: undefined, + minPrice: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/product/product/index.vue b/src/views/erp/product/product/index.vue new file mode 100644 index 0000000..4eeba1e --- /dev/null +++ b/src/views/erp/product/product/index.vue @@ -0,0 +1,224 @@ +<!-- ERP 产品列表 --> +<template> + <doc-alert title="【产品】产品信息、分类、单位" url="https://doc.iocoder.cn/erp/product/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="分类" prop="categoryId"> + <el-tree-select + v-model="queryParams.categoryId" + :data="categoryList" + :props="defaultProps" + check-strictly + default-expand-all + placeholder="请输入分类" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:product:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:product:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="条码" align="center" prop="barCode" /> + <el-table-column label="名称" align="center" prop="name" /> + <el-table-column label="规格" align="center" prop="standard" /> + <el-table-column label="分类" align="center" prop="categoryName" /> + <el-table-column label="单位" align="center" prop="unitName" /> + <el-table-column + label="采购价格" + align="center" + prop="purchasePrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="销售价格" + align="center" + prop="salePrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="最低价格" + align="center" + prop="minPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="110"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:product:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['erp:product:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ProductForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { ProductCategoryApi, ProductCategoryVO } from '@/api/erp/product/category' +import ProductForm from './ProductForm.vue' +import { DICT_TYPE } from '@/utils/dict' +import { defaultProps, handleTree } from '@/utils/tree' +import { erpPriceTableColumnFormatter } from '@/utils' + +/** ERP 产品列表 */ +defineOptions({ name: 'ErpProduct' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ProductVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + categoryId: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const categoryList = ref<ProductCategoryVO[]>([]) // 产品分类列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductApi.getProductPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ProductApi.deleteProduct(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ProductApi.exportProduct(queryParams) + download.excel(data, '产品.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 产品分类 + const categoryData = await ProductCategoryApi.getProductCategorySimpleList() + categoryList.value = handleTree(categoryData, 'id', 'parentId') +}) +</script> diff --git a/src/views/erp/product/unit/ProductUnitForm.vue b/src/views/erp/product/unit/ProductUnitForm.vue new file mode 100644 index 0000000..ca14ff4 --- /dev/null +++ b/src/views/erp/product/unit/ProductUnitForm.vue @@ -0,0 +1,108 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="单位名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入单位名字" /> + </el-form-item> + <el-form-item label="单位状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { ProductUnitApi } from '@/api/erp/product/unit' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' + +/** ERP 产品单位表单 */ +defineOptions({ name: 'ProductUnitForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + status: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '单位名字不能为空', trigger: 'blur' }], + status: [{ required: true, message: '单位状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ProductUnitApi.getProductUnit(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ProductUnitApi.ProductUnitVO + if (formType.value === 'create') { + await ProductUnitApi.createProductUnit(data) + message.success(t('common.createSuccess')) + } else { + await ProductUnitApi.updateProductUnit(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/product/unit/index.vue b/src/views/erp/product/unit/index.vue new file mode 100644 index 0000000..04259ac --- /dev/null +++ b/src/views/erp/product/unit/index.vue @@ -0,0 +1,198 @@ +<template> + <doc-alert title="【产品】产品信息、分类、单位" url="https://doc.iocoder.cn/erp/product/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="单位名字" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入单位名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="单位状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择单位状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:product-unit:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:product-unit:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:product-unit:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['erp:product-unit:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ProductUnitForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import { ProductUnitApi, ProductUnitVO } from '@/api/erp/product/unit' +import ProductUnitForm from './ProductUnitForm.vue' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' + +/** ERP 产品单位列表 */ +defineOptions({ name: 'ErpProductUnit' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ProductUnitVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductUnitApi.getProductUnitPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ProductUnitApi.deleteProductUnit(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ProductUnitApi.exportProductUnit(queryParams) + download.excel(data, '产品单位.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/erp/purchase/in/PurchaseInForm.vue b/src/views/erp/purchase/in/PurchaseInForm.vue new file mode 100644 index 0000000..c59d7df --- /dev/null +++ b/src/views/erp/purchase/in/PurchaseInForm.vue @@ -0,0 +1,325 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1440"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="入库单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="入库时间" prop="inTime"> + <el-date-picker + v-model="formData.inTime" + type="date" + value-format="x" + placeholder="选择入库时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="关联订单" prop="orderNo"> + <el-input v-model="formData.orderNo" readonly> + <template #append> + <el-button @click="openPurchaseOrderInEnableList"> + <Icon icon="ep:search" /> 选择 + </el-button> + </template> + </el-input> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="formData.supplierId" + clearable + filterable + disabled + placeholder="请选择供应商" + class="!w-1/1" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="入库产品清单" name="item"> + <PurchaseInItemForm ref="itemFormRef" :items="formData.items" :disabled="disabled" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="优惠率(%)" prop="discountPercent"> + <el-input-number + v-model="formData.discountPercent" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入优惠率" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="付款优惠" prop="discountPrice"> + <el-input + disabled + v-model="formData.discountPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="优惠后金额"> + <el-input + disabled + :model-value="formData.totalPrice - formData.otherPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="其它费用" prop="otherPrice"> + <el-input-number + v-model="formData.otherPrice" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入其它费用" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="结算账户" prop="accountId"> + <el-select + v-model="formData.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-1/1" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="应付金额"> + <el-input disabled v-model="formData.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + + <!-- 可入库的订单列表 --> + <PurchaseOrderInEnableList + ref="purchaseOrderInEnableListRef" + @success="handlePurchaseOrderChange" + /> +</template> +<script setup lang="ts"> +import { PurchaseInApi, PurchaseInVO } from '@/api/erp/purchase/in' +import PurchaseInItemForm from './components/PurchaseInItemForm.vue' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' +import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils' +import PurchaseOrderInEnableList from '@/views/erp/purchase/order/components/PurchaseOrderInEnableList.vue' +import { PurchaseOrderVO } from '@/api/erp/purchase/order' +import * as UserApi from '@/api/system/user' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' + +/** ERP 销售入库表单 */ +defineOptions({ name: 'PurchaseInForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + supplierId: undefined, + accountId: undefined, + inTime: undefined, + remark: undefined, + fileUrl: '', + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + otherPrice: 0, + orderNo: undefined, + items: [], + no: undefined // 入库单号,后端返回 +}) +const formRules = reactive({ + supplierId: [{ required: true, message: '供应商不能为空', trigger: 'blur' }], + inTime: [{ required: true, message: '入库时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 计算 discountPrice、totalPrice 价格 */ +watch( + () => formData.value, + (val) => { + if (!val) { + return + } + // 计算 + const totalPrice = val.items.reduce((prev, curr) => prev + curr.totalPrice, 0) + const discountPrice = + val.discountPercent != null ? erpPriceMultiply(totalPrice, val.discountPercent / 100.0) : 0 + formData.value.discountPrice = discountPrice + formData.value.totalPrice = totalPrice - discountPrice + val.otherPrice + }, + { deep: true } +) + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await PurchaseInApi.getPurchaseIn(id) + } finally { + formLoading.value = false + } + } + // 加载供应商列表 + supplierList.value = await SupplierApi.getSupplierSimpleList() + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() + // 加载账户列表 + accountList.value = await AccountApi.getAccountSimpleList() + const defaultAccount = accountList.value.find((item) => item.defaultStatus) + if (defaultAccount) { + formData.value.accountId = defaultAccount.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 打开【可入库的订单列表】弹窗 */ +const purchaseOrderInEnableListRef = ref() // 可入库的订单列表 Ref +const openPurchaseOrderInEnableList = () => { + purchaseOrderInEnableListRef.value.open() +} + +const handlePurchaseOrderChange = (order: PurchaseOrderVO) => { + // 将订单设置到入库单 + formData.value.orderId = order.id + formData.value.orderNo = order.no + formData.value.supplierId = order.supplierId + formData.value.accountId = order.accountId + formData.value.discountPercent = order.discountPercent + formData.value.remark = order.remark + formData.value.fileUrl = order.fileUrl + // 将订单项设置到入库单项 + order.items.forEach((item) => { + item.totalCount = item.count + item.count = item.totalCount - item.inCount + item.orderItemId = item.id + item.id = undefined + }) + formData.value.items = order.items.filter((item) => item.count > 0) +} + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as PurchaseInVO + if (formType.value === 'create') { + await PurchaseInApi.createPurchaseIn(data) + message.success(t('common.createSuccess')) + } else { + await PurchaseInApi.updatePurchaseIn(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + supplierId: undefined, + accountId: undefined, + inTime: undefined, + remark: undefined, + fileUrl: undefined, + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + otherPrice: 0, + items: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/purchase/in/components/PurchaseInItemForm.vue b/src/views/erp/purchase/in/components/PurchaseInItemForm.vue new file mode 100644 index 0000000..64377bc --- /dev/null +++ b/src/views/erp/purchase/in/components/PurchaseInItemForm.vue @@ -0,0 +1,300 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="仓库名称" min-width="125"> + <template #default="{ row, $index }"> + <el-form-item + :prop="`${$index}.warehouseId`" + :rules="formRules.warehouseId" + class="mb-0px!" + > + <el-select + v-model="row.warehouseId" + clearable + filterable + placeholder="请选择仓库" + @change="onChangeWarehouse($event, row)" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="库存" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productBarCode" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productUnitName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column + label="原数量" + fixed="right" + min-width="80" + v-if="formData[0]?.totalCount != null" + > + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.totalCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column + label="已入库" + fixed="right" + min-width="80" + v-if="formData[0]?.inCount != null" + > + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.inCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品单价" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productPrice`" class="mb-0px!"> + <el-input-number + v-model="row.productPrice" + controls-position="right" + :min="0.01" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="金额" prop="totalProductPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalProductPrice`" class="mb-0px!"> + <el-input + disabled + v-model="row.totalProductPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税率(%)" fixed="right" min-width="115"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPercent`" class="mb-0px!"> + <el-input-number + v-model="row.taxPercent" + controls-position="right" + :min="0" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额" prop="taxPrice" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-input disabled v-model="row.taxPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额合计" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button :disabled="formData.length === 1" @click="handleDelete($index)" link> + — + </el-button> + </template> + </el-table-column> + </el-table> + </el-form> +</template> +<script setup lang="ts"> +import { StockApi } from '@/api/erp/stock/stock' +import { + erpCountInputFormatter, + erpPriceInputFormatter, + erpPriceMultiply, + getSumValue +} from '@/utils' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' + +const props = defineProps<{ + items: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + warehouseId: [{ required: true, message: '仓库不能为空', trigger: 'blur' }], + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const defaultWarehouse = ref<WarehouseVO>(undefined) // 默认仓库 + +/** 初始化设置入库项 */ +watch( + () => props.items, + async (val) => { + val.forEach((item) => { + if (item.warehouseId == null) { + item.warehouseId = defaultWarehouse.value?.id + } + if (item.stockCount === null && item.warehouseId != null) { + setStockCount(item) + } + }) + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + item.totalProductPrice = erpPriceMultiply(item.productPrice, item.count) + item.taxPrice = erpPriceMultiply(item.totalProductPrice, item.taxPercent / 100.0) + if (item.totalProductPrice != null) { + item.totalPrice = item.totalProductPrice + (item.taxPrice || 0) + } else { + item.totalPrice = undefined + } + }) + }, + { deep: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index: number) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['count', 'totalProductPrice', 'taxPrice', 'totalPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = + column.property === 'count' ? erpCountInputFormatter(sum) : erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + + return sums +} + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + productId: undefined, + productUnitName: undefined, // 产品单位 + productBarCode: undefined, // 产品条码 + productPrice: undefined, + stockCount: undefined, + count: 1, + totalProductPrice: undefined, + taxPercent: undefined, + taxPrice: undefined, + totalPrice: undefined, + remark: undefined + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} + +/** 加载库存 */ +const setStockCount = async (row: any) => { + if (!row.productId) { + return + } + const count = await StockApi.getStockCount(row.productId) + row.stockCount = count || 0 +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + defaultWarehouse.value = warehouseList.value.find((item) => item.defaultStatus) +}) +</script> diff --git a/src/views/erp/purchase/in/components/PurchaseInPaymentEnableList.vue b/src/views/erp/purchase/in/components/PurchaseInPaymentEnableList.vue new file mode 100644 index 0000000..afaa644 --- /dev/null +++ b/src/views/erp/purchase/in/components/PurchaseInPaymentEnableList.vue @@ -0,0 +1,199 @@ +<!-- 可付款的采购入库单列表 --> +<template> + <Dialog + title="选择采购入库(仅展示可付款)" + v-model="dialogVisible" + :appendToBody="true" + :scroll="true" + width="1080" + > + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="入库单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入入库单号" + clearable + @keyup.enter="handleQuery" + class="!w-160px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-160px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="入库时间" prop="orderTime"> + <el-date-picker + v-model="queryParams.inTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-160px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :show-overflow-tooltip="true" + :stripe="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="入库单号" align="center" prop="no" /> + <el-table-column label="供应商" align="center" prop="supplierName" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column + label="入库时间" + align="center" + prop="inTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="应付金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="已付金额" + align="center" + prop="paymentPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="未付金额" align="center"> + <template #default="scope"> + <span v-if="scope.row.paymentPrice === scope.row.totalPrice">0</span> + <el-tag type="danger" v-else> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.paymentPrice) }} + </el-tag> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button :disabled="!selectionList.length" type="primary" @click="submitForm"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { ElTable } from 'element-plus' +import { dateFormatter2 } from '@/utils/formatTime' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { PurchaseInApi, PurchaseInVO } from '@/api/erp/purchase/in' + +defineOptions({ name: 'PurchaseInPaymentEnableList' }) + +const list = ref<PurchaseInVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + inTime: [], + paymentEnable: true, + supplierId: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 选中操作 */ +const selectionList = ref<PurchaseInVO[]>([]) +const handleSelectionChange = (rows: PurchaseInVO[]) => { + selectionList.value = rows +} + +/** 打开弹窗 */ +const open = async (supplierId: number) => { + dialogVisible.value = true + await nextTick() // 等待,避免 queryFormRef 为空 + // 加载可入库的订单列表 + queryParams.supplierId = supplierId + await resetQuery() + // 加载产品列表 + productList.value = await ProductApi.getProductSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交选择 */ +const emits = defineEmits<{ + (e: 'success', value: PurchaseInVO[]): void +}>() +const submitForm = () => { + try { + emits('success', selectionList.value) + } finally { + // 关闭弹窗 + dialogVisible.value = false + } +} + +/** 加载列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PurchaseInApi.getPurchaseInPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + selectionList.value = [] + getList() +} +</script> diff --git a/src/views/erp/purchase/in/index.vue b/src/views/erp/purchase/in/index.vue new file mode 100644 index 0000000..ce8ecee --- /dev/null +++ b/src/views/erp/purchase/in/index.vue @@ -0,0 +1,443 @@ +<template> + <doc-alert title="【采购】采购订单、入库、退货" url="https://doc.iocoder.cn/erp/purchase/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="入库单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入入库单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="入库时间" prop="inTime"> + <el-date-picker + v-model="queryParams.inTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="queryParams.supplierId" + clearable + filterable + placeholder="请选择供供应商" + class="!w-240px" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="仓库" prop="warehouseId"> + <el-select + v-model="queryParams.warehouseId" + clearable + filterable + placeholder="请选择仓库" + class="!w-240px" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="关联订单" prop="orderNo"> + <el-input + v-model="queryParams.orderNo" + placeholder="请输入关联订单" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="结算账户" prop="accountId"> + <el-select + v-model="queryParams.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-240px" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="付款状态" prop="paymentStatus"> + <el-select + v-model="queryParams.paymentStatus" + placeholder="请选择有款状态" + clearable + class="!w-240px" + > + <el-option label="未付款" value="0" /> + <el-option label="部分付款" value="1" /> + <el-option label="全部付款" value="2" /> + </el-select> + </el-form-item> + <el-form-item label="审核状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择审核状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:purchase-in:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:purchase-in:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:purchase-in:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="入库单号" align="center" prop="no" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column label="供应商" align="center" prop="supplierName" /> + <el-table-column + label="入库时间" + align="center" + prop="inTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="总数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="应付金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="已付金额" + align="center" + prop="paymentPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="未付金额" align="center"> + <template #default="scope"> + <span v-if="scope.row.paymentPrice === scope.row.totalPrice">0</span> + <el-tag type="danger" v-else> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.paymentPrice) }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="审核状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:purchase-in:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:purchase-in:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:purchase-in:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:purchase-in:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:purchase-in:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <PurchaseInForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { PurchaseInApi, PurchaseInVO } from '@/api/erp/purchase/in' +import PurchaseInForm from './PurchaseInForm.vue' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { + erpCountTableColumnFormatter, + erpPriceInputFormatter, + erpPriceTableColumnFormatter +} from '@/utils' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' + +/** ERP 销售入库列表 */ +defineOptions({ name: 'ErpPurchaseIn' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<PurchaseInVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + supplierId: undefined, + productId: undefined, + warehouseId: undefined, + inTime: [], + orderNo: undefined, + paymentStatus: undefined, + accountId: undefined, + status: undefined, + remark: undefined, + creator: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 +const userList = ref<UserVO[]>([]) // 用户列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PurchaseInApi.getPurchaseInPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await PurchaseInApi.deletePurchaseIn(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该入库吗?`) + // 发起审批 + await PurchaseInApi.updatePurchaseInStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await PurchaseInApi.exportPurchaseIn(queryParams) + download.excel(data, '销售入库.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<PurchaseInVO[]>([]) +const handleSelectionChange = (rows: PurchaseInVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表、供应商 + productList.value = await ProductApi.getProductSimpleList() + supplierList.value = await SupplierApi.getSupplierSimpleList() + userList.value = await UserApi.getSimpleUserList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + accountList.value = await AccountApi.getAccountSimpleList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/purchase/order/PurchaseOrderForm.vue b/src/views/erp/purchase/order/PurchaseOrderForm.vue new file mode 100644 index 0000000..a7a6eec --- /dev/null +++ b/src/views/erp/purchase/order/PurchaseOrderForm.vue @@ -0,0 +1,269 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1080"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="订单单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="订单时间" prop="orderTime"> + <el-date-picker + v-model="formData.orderTime" + type="date" + value-format="x" + placeholder="选择订单时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="formData.supplierId" + clearable + filterable + placeholder="请选择供应商" + class="!w-1/1" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="订单产品清单" name="item"> + <PurchaseOrderItemForm ref="itemFormRef" :items="formData.items" :disabled="disabled" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="优惠率(%)" prop="discountPercent"> + <el-input-number + v-model="formData.discountPercent" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入优惠率" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="付款优惠" prop="discountPrice"> + <el-input + disabled + v-model="formData.discountPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="优惠后金额"> + <el-input disabled v-model="formData.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="结算账户" prop="accountId"> + <el-select + v-model="formData.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-1/1" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="支付订金" prop="depositPrice"> + <el-input-number + v-model="formData.depositPrice" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入支付订金" + class="!w-1/1" + /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { PurchaseOrderApi, PurchaseOrderVO } from '@/api/erp/purchase/order' +import PurchaseOrderItemForm from './components/PurchaseOrderItemForm.vue' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' +import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils' +import * as UserApi from '@/api/system/user' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' + +/** ERP 销售订单表单 */ +defineOptions({ name: 'PurchaseOrderForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + supplierId: undefined, + accountId: undefined, + orderTime: undefined, + remark: undefined, + fileUrl: '', + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + depositPrice: 0, + items: [], + no: undefined // 订单单号,后端返回 +}) +const formRules = reactive({ + supplierId: [{ required: true, message: '供应商不能为空', trigger: 'blur' }], + orderTime: [{ required: true, message: '订单时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 计算 discountPrice、totalPrice 价格 */ +watch( + () => formData.value, + (val) => { + if (!val) { + return + } + const totalPrice = val.items.reduce((prev, curr) => prev + curr.totalPrice, 0) + const discountPrice = + val.discountPercent != null ? erpPriceMultiply(totalPrice, val.discountPercent / 100.0) : 0 + formData.value.discountPrice = discountPrice + formData.value.totalPrice = totalPrice - discountPrice + }, + { deep: true } +) + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await PurchaseOrderApi.getPurchaseOrder(id) + } finally { + formLoading.value = false + } + } + // 加载供应商列表 + supplierList.value = await SupplierApi.getSupplierSimpleList() + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() + // 加载账户列表 + accountList.value = await AccountApi.getAccountSimpleList() + const defaultAccount = accountList.value.find((item) => item.defaultStatus) + if (defaultAccount) { + formData.value.accountId = defaultAccount.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as PurchaseOrderVO + if (formType.value === 'create') { + await PurchaseOrderApi.createPurchaseOrder(data) + message.success(t('common.createSuccess')) + } else { + await PurchaseOrderApi.updatePurchaseOrder(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + supplierId: undefined, + accountId: undefined, + orderTime: undefined, + remark: undefined, + fileUrl: undefined, + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + depositPrice: 0, + items: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/purchase/order/components/PurchaseOrderInEnableList.vue b/src/views/erp/purchase/order/components/PurchaseOrderInEnableList.vue new file mode 100644 index 0000000..e10694a --- /dev/null +++ b/src/views/erp/purchase/order/components/PurchaseOrderInEnableList.vue @@ -0,0 +1,205 @@ +<!-- 可入库的订单列表 --> +<template> + <Dialog + title="选择采购订单(仅展示可入库)" + v-model="dialogVisible" + :appendToBody="true" + :scroll="true" + width="1080" + > + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="订单单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入订单单号" + clearable + @keyup.enter="handleQuery" + class="!w-160px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-160px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="订单时间" prop="orderTime"> + <el-date-picker + v-model="queryParams.orderTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-160px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" width="65"> + <template #default="scope"> + <el-radio + :label="scope.row.id" + v-model="currentRowValue" + @change="handleCurrentChange(scope.row)" + > + + </el-radio> + </template> + </el-table-column> + <el-table-column min-width="180" label="订单单号" align="center" prop="no" /> + <el-table-column label="供应商" align="center" prop="supplierName" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column + label="订单时间" + align="center" + prop="orderTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="总数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="入库数量" + align="center" + prop="inCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="金额合计" + align="center" + prop="totalProductPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="含税金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button :disabled="!currentRow" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { ElTable } from 'element-plus' +import { PurchaseOrderApi, PurchaseOrderVO } from '@/api/erp/purchase/order' +import { dateFormatter2 } from '@/utils/formatTime' +import { erpCountTableColumnFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { ProductApi, ProductVO } from '@/api/erp/product/product' + +defineOptions({ name: 'ErpPurchaseOrderOutEnableList' }) + +const list = ref<PurchaseOrderVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + orderTime: [], + inEnable: true +}) +const queryFormRef = ref() // 搜索的表单 +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 选中行 */ +const currentRowValue = ref(undefined) // 选中行的 value +const currentRow = ref(undefined) // 选中行 +const handleCurrentChange = (row) => { + currentRow.value = row +} + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + await nextTick() // 等待,避免 queryFormRef 为空 + // 加载可入库的订单列表 + await resetQuery() + // 加载产品列表 + productList.value = await ProductApi.getProductSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交选择 */ +const emits = defineEmits<{ + (e: 'success', value: PurchaseOrderVO): void +}>() +const submitForm = () => { + try { + emits('success', currentRow.value) + } finally { + // 关闭弹窗 + dialogVisible.value = false + } +} + +/** 加载列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PurchaseOrderApi.getPurchaseOrderPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + currentRowValue.value = undefined + currentRow.value = undefined + getList() +} +</script> diff --git a/src/views/erp/purchase/order/components/PurchaseOrderItemForm.vue b/src/views/erp/purchase/order/components/PurchaseOrderItemForm.vue new file mode 100644 index 0000000..265193e --- /dev/null +++ b/src/views/erp/purchase/order/components/PurchaseOrderItemForm.vue @@ -0,0 +1,271 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!"> + <el-select + v-model="row.productId" + clearable + filterable + @change="onChangeProduct($event, row)" + placeholder="请选择产品" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="库存" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productBarCode" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productUnitName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品单价" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productPrice`" class="mb-0px!"> + <el-input-number + v-model="row.productPrice" + controls-position="right" + :min="0.01" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="金额" prop="totalProductPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalProductPrice`" class="mb-0px!"> + <el-input + disabled + v-model="row.totalProductPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税率(%)" fixed="right" min-width="115"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPercent`" class="mb-0px!"> + <el-input-number + v-model="row.taxPercent" + controls-position="right" + :min="0" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额" prop="taxPrice" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-input disabled v-model="row.taxPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额合计" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3" v-if="!disabled"> + <el-button @click="handleAdd" round>+ 添加采购产品</el-button> + </el-row> +</template> +<script setup lang="ts"> +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { StockApi } from '@/api/erp/stock/stock' +import { + erpCountInputFormatter, + erpPriceInputFormatter, + erpPriceMultiply, + getSumValue +} from '@/utils' + +const props = defineProps<{ + items: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 初始化设置入库项 */ +watch( + () => props.items, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + item.totalProductPrice = erpPriceMultiply(item.productPrice, item.count) + item.taxPrice = erpPriceMultiply(item.totalProductPrice, item.taxPercent / 100.0) + if (item.totalProductPrice != null) { + item.totalPrice = item.totalProductPrice + (item.taxPrice || 0) + } else { + item.totalPrice = undefined + } + }) + }, + { deep: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index: number) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['count', 'totalProductPrice', 'taxPrice', 'totalPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = + column.property === 'count' ? erpCountInputFormatter(sum) : erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + + return sums +} + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + productId: undefined, + productUnitName: undefined, // 产品单位 + productBarCode: undefined, // 产品条码 + productPrice: undefined, + stockCount: undefined, + count: 1, + totalProductPrice: undefined, + taxPercent: undefined, + taxPrice: undefined, + totalPrice: undefined, + remark: undefined + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} + +/** 处理产品变更 */ +const onChangeProduct = (productId, row) => { + const product = productList.value.find((item) => item.id === productId) + if (product) { + row.productUnitName = product.unitName + row.productBarCode = product.barCode + row.productPrice = product.purchasePrice + } + // 加载库存 + setStockCount(row) +} + +/** 加载库存 */ +const setStockCount = async (row: any) => { + if (!row.productId) { + return + } + const count = await StockApi.getStockCount(row.productId) + row.stockCount = count || 0 +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + productList.value = await ProductApi.getProductSimpleList() + // 默认添加一个 + if (formData.value.length === 0) { + handleAdd() + } +}) +</script> diff --git a/src/views/erp/purchase/order/components/PurchaseOrderReturnEnableList.vue b/src/views/erp/purchase/order/components/PurchaseOrderReturnEnableList.vue new file mode 100644 index 0000000..cac2bbc --- /dev/null +++ b/src/views/erp/purchase/order/components/PurchaseOrderReturnEnableList.vue @@ -0,0 +1,212 @@ +<!-- 可退货的订单列表 --> +<template> + <Dialog + title="选择采购订单(仅展示可退货)" + v-model="dialogVisible" + :appendToBody="true" + :scroll="true" + width="1080" + > + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="订单单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入订单单号" + clearable + @keyup.enter="handleQuery" + class="!w-160px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-160px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="订单时间" prop="orderTime"> + <el-date-picker + v-model="queryParams.orderTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-160px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" width="65"> + <template #default="scope"> + <el-radio + :label="scope.row.id" + v-model="currentRowValue" + @change="handleCurrentChange(scope.row)" + > + + </el-radio> + </template> + </el-table-column> + <el-table-column min-width="180" label="订单单号" align="center" prop="no" /> + <el-table-column label="供应商" align="center" prop="supplierName" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column + label="订单时间" + align="center" + prop="orderTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="总数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="入库数量" + align="center" + prop="inCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="退货数量" + align="center" + prop="returnCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="金额合计" + align="center" + prop="totalProductPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="含税金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button :disabled="!currentRow" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> + +<script lang="ts" setup> +import { ElTable } from 'element-plus' +import { PurchaseOrderApi, PurchaseOrderVO } from '@/api/erp/purchase/order' +import { dateFormatter2 } from '@/utils/formatTime' +import { erpCountTableColumnFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { ProductApi, ProductVO } from '@/api/erp/product/product' + +defineOptions({ name: 'PurchaseOrderReturnEnableList' }) + +const list = ref<PurchaseOrderVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + orderTime: [], + returnEnable: true +}) +const queryFormRef = ref() // 搜索的表单 +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 选中行 */ +const currentRowValue = ref(undefined) // 选中行的 value +const currentRow = ref(undefined) // 选中行 +const handleCurrentChange = (row) => { + currentRow.value = row +} + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + await nextTick() // 等待,避免 queryFormRef 为空 + // 加载可退货的订单列表 + await resetQuery() + // 加载产品列表 + productList.value = await ProductApi.getProductSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交选择 */ +const emits = defineEmits<{ + (e: 'success', value: PurchaseOrderVO): void +}>() +const submitForm = () => { + try { + emits('success', currentRow.value) + } finally { + // 关闭弹窗 + dialogVisible.value = false + } +} + +/** 加载列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PurchaseOrderApi.getPurchaseOrderPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + currentRowValue.value = undefined + currentRow.value = undefined + getList() +} +</script> diff --git a/src/views/erp/purchase/order/index.vue b/src/views/erp/purchase/order/index.vue new file mode 100644 index 0000000..f179fa9 --- /dev/null +++ b/src/views/erp/purchase/order/index.vue @@ -0,0 +1,407 @@ +<template> + <doc-alert title="【采购】采购订单、入库、退货" url="https://doc.iocoder.cn/erp/purchase/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="订单单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入订单单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="订单时间" prop="orderTime"> + <el-date-picker + v-model="queryParams.orderTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="queryParams.supplierId" + clearable + filterable + placeholder="请选择供供应商" + class="!w-240px" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="入库数量" prop="inStatus"> + <el-select + v-model="queryParams.inStatus" + placeholder="请选择入库数量" + clearable + class="!w-240px" + > + <el-option label="未入库" value="0" /> + <el-option label="部分入库" value="1" /> + <el-option label="全部入库" value="2" /> + </el-select> + </el-form-item> + <el-form-item label="退货数量" prop="returnStatus"> + <el-select + v-model="queryParams.returnStatus" + placeholder="请选择退货数量" + clearable + class="!w-240px" + > + <el-option label="未退货" value="0" /> + <el-option label="部分退货" value="1" /> + <el-option label="全部退货" value="2" /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:purchase-order:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:purchase-order:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:purchase-order:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="订单单号" align="center" prop="no" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column label="供应商" align="center" prop="supplierName" /> + <el-table-column + label="订单时间" + align="center" + prop="orderTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="总数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="入库数量" + align="center" + prop="inCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="退货数量" + align="center" + prop="returnCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="金额合计" + align="center" + prop="totalProductPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="含税金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="支付订金" + align="center" + prop="depositPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:purchase-order:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:purchase-order:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:purchase-order:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:purchase-order:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:purchase-order:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <PurchaseOrderForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { PurchaseOrderApi, PurchaseOrderVO } from '@/api/erp/purchase/order' +import PurchaseOrderForm from './PurchaseOrderForm.vue' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { erpCountTableColumnFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' + +/** ERP 销售订单列表 */ +defineOptions({ name: 'ErpPurchaseOrder' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<PurchaseOrderVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + supplierId: undefined, + productId: undefined, + orderTime: [], + status: undefined, + remark: undefined, + creator: undefined, + inStatus: undefined, + returnStatus: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 +const userList = ref<UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PurchaseOrderApi.getPurchaseOrderPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await PurchaseOrderApi.deletePurchaseOrder(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该订单吗?`) + // 发起审批 + await PurchaseOrderApi.updatePurchaseOrderStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await PurchaseOrderApi.exportPurchaseOrder(queryParams) + download.excel(data, '销售订单.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<PurchaseOrderVO[]>([]) +const handleSelectionChange = (rows: PurchaseOrderVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表、供应商 + productList.value = await ProductApi.getProductSimpleList() + supplierList.value = await SupplierApi.getSupplierSimpleList() + userList.value = await UserApi.getSimpleUserList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/purchase/return/PurchaseReturnForm.vue b/src/views/erp/purchase/return/PurchaseReturnForm.vue new file mode 100644 index 0000000..e37fa09 --- /dev/null +++ b/src/views/erp/purchase/return/PurchaseReturnForm.vue @@ -0,0 +1,328 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1440"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="退货单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="退货时间" prop="returnTime"> + <el-date-picker + v-model="formData.returnTime" + type="date" + value-format="x" + placeholder="选择退货时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="关联订单" prop="orderNo"> + <el-input v-model="formData.orderNo" readonly> + <template #append> + <el-button @click="openPurchaseOrderReturnEnableList"> + <Icon icon="ep:search" /> 选择 + </el-button> + </template> + </el-input> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="formData.supplierId" + clearable + filterable + disabled + placeholder="请选择供应商" + class="!w-1/1" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="退货产品清单" name="item"> + <PurchaseReturnItemForm + ref="itemFormRef" + :items="formData.items" + :disabled="disabled" + /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="优惠率(%)" prop="discountPercent"> + <el-input-number + v-model="formData.discountPercent" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入优惠率" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="退款优惠" prop="discountPrice"> + <el-input + disabled + v-model="formData.discountPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="优惠后金额"> + <el-input + disabled + :model-value="formData.totalPrice - formData.otherPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="其它费用" prop="otherPrice"> + <el-input-number + v-model="formData.otherPrice" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入其它费用" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="结算账户" prop="accountId"> + <el-select + v-model="formData.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-1/1" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="应退金额" prop="totalPrice"> + <el-input disabled v-model="formData.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + + <!-- 可退货的订单列表 --> + <PurchaseOrderReturnEnableList + ref="purchaseOrderReturnEnableListRef" + @success="handlePurchaseOrderChange" + /> +</template> +<script setup lang="ts"> +import { PurchaseReturnApi, PurchaseReturnVO } from '@/api/erp/purchase/return' +import PurchaseReturnItemForm from './components/PurchaseReturnItemForm.vue' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' +import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils' +import PurchaseOrderReturnEnableList from '@/views/erp/purchase/order/components/PurchaseOrderReturnEnableList.vue' +import { PurchaseOrderVO } from '@/api/erp/purchase/order' +import * as UserApi from '@/api/system/user' + +/** ERP 采购退货表单 */ +defineOptions({ name: 'PurchaseReturnForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + supplierId: undefined, + accountId: undefined, + returnTime: undefined, + remark: undefined, + fileUrl: '', + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + otherPrice: 0, + orderNo: undefined, + items: [], + no: undefined // 退货单号,后端返回 +}) +const formRules = reactive({ + supplierId: [{ required: true, message: '供应商不能为空', trigger: 'blur' }], + returnTime: [{ required: true, message: '退货时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 计算 discountPrice、totalPrice 价格 */ +watch( + () => formData.value, + (val) => { + if (!val) { + return + } + // 计算 + const totalPrice = val.items.reduce((prev, curr) => prev + curr.totalPrice, 0) + const discountPrice = + val.discountPercent != null ? erpPriceMultiply(totalPrice, val.discountPercent / 100.0) : 0 + formData.value.discountPrice = discountPrice + formData.value.totalPrice = totalPrice - discountPrice + val.otherPrice + }, + { deep: true } +) + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await PurchaseReturnApi.getPurchaseReturn(id) + } finally { + formLoading.value = false + } + } + // 加载供应商列表 + supplierList.value = await SupplierApi.getSupplierSimpleList() + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() + // 加载账户列表 + accountList.value = await AccountApi.getAccountSimpleList() + const defaultAccount = accountList.value.find((item) => item.defaultStatus) + if (defaultAccount) { + formData.value.accountId = defaultAccount.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 打开【可退货的订单列表】弹窗 */ +const purchaseOrderReturnEnableListRef = ref() // 可退货的订单列表 Ref +const openPurchaseOrderReturnEnableList = () => { + purchaseOrderReturnEnableListRef.value.open() +} + +const handlePurchaseOrderChange = (order: PurchaseOrderVO) => { + // 将订单设置到退货单 + formData.value.orderId = order.id + formData.value.orderNo = order.no + formData.value.supplierId = order.supplierId + formData.value.accountId = order.accountId + formData.value.discountPercent = order.discountPercent + formData.value.remark = order.remark + formData.value.fileUrl = order.fileUrl + // 将订单项设置到退货单项 + order.items.forEach((item) => { + item.count = item.inCount - item.returnCount + item.orderItemId = item.id + item.id = undefined + }) + formData.value.items = order.items.filter((item) => item.count > 0) +} + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as PurchaseReturnVO + if (formType.value === 'create') { + await PurchaseReturnApi.createPurchaseReturn(data) + message.success(t('common.createSuccess')) + } else { + await PurchaseReturnApi.updatePurchaseReturn(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + supplierId: undefined, + accountId: undefined, + returnTime: undefined, + remark: undefined, + fileUrl: undefined, + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + otherPrice: 0, + items: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/purchase/return/components/PurchaseReturnItemForm.vue b/src/views/erp/purchase/return/components/PurchaseReturnItemForm.vue new file mode 100644 index 0000000..2d3e8c5 --- /dev/null +++ b/src/views/erp/purchase/return/components/PurchaseReturnItemForm.vue @@ -0,0 +1,300 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="仓库名称" min-width="125"> + <template #default="{ row, $index }"> + <el-form-item + :prop="`${$index}.warehouseId`" + :rules="formRules.warehouseId" + class="mb-0px!" + > + <el-select + v-model="row.warehouseId" + clearable + filterable + placeholder="请选择仓库" + @change="onChangeWarehouse($event, row)" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="库存" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productBarCode" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productUnitName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column + label="已出库" + fixed="right" + min-width="80" + v-if="formData[0]?.inCount != null" + > + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.inCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column + label="已退货" + fixed="right" + min-width="80" + v-if="formData[0]?.returnCount != null" + > + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.returnCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品单价" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productPrice`" class="mb-0px!"> + <el-input-number + v-model="row.productPrice" + controls-position="right" + :min="0.01" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="金额" prop="totalProductPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalProductPrice`" class="mb-0px!"> + <el-input + disabled + v-model="row.totalProductPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税率(%)" fixed="right" min-width="115"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPercent`" class="mb-0px!"> + <el-input-number + v-model="row.taxPercent" + controls-position="right" + :min="0" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额" prop="taxPrice" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-input disabled v-model="row.taxPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额合计" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button :disabled="formData.length === 1" @click="handleDelete($index)" link> + — + </el-button> + </template> + </el-table-column> + </el-table> + </el-form> +</template> +<script setup lang="ts"> +import { StockApi } from '@/api/erp/stock/stock' +import { + erpCountInputFormatter, + erpPriceInputFormatter, + erpPriceMultiply, + getSumValue +} from '@/utils' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' + +const props = defineProps<{ + items: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + warehouseId: [{ required: true, message: '仓库不能为空', trigger: 'blur' }], + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const defaultWarehouse = ref<WarehouseVO>(undefined) // 默认仓库 + +/** 初始化设置出库项 */ +watch( + () => props.items, + async (val) => { + val.forEach((item) => { + if (item.warehouseId == null) { + item.warehouseId = defaultWarehouse.value?.id + } + if (item.stockCount === null && item.warehouseId != null) { + setStockCount(item) + } + }) + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + item.totalProductPrice = erpPriceMultiply(item.productPrice, item.count) + item.taxPrice = erpPriceMultiply(item.totalProductPrice, item.taxPercent / 100.0) + if (item.totalProductPrice != null) { + item.totalPrice = item.totalProductPrice + (item.taxPrice || 0) + } else { + item.totalPrice = undefined + } + }) + }, + { deep: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index: number) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['count', 'totalProductPrice', 'taxPrice', 'totalPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = + column.property === 'count' ? erpCountInputFormatter(sum) : erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + + return sums +} + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + productId: undefined, + productUnitName: undefined, // 产品单位 + productBarCode: undefined, // 产品条码 + productPrice: undefined, + stockCount: undefined, + count: 1, + totalProductPrice: undefined, + taxPercent: undefined, + taxPrice: undefined, + totalPrice: undefined, + remark: undefined + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} + +/** 加载库存 */ +const setStockCount = async (row: any) => { + if (!row.productId) { + return + } + const count = await StockApi.getStockCount(row.productId) + row.stockCount = count || 0 +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + defaultWarehouse.value = warehouseList.value.find((item) => item.defaultStatus) +}) +</script> diff --git a/src/views/erp/purchase/return/components/PurchaseReturnRefundEnableList.vue b/src/views/erp/purchase/return/components/PurchaseReturnRefundEnableList.vue new file mode 100644 index 0000000..a95749e --- /dev/null +++ b/src/views/erp/purchase/return/components/PurchaseReturnRefundEnableList.vue @@ -0,0 +1,200 @@ +<!-- 可退款的采购退货单列表 --> +<template> + <Dialog + title="选择采购退货(仅展示可退款)" + v-model="dialogVisible" + :appendToBody="true" + :scroll="true" + width="1080" + > + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="退货单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入退货单号" + clearable + @keyup.enter="handleQuery" + class="!w-160px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-160px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="退货时间" prop="orderTime"> + <el-date-picker + v-model="queryParams.returnTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-160px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :show-overflow-tooltip="true" + :stripe="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="退货单号" align="center" prop="no" /> + <el-table-column label="供应商" align="center" prop="supplierName" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column + label="退货时间" + align="center" + prop="returnTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="应退金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="已退金额" + align="center" + prop="refundPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="未退金额" align="center"> + <template #default="scope"> + <span v-if="scope.row.refundPrice === scope.row.totalPrice">0</span> + <el-tag type="danger" v-else> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.refundPrice) }} + </el-tag> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button :disabled="!selectionList.length" type="primary" @click="submitForm"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { ElTable } from 'element-plus' +import { dateFormatter2 } from '@/utils/formatTime' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { PurchaseReturnApi, PurchaseReturnVO } from '@/api/erp/purchase/return' +import { SaleReturnVO } from '@/api/erp/sale/return' + +defineOptions({ name: 'PurchaseInPaymentEnableList' }) + +const list = ref<PurchaseReturnVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + returnTime: [], + refundEnable: true, + supplierId: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 选中操作 */ +const selectionList = ref<SaleReturnVO[]>([]) +const handleSelectionChange = (rows: SaleReturnVO[]) => { + selectionList.value = rows +} + +/** 打开弹窗 */ +const open = async (supplierId: number) => { + dialogVisible.value = true + await nextTick() // 等待,避免 queryFormRef 为空 + // 加载列表 + queryParams.supplierId = supplierId + await resetQuery() + // 加载产品列表 + productList.value = await ProductApi.getProductSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交选择 */ +const emits = defineEmits<{ + (e: 'success', value: SaleReturnVO[]): void +}>() +const submitForm = () => { + try { + emits('success', selectionList.value) + } finally { + // 关闭弹窗 + dialogVisible.value = false + } +} + +/** 加载列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PurchaseReturnApi.getPurchaseReturnPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + selectionList.value = [] + getList() +} +</script> diff --git a/src/views/erp/purchase/return/index.vue b/src/views/erp/purchase/return/index.vue new file mode 100644 index 0000000..545d18a --- /dev/null +++ b/src/views/erp/purchase/return/index.vue @@ -0,0 +1,443 @@ +<template> + <doc-alert title="【采购】采购订单、入库、退货" url="https://doc.iocoder.cn/erp/purchase/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="退货单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入退货单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="退货时间" prop="inTime"> + <el-date-picker + v-model="queryParams.inTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="queryParams.supplierId" + clearable + filterable + placeholder="请选择供供应商" + class="!w-240px" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="仓库" prop="warehouseId"> + <el-select + v-model="queryParams.warehouseId" + clearable + filterable + placeholder="请选择仓库" + class="!w-240px" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="关联订单" prop="orderNo"> + <el-input + v-model="queryParams.orderNo" + placeholder="请输入关联订单" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="结算账户" prop="accountId"> + <el-select + v-model="queryParams.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-240px" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="退款状态" prop="refundStatus"> + <el-select + v-model="queryParams.refundStatus" + placeholder="请选择退款状态" + clearable + class="!w-240px" + > + <el-option label="未退款" value="0" /> + <el-option label="部分退款" value="1" /> + <el-option label="全部退款" value="2" /> + </el-select> + </el-form-item> + <el-form-item label="审核状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择审核状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:purchase-return:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:purchase-return:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:purchase-return:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="退货单号" align="center" prop="no" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column label="供应商" align="center" prop="supplierName" /> + <el-table-column + label="退货时间" + align="center" + prop="returnTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="总数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="应退金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="已退金额" + align="center" + prop="refundPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="未退金额" align="center"> + <template #default="scope"> + <span v-if="scope.row.refundPrice === scope.row.totalPrice">0</span> + <el-tag type="danger" v-else> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.refundPrice) }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="审核状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:purchase-return:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:purchase-return:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:purchase-return:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:purchase-return:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:purchase-return:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <PurchaseReturnForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { PurchaseReturnApi, PurchaseReturnVO } from '@/api/erp/purchase/return' +import PurchaseReturnForm from './PurchaseReturnForm.vue' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { + erpCountTableColumnFormatter, + erpPriceInputFormatter, + erpPriceTableColumnFormatter +} from '@/utils' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' + +/** ERP 采购退货列表 */ +defineOptions({ name: 'ErpPurchaseReturn' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<PurchaseReturnVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + supplierId: undefined, + productId: undefined, + warehouseId: undefined, + returnTime: [], + orderNo: undefined, + accountId: undefined, + status: undefined, + refundStatus: undefined, + remark: undefined, + creator: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 +const userList = ref<UserVO[]>([]) // 用户列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PurchaseReturnApi.getPurchaseReturnPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await PurchaseReturnApi.deletePurchaseReturn(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该退货吗?`) + // 发起审批 + await PurchaseReturnApi.updatePurchaseReturnStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await PurchaseReturnApi.exportPurchaseReturn(queryParams) + download.excel(data, '采购退货.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<PurchaseReturnVO[]>([]) +const handleSelectionChange = (rows: PurchaseReturnVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表、供应商 + productList.value = await ProductApi.getProductSimpleList() + supplierList.value = await SupplierApi.getSupplierSimpleList() + userList.value = await UserApi.getSimpleUserList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + accountList.value = await AccountApi.getAccountSimpleList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/purchase/supplier/SupplierForm.vue b/src/views/erp/purchase/supplier/SupplierForm.vue new file mode 100644 index 0000000..d3c433c --- /dev/null +++ b/src/views/erp/purchase/supplier/SupplierForm.vue @@ -0,0 +1,210 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-row :gutter="20"> + <el-col :span="12"> + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="联系人" prop="contact"> + <el-input v-model="formData.contact" placeholder="请输入联系人" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="手机号码" prop="mobile"> + <el-input v-model="formData.mobile" placeholder="请输入手机号码" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="联系电话" prop="telephone"> + <el-input v-model="formData.telephone" placeholder="请输入联系电话" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="电子邮箱" prop="email"> + <el-input v-model="formData.email" placeholder="请输入电子邮箱" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="传真" prop="fax"> + <el-input v-model="formData.fax" placeholder="请输入传真" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="排序" prop="sort"> + <el-input-number + v-model="formData.sort" + placeholder="请输入排序" + class="!w-1/1" + :precision="0" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="纳税人识别号" prop="taxNo"> + <el-input v-model="formData.taxNo" placeholder="请输入纳税人识别号" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="税率(%)" prop="taxPercent"> + <el-input-number + v-model="formData.taxPercent" + :min="0" + :precision="2" + placeholder="请输入税率" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="开户行" prop="bankName"> + <el-input v-model="formData.bankName" placeholder="请输入开户行" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="开户账号" prop="bankAccount"> + <el-input v-model="formData.bankAccount" placeholder="请输入开户账号" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="开户地址" prop="bankAddress"> + <el-input v-model="formData.bankAddress" placeholder="请输入开户地址" /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="备注" prop="remark"> + <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' +import { CommonStatusEnum } from '@/utils/constants' + +/** ERP 表单 */ +defineOptions({ name: 'SupplierForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + contact: undefined, + mobile: undefined, + telephone: undefined, + email: undefined, + fax: undefined, + remark: undefined, + status: undefined, + sort: undefined, + taxNo: undefined, + taxPercent: undefined, + bankName: undefined, + bankAccount: undefined, + bankAddress: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await SupplierApi.getSupplier(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as SupplierVO + if (formType.value === 'create') { + await SupplierApi.createSupplier(data) + message.success(t('common.createSuccess')) + } else { + await SupplierApi.updateSupplier(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + contact: undefined, + mobile: undefined, + telephone: undefined, + email: undefined, + fax: undefined, + remark: undefined, + status: CommonStatusEnum.ENABLE, + sort: undefined, + taxNo: undefined, + taxPercent: undefined, + bankName: undefined, + bankAccount: undefined, + bankAddress: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/purchase/supplier/index.vue b/src/views/erp/purchase/supplier/index.vue new file mode 100644 index 0000000..4d3a405 --- /dev/null +++ b/src/views/erp/purchase/supplier/index.vue @@ -0,0 +1,201 @@ +<template> + <doc-alert title="【采购】采购订单、入库、退货" url="https://doc.iocoder.cn/erp/purchase/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="手机号码" prop="mobile"> + <el-input + v-model="queryParams.mobile" + placeholder="请输入手机号码" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="联系电话" prop="telephone"> + <el-input + v-model="queryParams.telephone" + placeholder="请输入联系电话" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:supplier:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:supplier:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="名称" align="center" prop="name" /> + <el-table-column label="联系人" align="center" prop="contact" /> + <el-table-column label="手机号码" align="center" prop="mobile" /> + <el-table-column label="联系电话" align="center" prop="telephone" /> + <el-table-column label="电子邮箱" align="center" prop="email" /> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column label="排序" align="center" prop="sort" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:supplier:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['erp:supplier:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SupplierForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' +import SupplierForm from './SupplierForm.vue' + +/** ERP 供应商 列表 */ +defineOptions({ name: 'ErpSupplier' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<SupplierVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + mobile: undefined, + telephone: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SupplierApi.getSupplierPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await SupplierApi.deleteSupplier(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await SupplierApi.exportSupplier(queryParams) + download.excel(data, 'ERP 供应商.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/erp/sale/customer/CustomerForm.vue b/src/views/erp/sale/customer/CustomerForm.vue new file mode 100644 index 0000000..da6e004 --- /dev/null +++ b/src/views/erp/sale/customer/CustomerForm.vue @@ -0,0 +1,210 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-row :gutter="20"> + <el-col :span="12"> + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="联系人" prop="contact"> + <el-input v-model="formData.contact" placeholder="请输入联系人" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="手机号码" prop="mobile"> + <el-input v-model="formData.mobile" placeholder="请输入手机号码" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="联系电话" prop="telephone"> + <el-input v-model="formData.telephone" placeholder="请输入联系电话" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="电子邮箱" prop="email"> + <el-input v-model="formData.email" placeholder="请输入电子邮箱" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="传真" prop="fax"> + <el-input v-model="formData.fax" placeholder="请输入传真" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="排序" prop="sort"> + <el-input-number + v-model="formData.sort" + placeholder="请输入排序" + class="!w-1/1" + :precision="0" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="纳税人识别号" prop="taxNo"> + <el-input v-model="formData.taxNo" placeholder="请输入纳税人识别号" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="税率(%)" prop="taxPercent"> + <el-input-number + v-model="formData.taxPercent" + :min="0" + :precision="2" + placeholder="请输入税率" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="开户行" prop="bankName"> + <el-input v-model="formData.bankName" placeholder="请输入开户行" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="开户账号" prop="bankAccount"> + <el-input v-model="formData.bankAccount" placeholder="请输入开户账号" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="开户地址" prop="bankAddress"> + <el-input v-model="formData.bankAddress" placeholder="请输入开户地址" /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="备注" prop="remark"> + <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' +import { CommonStatusEnum } from '@/utils/constants' + +/** ERP 客户 表单 */ +defineOptions({ name: 'CustomerForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + contact: undefined, + mobile: undefined, + telephone: undefined, + email: undefined, + fax: undefined, + remark: undefined, + status: undefined, + sort: undefined, + taxNo: undefined, + taxPercent: undefined, + bankName: undefined, + bankAccount: undefined, + bankAddress: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '客户名称不能为空', trigger: 'blur' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await CustomerApi.getCustomer(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as CustomerVO + if (formType.value === 'create') { + await CustomerApi.createCustomer(data) + message.success(t('common.createSuccess')) + } else { + await CustomerApi.updateCustomer(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + contact: undefined, + mobile: undefined, + telephone: undefined, + email: undefined, + fax: undefined, + remark: undefined, + status: CommonStatusEnum.ENABLE, + sort: undefined, + taxNo: undefined, + taxPercent: undefined, + bankName: undefined, + bankAccount: undefined, + bankAddress: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/sale/customer/index.vue b/src/views/erp/sale/customer/index.vue new file mode 100644 index 0000000..c79bbe8 --- /dev/null +++ b/src/views/erp/sale/customer/index.vue @@ -0,0 +1,201 @@ +<template> + <doc-alert title="【销售】销售订单、出库、退货" url="https://doc.iocoder.cn/erp/sale/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="手机号码" prop="mobile"> + <el-input + v-model="queryParams.mobile" + placeholder="请输入手机号码" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="联系电话" prop="telephone"> + <el-input + v-model="queryParams.telephone" + placeholder="请输入联系电话" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:customer:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:customer:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="名称" align="center" prop="name" /> + <el-table-column label="联系人" align="center" prop="contact" /> + <el-table-column label="手机号码" align="center" prop="mobile" /> + <el-table-column label="联系电话" align="center" prop="telephone" /> + <el-table-column label="电子邮箱" align="center" prop="email" /> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column label="排序" align="center" prop="sort" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:customer:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['erp:customer:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <CustomerForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' +import CustomerForm from './CustomerForm.vue' + +/** ERP 客户 列表 */ +defineOptions({ name: 'ErpCustomer' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<CustomerVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + mobile: undefined, + telephone: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CustomerApi.getCustomerPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await CustomerApi.deleteCustomer(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await CustomerApi.exportCustomer(queryParams) + download.excel(data, '客户.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/erp/sale/order/SaleOrderForm.vue b/src/views/erp/sale/order/SaleOrderForm.vue new file mode 100644 index 0000000..30b2b30 --- /dev/null +++ b/src/views/erp/sale/order/SaleOrderForm.vue @@ -0,0 +1,289 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1080"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="订单单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="订单时间" prop="orderTime"> + <el-date-picker + v-model="formData.orderTime" + type="date" + value-format="x" + placeholder="选择订单时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="formData.customerId" + clearable + filterable + placeholder="请选择客户" + class="!w-1/1" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="销售人员" prop="saleUserId"> + <el-select + v-model="formData.saleUserId" + clearable + filterable + placeholder="请选择销售人员" + class="!w-1/1" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="订单产品清单" name="item"> + <SaleOrderItemForm ref="itemFormRef" :items="formData.items" :disabled="disabled" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="优惠率(%)" prop="discountPercent"> + <el-input-number + v-model="formData.discountPercent" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入优惠率" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="收款优惠" prop="discountPrice"> + <el-input + disabled + v-model="formData.discountPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="优惠后金额"> + <el-input disabled v-model="formData.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="结算账户" prop="accountId"> + <el-select + v-model="formData.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-1/1" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="收取订金" prop="depositPrice"> + <el-input-number + v-model="formData.depositPrice" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入收取订金" + class="!w-1/1" + /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { SaleOrderApi, SaleOrderVO } from '@/api/erp/sale/order' +import SaleOrderItemForm from './components/SaleOrderItemForm.vue' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' +import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils' +import * as UserApi from '@/api/system/user' + +/** ERP 销售订单表单 */ +defineOptions({ name: 'SaleOrderForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + customerId: undefined, + accountId: undefined, + saleUserId: undefined, + orderTime: undefined, + remark: undefined, + fileUrl: '', + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + depositPrice: 0, + items: [], + no: undefined // 订单单号,后端返回 +}) +const formRules = reactive({ + customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }], + orderTime: [{ required: true, message: '订单时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref +const customerList = ref<CustomerVO[]>([]) // 客户列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 计算 discountPrice、totalPrice 价格 */ +watch( + () => formData.value, + (val) => { + if (!val) { + return + } + const totalPrice = val.items.reduce((prev, curr) => prev + curr.totalPrice, 0) + const discountPrice = + val.discountPercent != null ? erpPriceMultiply(totalPrice, val.discountPercent / 100.0) : 0 + formData.value.discountPrice = discountPrice + formData.value.totalPrice = totalPrice - discountPrice + }, + { deep: true } +) + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await SaleOrderApi.getSaleOrder(id) + } finally { + formLoading.value = false + } + } + // 加载客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() + // 加载账户列表 + accountList.value = await AccountApi.getAccountSimpleList() + const defaultAccount = accountList.value.find((item) => item.defaultStatus) + if (defaultAccount) { + formData.value.accountId = defaultAccount.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as SaleOrderVO + if (formType.value === 'create') { + await SaleOrderApi.createSaleOrder(data) + message.success(t('common.createSuccess')) + } else { + await SaleOrderApi.updateSaleOrder(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + customerId: undefined, + accountId: undefined, + saleUserId: undefined, + orderTime: undefined, + remark: undefined, + fileUrl: undefined, + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + depositPrice: 0, + items: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/sale/order/components/SaleOrderItemForm.vue b/src/views/erp/sale/order/components/SaleOrderItemForm.vue new file mode 100644 index 0000000..3a579d5 --- /dev/null +++ b/src/views/erp/sale/order/components/SaleOrderItemForm.vue @@ -0,0 +1,271 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!"> + <el-select + v-model="row.productId" + clearable + filterable + @change="onChangeProduct($event, row)" + placeholder="请选择产品" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="库存" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productBarCode" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productUnitName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品单价" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productPrice`" class="mb-0px!"> + <el-input-number + v-model="row.productPrice" + controls-position="right" + :min="0.01" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="金额" prop="totalProductPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalProductPrice`" class="mb-0px!"> + <el-input + disabled + v-model="row.totalProductPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税率(%)" fixed="right" min-width="115"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPercent`" class="mb-0px!"> + <el-input-number + v-model="row.taxPercent" + controls-position="right" + :min="0" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额" prop="taxPrice" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-input disabled v-model="row.taxPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额合计" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3" v-if="!disabled"> + <el-button @click="handleAdd" round>+ 添加采购产品</el-button> + </el-row> +</template> +<script setup lang="ts"> +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { StockApi } from '@/api/erp/stock/stock' +import { + erpCountInputFormatter, + erpPriceInputFormatter, + erpPriceMultiply, + getSumValue +} from '@/utils' + +const props = defineProps<{ + items: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 初始化设置出库项 */ +watch( + () => props.items, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + item.totalProductPrice = erpPriceMultiply(item.productPrice, item.count) + item.taxPrice = erpPriceMultiply(item.totalProductPrice, item.taxPercent / 100.0) + if (item.totalProductPrice != null) { + item.totalPrice = item.totalProductPrice + (item.taxPrice || 0) + } else { + item.totalPrice = undefined + } + }) + }, + { deep: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index: number) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['count', 'totalProductPrice', 'taxPrice', 'totalPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = + column.property === 'count' ? erpCountInputFormatter(sum) : erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + + return sums +} + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + productId: undefined, + productUnitName: undefined, // 产品单位 + productBarCode: undefined, // 产品条码 + productPrice: undefined, + stockCount: undefined, + count: 1, + totalProductPrice: undefined, + taxPercent: undefined, + taxPrice: undefined, + totalPrice: undefined, + remark: undefined + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} + +/** 处理产品变更 */ +const onChangeProduct = (productId, row) => { + const product = productList.value.find((item) => item.id === productId) + if (product) { + row.productUnitName = product.unitName + row.productBarCode = product.barCode + row.productPrice = product.salePrice + } + // 加载库存 + setStockCount(row) +} + +/** 加载库存 */ +const setStockCount = async (row: any) => { + if (!row.productId) { + return + } + const count = await StockApi.getStockCount(row.productId) + row.stockCount = count || 0 +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + productList.value = await ProductApi.getProductSimpleList() + // 默认添加一个 + if (formData.value.length === 0) { + handleAdd() + } +}) +</script> diff --git a/src/views/erp/sale/order/components/SaleOrderOutEnableList.vue b/src/views/erp/sale/order/components/SaleOrderOutEnableList.vue new file mode 100644 index 0000000..55de745 --- /dev/null +++ b/src/views/erp/sale/order/components/SaleOrderOutEnableList.vue @@ -0,0 +1,206 @@ +<!-- 可出库的订单列表 --> +<template> + <Dialog + title="选择销售订单(仅展示可出库)" + v-model="dialogVisible" + :appendToBody="true" + :scroll="true" + width="1080" + > + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="订单单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入订单单号" + clearable + @keyup.enter="handleQuery" + class="!w-160px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-160px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="订单时间" prop="orderTime"> + <el-date-picker + v-model="queryParams.orderTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-160px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" width="65"> + <template #default="scope"> + <el-radio + :label="scope.row.id" + v-model="currentRowValue" + @change="handleCurrentChange(scope.row)" + > + + </el-radio> + </template> + </el-table-column> + <el-table-column min-width="180" label="订单单号" align="center" prop="no" /> + <el-table-column label="客户" align="center" prop="customerName" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column + label="订单时间" + align="center" + prop="orderTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="总数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="出库数量" + align="center" + prop="outCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="金额合计" + align="center" + prop="totalProductPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="含税金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button :disabled="!currentRow" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> + +<script lang="ts" setup> +import { ElTable } from 'element-plus' +import { SaleOrderApi, SaleOrderVO } from '@/api/erp/sale/order' +import { dateFormatter2 } from '@/utils/formatTime' +import { erpCountTableColumnFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { ProductApi, ProductVO } from '@/api/erp/product/product' + +defineOptions({ name: 'ErpSaleOrderOutEnableList' }) + +const list = ref<SaleOrderVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + orderTime: [], + outEnable: true +}) +const queryFormRef = ref() // 搜索的表单 +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 选中行 */ +const currentRowValue = ref(undefined) // 选中行的 value +const currentRow = ref(undefined) // 选中行 +const handleCurrentChange = (row) => { + currentRow.value = row +} + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + await nextTick() // 等待,避免 queryFormRef 为空 + // 加载可出库的订单列表 + await resetQuery() + // 加载产品列表 + productList.value = await ProductApi.getProductSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交选择 */ +const emits = defineEmits<{ + (e: 'success', value: SaleOrderVO): void +}>() +const submitForm = () => { + try { + emits('success', currentRow.value) + } finally { + // 关闭弹窗 + dialogVisible.value = false + } +} + +/** 加载列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SaleOrderApi.getSaleOrderPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + currentRowValue.value = undefined + currentRow.value = undefined + getList() +} +</script> diff --git a/src/views/erp/sale/order/components/SaleOrderReturnEnableList.vue b/src/views/erp/sale/order/components/SaleOrderReturnEnableList.vue new file mode 100644 index 0000000..a93a997 --- /dev/null +++ b/src/views/erp/sale/order/components/SaleOrderReturnEnableList.vue @@ -0,0 +1,212 @@ +<!-- 可退货的订单列表 --> +<template> + <Dialog + title="选择销售订单(仅展示可退货)" + v-model="dialogVisible" + :appendToBody="true" + :scroll="true" + width="1080" + > + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="订单单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入订单单号" + clearable + @keyup.enter="handleQuery" + class="!w-160px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-160px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="订单时间" prop="orderTime"> + <el-date-picker + v-model="queryParams.orderTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-160px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" width="65"> + <template #default="scope"> + <el-radio + :label="scope.row.id" + v-model="currentRowValue" + @change="handleCurrentChange(scope.row)" + > + + </el-radio> + </template> + </el-table-column> + <el-table-column min-width="180" label="订单单号" align="center" prop="no" /> + <el-table-column label="客户" align="center" prop="customerName" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column + label="订单时间" + align="center" + prop="orderTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="总数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="出库数量" + align="center" + prop="outCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="退货数量" + align="center" + prop="returnCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="金额合计" + align="center" + prop="totalProductPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="含税金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button :disabled="!currentRow" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> + +<script lang="ts" setup> +import { ElTable } from 'element-plus' +import { SaleOrderApi, SaleOrderVO } from '@/api/erp/sale/order' +import { dateFormatter2 } from '@/utils/formatTime' +import { erpCountTableColumnFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { ProductApi, ProductVO } from '@/api/erp/product/product' + +defineOptions({ name: 'SaleOrderReturnEnableList' }) + +const list = ref<SaleOrderVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + orderTime: [], + returnEnable: true +}) +const queryFormRef = ref() // 搜索的表单 +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 选中行 */ +const currentRowValue = ref(undefined) // 选中行的 value +const currentRow = ref(undefined) // 选中行 +const handleCurrentChange = (row) => { + currentRow.value = row +} + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + await nextTick() // 等待,避免 queryFormRef 为空 + // 加载可退货的订单列表 + await resetQuery() + // 加载产品列表 + productList.value = await ProductApi.getProductSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交选择 */ +const emits = defineEmits<{ + (e: 'success', value: SaleOrderVO): void +}>() +const submitForm = () => { + try { + emits('success', currentRow.value) + } finally { + // 关闭弹窗 + dialogVisible.value = false + } +} + +/** 加载列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SaleOrderApi.getSaleOrderPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + currentRowValue.value = undefined + currentRow.value = undefined + getList() +} +</script> diff --git a/src/views/erp/sale/order/index.vue b/src/views/erp/sale/order/index.vue new file mode 100644 index 0000000..deb03c0 --- /dev/null +++ b/src/views/erp/sale/order/index.vue @@ -0,0 +1,407 @@ +<template> + <doc-alert title="【销售】销售订单、出库、退货" url="https://doc.iocoder.cn/erp/sale/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="订单单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入订单单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="订单时间" prop="orderTime"> + <el-date-picker + v-model="queryParams.orderTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="queryParams.customerId" + clearable + filterable + placeholder="请选择供客户" + class="!w-240px" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="出库数量" prop="outStatus"> + <el-select + v-model="queryParams.outStatus" + placeholder="请选择出库数量" + clearable + class="!w-240px" + > + <el-option label="未出库" value="0" /> + <el-option label="部分出库" value="1" /> + <el-option label="全部出库" value="2" /> + </el-select> + </el-form-item> + <el-form-item label="退货数量" prop="returnStatus"> + <el-select + v-model="queryParams.returnStatus" + placeholder="请选择退货数量" + clearable + class="!w-240px" + > + <el-option label="未退货" value="0" /> + <el-option label="部分退货" value="1" /> + <el-option label="全部退货" value="2" /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:sale-order:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:sale-order:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:sale-order:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="订单单号" align="center" prop="no" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column label="客户" align="center" prop="customerName" /> + <el-table-column + label="订单时间" + align="center" + prop="orderTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="总数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="出库数量" + align="center" + prop="outCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="退货数量" + align="center" + prop="returnCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="金额合计" + align="center" + prop="totalProductPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="含税金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="收取订金" + align="center" + prop="depositPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:sale-order:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:sale-order:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:sale-order:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:sale-order:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:sale-order:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SaleOrderForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { SaleOrderApi, SaleOrderVO } from '@/api/erp/sale/order' +import SaleOrderForm from './SaleOrderForm.vue' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { erpCountTableColumnFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' + +/** ERP 销售订单列表 */ +defineOptions({ name: 'ErpSaleOrder' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<SaleOrderVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + customerId: undefined, + productId: undefined, + orderTime: [], + status: undefined, + remark: undefined, + creator: undefined, + outStatus: undefined, + returnStatus: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const customerList = ref<CustomerVO[]>([]) // 客户列表 +const userList = ref<UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SaleOrderApi.getSaleOrderPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await SaleOrderApi.deleteSaleOrder(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该订单吗?`) + // 发起审批 + await SaleOrderApi.updateSaleOrderStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await SaleOrderApi.exportSaleOrder(queryParams) + download.excel(data, '销售订单.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<SaleOrderVO[]>([]) +const handleSelectionChange = (rows: SaleOrderVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表、客户 + productList.value = await ProductApi.getProductSimpleList() + customerList.value = await CustomerApi.getCustomerSimpleList() + userList.value = await UserApi.getSimpleUserList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/sale/out/SaleOutForm.vue b/src/views/erp/sale/out/SaleOutForm.vue new file mode 100644 index 0000000..7d47713 --- /dev/null +++ b/src/views/erp/sale/out/SaleOutForm.vue @@ -0,0 +1,343 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1440"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="出库单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="出库时间" prop="outTime"> + <el-date-picker + v-model="formData.outTime" + type="date" + value-format="x" + placeholder="选择出库时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="关联订单" prop="orderNo"> + <el-input v-model="formData.orderNo" readonly> + <template #append> + <el-button @click="openSaleOrderOutEnableList"> + <Icon icon="ep:search" /> 选择 + </el-button> + </template> + </el-input> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="formData.customerId" + clearable + filterable + disabled + placeholder="请选择客户" + class="!w-1/1" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="销售人员" prop="saleUserId"> + <el-select + v-model="formData.saleUserId" + clearable + filterable + placeholder="请选择销售人员" + class="!w-1/1" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="出库产品清单" name="item"> + <SaleOutItemForm ref="itemFormRef" :items="formData.items" :disabled="disabled" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="优惠率(%)" prop="discountPercent"> + <el-input-number + v-model="formData.discountPercent" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入优惠率" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="收款优惠" prop="discountPrice"> + <el-input + disabled + v-model="formData.discountPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="优惠后金额"> + <el-input + disabled + :model-value="formData.totalPrice - formData.otherPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="其它费用" prop="otherPrice"> + <el-input-number + v-model="formData.otherPrice" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入其它费用" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="结算账户" prop="accountId"> + <el-select + v-model="formData.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-1/1" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="应收金额"> + <el-input disabled v-model="formData.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + + <!-- 可出库的订单列表 --> + <SaleOrderOutEnableList ref="saleOrderOutEnableListRef" @success="handleSaleOrderChange" /> +</template> +<script setup lang="ts"> +import { SaleOutApi, SaleOutVO } from '@/api/erp/sale/out' +import SaleOutItemForm from './components/SaleOutItemForm.vue' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' +import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils' +import SaleOrderOutEnableList from '@/views/erp/sale/order/components/SaleOrderOutEnableList.vue' +import { SaleOrderVO } from '@/api/erp/sale/order' +import * as UserApi from '@/api/system/user' + +/** ERP 销售出库表单 */ +defineOptions({ name: 'SaleOutForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + customerId: undefined, + accountId: undefined, + saleUserId: undefined, + outTime: undefined, + remark: undefined, + fileUrl: '', + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + otherPrice: 0, + orderNo: undefined, + items: [], + no: undefined // 出库单号,后端返回 +}) +const formRules = reactive({ + customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }], + outTime: [{ required: true, message: '出库时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref +const customerList = ref<CustomerVO[]>([]) // 客户列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 计算 discountPrice、totalPrice 价格 */ +watch( + () => formData.value, + (val) => { + if (!val) { + return + } + // 计算 + const totalPrice = val.items.reduce((prev, curr) => prev + curr.totalPrice, 0) + const discountPrice = + val.discountPercent != null ? erpPriceMultiply(totalPrice, val.discountPercent / 100.0) : 0 + formData.value.discountPrice = discountPrice + formData.value.totalPrice = totalPrice - discountPrice + val.otherPrice + }, + { deep: true } +) + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await SaleOutApi.getSaleOut(id) + } finally { + formLoading.value = false + } + } + // 加载客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() + // 加载账户列表 + accountList.value = await AccountApi.getAccountSimpleList() + const defaultAccount = accountList.value.find((item) => item.defaultStatus) + if (defaultAccount) { + formData.value.accountId = defaultAccount.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 打开【可出库的订单列表】弹窗 */ +const saleOrderOutEnableListRef = ref() // 可出库的订单列表 Ref +const openSaleOrderOutEnableList = () => { + saleOrderOutEnableListRef.value.open() +} + +const handleSaleOrderChange = (order: SaleOrderVO) => { + // 将订单设置到出库单 + formData.value.orderId = order.id + formData.value.orderNo = order.no + formData.value.customerId = order.customerId + formData.value.accountId = order.accountId + formData.value.saleUserId = order.saleUserId + formData.value.discountPercent = order.discountPercent + formData.value.remark = order.remark + formData.value.fileUrl = order.fileUrl + // 将订单项设置到出库单项 + order.items.forEach((item) => { + item.totalCount = item.count + item.count = item.totalCount - item.outCount + item.orderItemId = item.id + item.id = undefined + }) + formData.value.items = order.items.filter((item) => item.count > 0) +} + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as SaleOutVO + if (formType.value === 'create') { + await SaleOutApi.createSaleOut(data) + message.success(t('common.createSuccess')) + } else { + await SaleOutApi.updateSaleOut(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + customerId: undefined, + accountId: undefined, + saleUserId: undefined, + outTime: undefined, + remark: undefined, + fileUrl: undefined, + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + otherPrice: 0, + items: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/sale/out/components/SaleOutItemForm.vue b/src/views/erp/sale/out/components/SaleOutItemForm.vue new file mode 100644 index 0000000..15cbef0 --- /dev/null +++ b/src/views/erp/sale/out/components/SaleOutItemForm.vue @@ -0,0 +1,300 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="仓库名称" min-width="125"> + <template #default="{ row, $index }"> + <el-form-item + :prop="`${$index}.warehouseId`" + :rules="formRules.warehouseId" + class="mb-0px!" + > + <el-select + v-model="row.warehouseId" + clearable + filterable + placeholder="请选择仓库" + @change="onChangeWarehouse($event, row)" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="库存" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productBarCode" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productUnitName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column + label="原数量" + fixed="right" + min-width="80" + v-if="formData[0]?.totalCount != null" + > + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.totalCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column + label="已出库" + fixed="right" + min-width="80" + v-if="formData[0]?.outCount != null" + > + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.outCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品单价" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productPrice`" class="mb-0px!"> + <el-input-number + v-model="row.productPrice" + controls-position="right" + :min="0.01" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="金额" prop="totalProductPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalProductPrice`" class="mb-0px!"> + <el-input + disabled + v-model="row.totalProductPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税率(%)" fixed="right" min-width="115"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPercent`" class="mb-0px!"> + <el-input-number + v-model="row.taxPercent" + controls-position="right" + :min="0" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额" prop="taxPrice" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-input disabled v-model="row.taxPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额合计" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button :disabled="formData.length === 1" @click="handleDelete($index)" link> + — + </el-button> + </template> + </el-table-column> + </el-table> + </el-form> +</template> +<script setup lang="ts"> +import { StockApi } from '@/api/erp/stock/stock' +import { + erpCountInputFormatter, + erpPriceInputFormatter, + erpPriceMultiply, + getSumValue +} from '@/utils' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' + +const props = defineProps<{ + items: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + warehouseId: [{ required: true, message: '仓库不能为空', trigger: 'blur' }], + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const defaultWarehouse = ref<WarehouseVO>(undefined) // 默认仓库 + +/** 初始化设置出库项 */ +watch( + () => props.items, + async (val) => { + val.forEach((item) => { + if (item.warehouseId == null) { + item.warehouseId = defaultWarehouse.value?.id + } + if (item.stockCount === null && item.warehouseId != null) { + setStockCount(item) + } + }) + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + item.totalProductPrice = erpPriceMultiply(item.productPrice, item.count) + item.taxPrice = erpPriceMultiply(item.totalProductPrice, item.taxPercent / 100.0) + if (item.totalProductPrice != null) { + item.totalPrice = item.totalProductPrice + (item.taxPrice || 0) + } else { + item.totalPrice = undefined + } + }) + }, + { deep: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index: number) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['count', 'totalProductPrice', 'taxPrice', 'totalPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = + column.property === 'count' ? erpCountInputFormatter(sum) : erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + + return sums +} + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + productId: undefined, + productUnitName: undefined, // 产品单位 + productBarCode: undefined, // 产品条码 + productPrice: undefined, + stockCount: undefined, + count: 1, + totalProductPrice: undefined, + taxPercent: undefined, + taxPrice: undefined, + totalPrice: undefined, + remark: undefined + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} + +/** 加载库存 */ +const setStockCount = async (row: any) => { + if (!row.productId) { + return + } + const count = await StockApi.getStockCount(row.productId) + row.stockCount = count || 0 +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + defaultWarehouse.value = warehouseList.value.find((item) => item.defaultStatus) +}) +</script> diff --git a/src/views/erp/sale/out/components/SaleOutReceiptEnableList.vue b/src/views/erp/sale/out/components/SaleOutReceiptEnableList.vue new file mode 100644 index 0000000..0c4a21d --- /dev/null +++ b/src/views/erp/sale/out/components/SaleOutReceiptEnableList.vue @@ -0,0 +1,199 @@ +<!-- 可收款的销售出库单列表 --> +<template> + <Dialog + title="选择销售出库(仅展示可收款)" + v-model="dialogVisible" + :appendToBody="true" + :scroll="true" + width="1080" + > + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="出库单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入出库单号" + clearable + @keyup.enter="handleQuery" + class="!w-160px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-160px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="出库时间" prop="orderTime"> + <el-date-picker + v-model="queryParams.outTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-160px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :show-overflow-tooltip="true" + :stripe="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="出库单号" align="center" prop="no" /> + <el-table-column label="客户" align="center" prop="customerName" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column + label="出库时间" + align="center" + prop="outTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="应收金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="已收金额" + align="center" + prop="receiptPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="未收金额" align="center"> + <template #default="scope"> + <span v-if="scope.row.receiptPrice === scope.row.totalPrice">0</span> + <el-tag type="danger" v-else> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.receiptPrice) }} + </el-tag> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button :disabled="!selectionList.length" type="primary" @click="submitForm"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { ElTable } from 'element-plus' +import { dateFormatter2 } from '@/utils/formatTime' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { SaleOutApi, SaleOutVO } from '@/api/erp/sale/out' + +defineOptions({ name: 'SaleOutReceiptEnableList' }) + +const list = ref<SaleOutVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + outTime: [], + receiptEnable: true, + customerId: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 选中操作 */ +const selectionList = ref<SaleOutVO[]>([]) +const handleSelectionChange = (rows: SaleOutVO[]) => { + selectionList.value = rows +} + +/** 打开弹窗 */ +const open = async (customerId: number) => { + dialogVisible.value = true + await nextTick() // 等待,避免 queryFormRef 为空 + // 加载可出库的订单列表 + queryParams.customerId = customerId + await resetQuery() + // 加载产品列表 + productList.value = await ProductApi.getProductSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交选择 */ +const emits = defineEmits<{ + (e: 'success', value: SaleOutVO[]): void +}>() +const submitForm = () => { + try { + emits('success', selectionList.value) + } finally { + // 关闭弹窗 + dialogVisible.value = false + } +} + +/** 加载列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SaleOutApi.getSaleOutPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + selectionList.value = [] + getList() +} +</script> diff --git a/src/views/erp/sale/out/index.vue b/src/views/erp/sale/out/index.vue new file mode 100644 index 0000000..bd143b9 --- /dev/null +++ b/src/views/erp/sale/out/index.vue @@ -0,0 +1,438 @@ +<template> + <doc-alert title="【销售】销售订单、出库、退货" url="https://doc.iocoder.cn/erp/sale/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="出库单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入出库单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="出库时间" prop="outTime"> + <el-date-picker + v-model="queryParams.outTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="queryParams.customerId" + clearable + filterable + placeholder="请选择供客户" + class="!w-240px" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="仓库" prop="warehouseId"> + <el-select + v-model="queryParams.warehouseId" + clearable + filterable + placeholder="请选择仓库" + class="!w-240px" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="关联订单" prop="orderNo"> + <el-input + v-model="queryParams.orderNo" + placeholder="请输入关联订单" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="结算账户" prop="accountId"> + <el-select + v-model="queryParams.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-240px" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="收款状态" prop="receiptStatus"> + <el-select + v-model="queryParams.receiptStatus" + placeholder="请选择有款状态" + clearable + class="!w-240px" + > + <el-option label="未收款" value="0" /> + <el-option label="部分收款" value="1" /> + <el-option label="全部收款" value="2" /> + </el-select> + </el-form-item> + <el-form-item label="审核状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:sale-out:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:sale-out:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:sale-out:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="出库单号" align="center" prop="no" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column label="客户" align="center" prop="customerName" /> + <el-table-column + label="出库时间" + align="center" + prop="outTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="总数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="应收金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="已收金额" + align="center" + prop="receiptPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="未收金额" align="center"> + <template #default="scope"> + <span v-if="scope.row.receiptPrice === scope.row.totalPrice">0</span> + <el-tag type="danger" v-else> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.receiptPrice) }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="审核状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:sale-out:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:sale-out:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:sale-out:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:sale-out:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:sale-out:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SaleOutForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { SaleOutApi, SaleOutVO } from '@/api/erp/sale/out' +import SaleOutForm from './SaleOutForm.vue' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { + erpCountTableColumnFormatter, + erpPriceInputFormatter, + erpPriceTableColumnFormatter +} from '@/utils' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' + +/** ERP 销售出库列表 */ +defineOptions({ name: 'ErpSaleOut' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<SaleOutVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + customerId: undefined, + productId: undefined, + warehouseId: undefined, + outTime: [], + orderNo: undefined, + receiptStatus: undefined, + accountId: undefined, + status: undefined, + remark: undefined, + creator: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const customerList = ref<CustomerVO[]>([]) // 客户列表 +const userList = ref<UserVO[]>([]) // 用户列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SaleOutApi.getSaleOutPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await SaleOutApi.deleteSaleOut(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该出库吗?`) + // 发起审批 + await SaleOutApi.updateSaleOutStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await SaleOutApi.exportSaleOut(queryParams) + download.excel(data, '销售出库.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<SaleOutVO[]>([]) +const handleSelectionChange = (rows: SaleOutVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表、客户 + productList.value = await ProductApi.getProductSimpleList() + customerList.value = await CustomerApi.getCustomerSimpleList() + userList.value = await UserApi.getSimpleUserList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + accountList.value = await AccountApi.getAccountSimpleList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/sale/return/SaleReturnForm.vue b/src/views/erp/sale/return/SaleReturnForm.vue new file mode 100644 index 0000000..b10403b --- /dev/null +++ b/src/views/erp/sale/return/SaleReturnForm.vue @@ -0,0 +1,341 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1440"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="退货单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="退货时间" prop="returnTime"> + <el-date-picker + v-model="formData.returnTime" + type="date" + value-format="x" + placeholder="选择退货时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="关联订单" prop="orderNo"> + <el-input v-model="formData.orderNo" readonly> + <template #append> + <el-button @click="openSaleOrderReturnEnableList"> + <Icon icon="ep:search" /> 选择 + </el-button> + </template> + </el-input> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="formData.customerId" + clearable + filterable + disabled + placeholder="请选择客户" + class="!w-1/1" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="销售人员" prop="saleUserId"> + <el-select + v-model="formData.saleUserId" + clearable + filterable + placeholder="请选择销售人员" + class="!w-1/1" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="退货产品清单" name="item"> + <SaleReturnItemForm ref="itemFormRef" :items="formData.items" :disabled="disabled" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="优惠率(%)" prop="discountPercent"> + <el-input-number + v-model="formData.discountPercent" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入优惠率" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="退款优惠" prop="discountPrice"> + <el-input + disabled + v-model="formData.discountPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="优惠后金额"> + <el-input + disabled + :model-value="formData.totalPrice - formData.otherPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="其它费用" prop="otherPrice"> + <el-input-number + v-model="formData.otherPrice" + controls-position="right" + :min="0" + :precision="2" + placeholder="请输入其它费用" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="结算账户" prop="accountId"> + <el-select + v-model="formData.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-1/1" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="应退金额" prop="totalPrice"> + <el-input disabled v-model="formData.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + + <!-- 可退货的订单列表 --> + <SaleOrderReturnEnableList ref="saleOrderReturnEnableListRef" @success="handleSaleOrderChange" /> +</template> +<script setup lang="ts"> +import { SaleReturnApi, SaleReturnVO } from '@/api/erp/sale/return' +import SaleReturnItemForm from './components/SaleReturnItemForm.vue' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' +import { erpPriceInputFormatter, erpPriceMultiply } from '@/utils' +import SaleOrderReturnEnableList from '@/views/erp/sale/order/components/SaleOrderReturnEnableList.vue' +import { SaleOrderVO } from '@/api/erp/sale/order' +import * as UserApi from '@/api/system/user' + +/** ERP 销售退货表单 */ +defineOptions({ name: 'SaleReturnForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + customerId: undefined, + accountId: undefined, + saleUserId: undefined, + returnTime: undefined, + remark: undefined, + fileUrl: '', + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + otherPrice: 0, + orderNo: undefined, + items: [], + no: undefined // 退货单号,后端返回 +}) +const formRules = reactive({ + customerId: [{ required: true, message: '客户不能为空', trigger: 'blur' }], + returnTime: [{ required: true, message: '退货时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref +const customerList = ref<CustomerVO[]>([]) // 客户列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 计算 discountPrice、totalPrice 价格 */ +watch( + () => formData.value, + (val) => { + if (!val) { + return + } + // 计算 + const totalPrice = val.items.reduce((prev, curr) => prev + curr.totalPrice, 0) + const discountPrice = + val.discountPercent != null ? erpPriceMultiply(totalPrice, val.discountPercent / 100.0) : 0 + formData.value.totalPrice = totalPrice - discountPrice + val.otherPrice + }, + { deep: true } +) + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await SaleReturnApi.getSaleReturn(id) + } finally { + formLoading.value = false + } + } + // 加载客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() + // 加载用户列表 + userList.value = await UserApi.getSimpleUserList() + // 加载账户列表 + accountList.value = await AccountApi.getAccountSimpleList() + const defaultAccount = accountList.value.find((item) => item.defaultStatus) + if (defaultAccount) { + formData.value.accountId = defaultAccount.id + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 打开【可退货的订单列表】弹窗 */ +const saleOrderReturnEnableListRef = ref() // 可退货的订单列表 Ref +const openSaleOrderReturnEnableList = () => { + saleOrderReturnEnableListRef.value.open() +} + +const handleSaleOrderChange = (order: SaleOrderVO) => { + // 将订单设置到退货单 + formData.value.orderId = order.id + formData.value.orderNo = order.no + formData.value.customerId = order.customerId + formData.value.accountId = order.accountId + formData.value.saleUserId = order.saleUserId + formData.value.discountPercent = order.discountPercent + formData.value.remark = order.remark + formData.value.fileUrl = order.fileUrl + // 将订单项设置到退货单项 + order.items.forEach((item) => { + item.count = item.outCount - item.returnCount + item.orderItemId = item.id + item.id = undefined + }) + formData.value.items = order.items.filter((item) => item.count > 0) +} + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as SaleReturnVO + if (formType.value === 'create') { + await SaleReturnApi.createSaleReturn(data) + message.success(t('common.createSuccess')) + } else { + await SaleReturnApi.updateSaleReturn(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + customerId: undefined, + accountId: undefined, + saleUserId: undefined, + returnTime: undefined, + remark: undefined, + fileUrl: undefined, + discountPercent: 0, + discountPrice: 0, + totalPrice: 0, + otherPrice: 0, + items: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/sale/return/components/SaleReturnItemForm.vue b/src/views/erp/sale/return/components/SaleReturnItemForm.vue new file mode 100644 index 0000000..adb9fd4 --- /dev/null +++ b/src/views/erp/sale/return/components/SaleReturnItemForm.vue @@ -0,0 +1,300 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="仓库名称" min-width="125"> + <template #default="{ row, $index }"> + <el-form-item + :prop="`${$index}.warehouseId`" + :rules="formRules.warehouseId" + class="mb-0px!" + > + <el-select + v-model="row.warehouseId" + clearable + filterable + placeholder="请选择仓库" + @change="onChangeWarehouse($event, row)" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="库存" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productBarCode" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productUnitName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column + label="已出库" + fixed="right" + min-width="80" + v-if="formData[0]?.outCount != null" + > + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.outCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column + label="已退货" + fixed="right" + min-width="80" + v-if="formData[0]?.returnCount != null" + > + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.returnCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品单价" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productPrice`" class="mb-0px!"> + <el-input-number + v-model="row.productPrice" + controls-position="right" + :min="0.01" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="金额" prop="totalProductPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalProductPrice`" class="mb-0px!"> + <el-input + disabled + v-model="row.totalProductPrice" + :formatter="erpPriceInputFormatter" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税率(%)" fixed="right" min-width="115"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPercent`" class="mb-0px!"> + <el-input-number + v-model="row.taxPercent" + controls-position="right" + :min="0" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额" prop="taxPrice" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-form-item :prop="`${$index}.taxPrice`" class="mb-0px!"> + <el-input disabled v-model="row.taxPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="税额合计" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button :disabled="formData.length === 1" @click="handleDelete($index)" link> + — + </el-button> + </template> + </el-table-column> + </el-table> + </el-form> +</template> +<script setup lang="ts"> +import { StockApi } from '@/api/erp/stock/stock' +import { + erpCountInputFormatter, + erpPriceInputFormatter, + erpPriceMultiply, + getSumValue +} from '@/utils' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' + +const props = defineProps<{ + items: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + warehouseId: [{ required: true, message: '仓库不能为空', trigger: 'blur' }], + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const defaultWarehouse = ref<WarehouseVO>(undefined) // 默认仓库 + +/** 初始化设置出库项 */ +watch( + () => props.items, + async (val) => { + val.forEach((item) => { + if (item.warehouseId == null) { + item.warehouseId = defaultWarehouse.value?.id + } + if (item.stockCount === null && item.warehouseId != null) { + setStockCount(item) + } + }) + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + item.totalProductPrice = erpPriceMultiply(item.productPrice, item.count) + item.taxPrice = erpPriceMultiply(item.totalProductPrice, item.taxPercent / 100.0) + if (item.totalProductPrice != null) { + item.totalPrice = item.totalProductPrice + (item.taxPrice || 0) + } else { + item.totalPrice = undefined + } + }) + }, + { deep: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index: number) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['count', 'totalProductPrice', 'taxPrice', 'totalPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = + column.property === 'count' ? erpCountInputFormatter(sum) : erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + + return sums +} + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + productId: undefined, + productUnitName: undefined, // 产品单位 + productBarCode: undefined, // 产品条码 + productPrice: undefined, + stockCount: undefined, + count: 1, + totalProductPrice: undefined, + taxPercent: undefined, + taxPrice: undefined, + totalPrice: undefined, + remark: undefined + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index: number) => { + formData.value.splice(index, 1) +} + +/** 加载库存 */ +const setStockCount = async (row: any) => { + if (!row.productId) { + return + } + const count = await StockApi.getStockCount(row.productId) + row.stockCount = count || 0 +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + defaultWarehouse.value = warehouseList.value.find((item) => item.defaultStatus) +}) +</script> diff --git a/src/views/erp/sale/return/components/SaleReturnRefundEnableList.vue b/src/views/erp/sale/return/components/SaleReturnRefundEnableList.vue new file mode 100644 index 0000000..dc875e6 --- /dev/null +++ b/src/views/erp/sale/return/components/SaleReturnRefundEnableList.vue @@ -0,0 +1,199 @@ +<!-- 可退款的销售退货单列表 --> +<template> + <Dialog + title="选择销售退货(仅展示可退款)" + v-model="dialogVisible" + :appendToBody="true" + :scroll="true" + width="1080" + > + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="退货单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入退货单号" + clearable + @keyup.enter="handleQuery" + class="!w-160px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-160px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="退货时间" prop="orderTime"> + <el-date-picker + v-model="queryParams.returnTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-160px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :show-overflow-tooltip="true" + :stripe="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="退货单号" align="center" prop="no" /> + <el-table-column label="客户" align="center" prop="customerName" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column + label="退货时间" + align="center" + prop="returnTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="应退金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="已退金额" + align="center" + prop="refundPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="未退金额" align="center"> + <template #default="scope"> + <span v-if="scope.row.refundPrice === scope.row.totalPrice">0</span> + <el-tag type="danger" v-else> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.refundPrice) }} + </el-tag> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button :disabled="!selectionList.length" type="primary" @click="submitForm"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { ElTable } from 'element-plus' +import { dateFormatter2 } from '@/utils/formatTime' +import { erpPriceInputFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { SaleReturnApi, SaleReturnVO } from '@/api/erp/sale/return' + +defineOptions({ name: 'SaleReturnPaymentEnableList' }) + +const list = ref<SaleReturnVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + returnTime: [], + refundEnable: true, + customerId: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const productList = ref<ProductVO[]>([]) // 产品列表 + +/** 选中操作 */ +const selectionList = ref<SaleReturnVO[]>([]) +const handleSelectionChange = (rows: SaleReturnVO[]) => { + selectionList.value = rows +} + +/** 打开弹窗 */ +const open = async (customerId: number) => { + dialogVisible.value = true + await nextTick() // 等待,避免 queryFormRef 为空 + // 加载列表 + queryParams.customerId = customerId + await resetQuery() + // 加载产品列表 + productList.value = await ProductApi.getProductSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交选择 */ +const emits = defineEmits<{ + (e: 'success', value: SaleReturnVO[]): void +}>() +const submitForm = () => { + try { + emits('success', selectionList.value) + } finally { + // 关闭弹窗 + dialogVisible.value = false + } +} + +/** 加载列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SaleReturnApi.getSaleReturnPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + selectionList.value = [] + getList() +} +</script> diff --git a/src/views/erp/sale/return/index.vue b/src/views/erp/sale/return/index.vue new file mode 100644 index 0000000..c88f584 --- /dev/null +++ b/src/views/erp/sale/return/index.vue @@ -0,0 +1,443 @@ +<template> + <doc-alert title="【销售】销售订单、出库、退货" url="https://doc.iocoder.cn/erp/sale/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="退货单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入退货单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="退货时间" prop="outTime"> + <el-date-picker + v-model="queryParams.outTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="queryParams.customerId" + clearable + filterable + placeholder="请选择供客户" + class="!w-240px" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="仓库" prop="warehouseId"> + <el-select + v-model="queryParams.warehouseId" + clearable + filterable + placeholder="请选择仓库" + class="!w-240px" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="关联订单" prop="orderNo"> + <el-input + v-model="queryParams.orderNo" + placeholder="请输入关联订单" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="结算账户" prop="accountId"> + <el-select + v-model="queryParams.accountId" + clearable + filterable + placeholder="请选择结算账户" + class="!w-240px" + > + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="退款状态" prop="refundStatus"> + <el-select + v-model="queryParams.refundStatus" + placeholder="请选择退款状态" + clearable + class="!w-240px" + > + <el-option label="未退款" value="0" /> + <el-option label="部分退款" value="1" /> + <el-option label="全部退款" value="2" /> + </el-select> + </el-form-item> + <el-form-item label="审核状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择审核状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:sale-return:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:sale-return:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:sale-return:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="退货单号" align="center" prop="no" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column label="客户" align="center" prop="customerName" /> + <el-table-column + label="退货时间" + align="center" + prop="returnTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="总数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="应退金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="已退金额" + align="center" + prop="refundPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="未退金额" align="center"> + <template #default="scope"> + <span v-if="scope.row.refundPrice === scope.row.totalPrice">0</span> + <el-tag type="danger" v-else> + {{ erpPriceInputFormatter(scope.row.totalPrice - scope.row.refundPrice) }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="审核状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:sale-return:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:sale-return:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:sale-return:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:sale-return:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:sale-return:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SaleReturnForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { SaleReturnApi, SaleReturnVO } from '@/api/erp/sale/return' +import SaleReturnForm from './SaleReturnForm.vue' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { + erpCountTableColumnFormatter, + erpPriceInputFormatter, + erpPriceTableColumnFormatter +} from '@/utils' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { AccountApi, AccountVO } from '@/api/erp/finance/account' + +/** ERP 销售退货列表 */ +defineOptions({ name: 'ErpSaleReturn' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<SaleReturnVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + customerId: undefined, + productId: undefined, + warehouseId: undefined, + returnTime: [], + orderNo: undefined, + accountId: undefined, + status: undefined, + remark: undefined, + creator: undefined, + refundStatus: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const customerList = ref<CustomerVO[]>([]) // 客户列表 +const userList = ref<UserVO[]>([]) // 用户列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const accountList = ref<AccountVO[]>([]) // 账户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SaleReturnApi.getSaleReturnPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await SaleReturnApi.deleteSaleReturn(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该退货吗?`) + // 发起审批 + await SaleReturnApi.updateSaleReturnStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await SaleReturnApi.exportSaleReturn(queryParams) + download.excel(data, '销售退货.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<SaleReturnVO[]>([]) +const handleSelectionChange = (rows: SaleReturnVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表、客户 + productList.value = await ProductApi.getProductSimpleList() + customerList.value = await CustomerApi.getCustomerSimpleList() + userList.value = await UserApi.getSimpleUserList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + accountList.value = await AccountApi.getAccountSimpleList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/stock/check/StockCheckForm.vue b/src/views/erp/stock/check/StockCheckForm.vue new file mode 100644 index 0000000..9e7f673 --- /dev/null +++ b/src/views/erp/stock/check/StockCheckForm.vue @@ -0,0 +1,148 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1080"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="盘点单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="盘点时间" prop="checkTime"> + <el-date-picker + v-model="formData.checkTime" + type="date" + value-format="x" + placeholder="选择盘点时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="盘点产品清单" name="item"> + <StockCheckItemForm ref="itemFormRef" :items="formData.items" :disabled="disabled" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { StockCheckApi, StockCheckVO } from '@/api/erp/stock/check' +import StockCheckItemForm from './components/StockCheckItemForm.vue' + +/** ERP 其它盘点单表单 */ +defineOptions({ name: 'StockCheckForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + customerId: undefined, + checkTime: undefined, + remark: undefined, + fileUrl: '', + items: [] +}) +const formRules = reactive({ + checkTime: [{ required: true, message: '盘点时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await StockCheckApi.getStockCheck(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as StockCheckVO + if (formType.value === 'create') { + await StockCheckApi.createStockCheck(data) + message.success(t('common.createSuccess')) + } else { + await StockCheckApi.updateStockCheck(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + customerId: undefined, + checkTime: undefined, + remark: undefined, + fileUrl: undefined, + items: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/stock/check/components/StockCheckItemForm.vue b/src/views/erp/stock/check/components/StockCheckItemForm.vue new file mode 100644 index 0000000..6036311 --- /dev/null +++ b/src/views/erp/stock/check/components/StockCheckItemForm.vue @@ -0,0 +1,289 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="仓库名字" min-width="125"> + <template #default="{ row, $index }"> + <el-form-item + :prop="`${$index}.warehouseId`" + :rules="formRules.warehouseId" + class="mb-0px!" + > + <el-select + v-model="row.warehouseId" + clearable + filterable + placeholder="请选择仓库名字" + @change="onChangeWarehouse($event, row)" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!"> + <el-select + v-model="row.productId" + clearable + filterable + @change="onChangeProduct($event, row)" + placeholder="请选择产品" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="账面库存" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productBarCode" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productUnitName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="实际库存" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item + :prop="`${$index}.actualCount`" + :rules="formRules.actualCount" + class="mb-0px!" + > + <el-input-number + v-model="row.actualCount" + controls-position="right" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="盈亏数量" prop="count" fixed="right" min-width="110"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input + disabled + v-model="row.count" + :formatter="erpCountInputFormatter" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品单价" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productPrice`" class="mb-0px!"> + <el-input-number + v-model="row.productPrice" + controls-position="right" + :min="0.01" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="合计金额" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3" v-if="!disabled"> + <el-button @click="handleAdd" round>+ 添加盘点产品</el-button> + </el-row> +</template> +<script setup lang="ts"> +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { StockApi } from '@/api/erp/stock/stock' +import { + erpCountInputFormatter, + erpPriceInputFormatter, + erpPriceMultiply, + getSumValue +} from '@/utils' + +const props = defineProps<{ + items: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + inId: [{ required: true, message: '盘点编号不能为空', trigger: 'blur' }], + warehouseId: [{ required: true, message: '仓库名字不能为空', trigger: 'blur' }], + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const productList = ref<ProductVO[]>([]) // 产品列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const defaultWarehouse = ref<WarehouseVO>(undefined) // 默认仓库 + +/** 初始化设置盘点项 */ +watch( + () => props.items, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + if (item.stockCount != null && item.actualCount != null) { + item.count = item.actualCount - item.stockCount + } else { + item.count = undefined + } + item.totalPrice = erpPriceMultiply(item.productPrice, item.count) + }) + }, + { deep: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['count', 'totalPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = + column.property === 'count' ? erpCountInputFormatter(sum) : erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + + return sums +} + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + warehouseId: defaultWarehouse.value?.id, + productId: undefined, + productUnitName: undefined, // 产品单位 + productBarCode: undefined, // 产品条码 + productPrice: undefined, + stockCount: undefined, + actualCount: undefined, + count: undefined, + totalPrice: undefined, + remark: undefined + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index) => { + formData.value.splice(index, 1) +} + +/** 处理仓库变更 */ +const onChangeWarehouse = (warehouseId, row) => { + // 加载库存 + setStockCount(row) +} + +/** 处理产品变更 */ +const onChangeProduct = (productId, row) => { + const product = productList.value.find((item) => item.id === productId) + if (product) { + row.productUnitName = product.unitName + row.productBarCode = product.barCode + row.productPrice = product.minPrice + } + // 加载库存 + setStockCount(row) +} + +/** 加载库存 */ +const setStockCount = async (row) => { + if (!row.productId || !row.warehouseId) { + return + } + const stock = await StockApi.getStock2(row.productId, row.warehouseId) + row.stockCount = stock ? stock.count : 0 + row.actualCount = row.stockCount +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + productList.value = await ProductApi.getProductSimpleList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + defaultWarehouse.value = warehouseList.value.find((item) => item.defaultStatus) + // 默认添加一个 + if (formData.value.length === 0) { + handleAdd() + } +}) +</script> diff --git a/src/views/erp/stock/check/index.vue b/src/views/erp/stock/check/index.vue new file mode 100644 index 0000000..f661ab7 --- /dev/null +++ b/src/views/erp/stock/check/index.vue @@ -0,0 +1,359 @@ +<template> + <doc-alert + title="【库存】库存调拨、库存盘点" + url="https://doc.iocoder.cn/erp/stock-move-check/" + /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="盘点单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入盘点单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="盘点时间" prop="checkTime"> + <el-date-picker + v-model="queryParams.checkTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="仓库" prop="warehouseId"> + <el-select + v-model="queryParams.warehouseId" + clearable + filterable + placeholder="请选择仓库" + class="!w-240px" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:stock-check:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:stock-check:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:stock-check:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="盘点单号" align="center" prop="no" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column + label="盘点时间" + align="center" + prop="checkTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:stock-check:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:stock-check:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:stock-check:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:stock-check:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:stock-check:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <StockCheckForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { StockCheckApi, StockCheckVO } from '@/api/erp/stock/check' +import StockCheckForm from './StockCheckForm.vue' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { erpCountTableColumnFormatter, erpPriceTableColumnFormatter } from '@/utils' + +/** ERP 其它盘点单列表 */ +defineOptions({ name: 'ErpStockCheck' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<StockCheckVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + warehouseId: undefined, + checkTime: [], + status: undefined, + remark: undefined, + creator: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const userList = ref<UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await StockCheckApi.getStockCheckPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await StockCheckApi.deleteStockCheck(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该盘点单吗?`) + // 发起审批 + await StockCheckApi.updateStockCheckStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await StockCheckApi.exportStockCheck(queryParams) + download.excel(data, '其它盘点单.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<StockCheckVO[]>([]) +const handleSelectionChange = (rows: StockCheckVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表、客户 + productList.value = await ProductApi.getProductSimpleList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + userList.value = await UserApi.getSimpleUserList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/stock/in/StockInForm.vue b/src/views/erp/stock/in/StockInForm.vue new file mode 100644 index 0000000..f36bbb6 --- /dev/null +++ b/src/views/erp/stock/in/StockInForm.vue @@ -0,0 +1,170 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1080"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="入库单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="入库时间" prop="inTime"> + <el-date-picker + v-model="formData.inTime" + type="date" + value-format="x" + placeholder="选择入库时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="formData.supplierId" + clearable + filterable + placeholder="请选择供应商" + class="!w-1/1" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="入库产品清单" name="item"> + <StockInItemForm ref="itemFormRef" :items="formData.items" :disabled="disabled" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { StockInApi, StockInVO } from '@/api/erp/stock/in' +import StockInItemForm from './components/StockInItemForm.vue' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' + +/** ERP 其它入库单 表单 */ +defineOptions({ name: 'StockInForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + supplierId: undefined, + inTime: undefined, + remark: undefined, + fileUrl: '', + items: [] +}) +const formRules = reactive({ + inTime: [{ required: true, message: '入库时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await StockInApi.getStockIn(id) + } finally { + formLoading.value = false + } + } + // 加载供应商列表 + supplierList.value = await SupplierApi.getSupplierSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as StockInVO + if (formType.value === 'create') { + await StockInApi.createStockIn(data) + message.success(t('common.createSuccess')) + } else { + await StockInApi.updateStockIn(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + supplierId: undefined, + inTime: undefined, + remark: undefined, + fileUrl: undefined, + items: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/stock/in/components/StockInItemForm.vue b/src/views/erp/stock/in/components/StockInItemForm.vue new file mode 100644 index 0000000..53a2fd2 --- /dev/null +++ b/src/views/erp/stock/in/components/StockInItemForm.vue @@ -0,0 +1,267 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="仓库名称" min-width="125"> + <template #default="{ row, $index }"> + <el-form-item + :prop="`${$index}.warehouseId`" + :rules="formRules.warehouseId" + class="mb-0px!" + > + <el-select + v-model="row.warehouseId" + clearable + filterable + placeholder="请选择仓库" + @change="onChangeWarehouse($event, row)" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!"> + <el-select + v-model="row.productId" + clearable + filterable + @change="onChangeProduct($event, row)" + placeholder="请选择产品" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="库存" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productBarCode" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productUnitName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品单价" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productPrice`" class="mb-0px!"> + <el-input-number + v-model="row.productPrice" + controls-position="right" + :min="0.01" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="合计金额" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3" v-if="!disabled"> + <el-button @click="handleAdd" round>+ 添加入库产品</el-button> + </el-row> +</template> +<script setup lang="ts"> +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { StockApi } from '@/api/erp/stock/stock' +import { + erpCountInputFormatter, + erpPriceInputFormatter, + erpPriceMultiply, + getSumValue +} from '@/utils' + +const props = defineProps<{ + items: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + inId: [{ required: true, message: '入库编号不能为空', trigger: 'blur' }], + warehouseId: [{ required: true, message: '仓库不能为空', trigger: 'blur' }], + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const productList = ref<ProductVO[]>([]) // 产品列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const defaultWarehouse = ref<WarehouseVO>(undefined) // 默认仓库 + +/** 初始化设置入库项 */ +watch( + () => props.items, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + item.totalPrice = erpPriceMultiply(item.productPrice, item.count) + }) + }, + { deep: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['count', 'totalPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = + column.property === 'count' ? erpCountInputFormatter(sum) : erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + + return sums +} + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + warehouseId: defaultWarehouse.value?.id, + productId: undefined, + productUnitName: undefined, // 产品单位 + productBarCode: undefined, // 产品条码 + productPrice: undefined, + stockCount: undefined, + count: 1, + totalPrice: undefined, + remark: undefined + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index) => { + formData.value.splice(index, 1) +} + +/** 处理仓库变更 */ +const onChangeWarehouse = (warehouseId, row) => { + // 加载库存 + setStockCount(row) +} + +/** 处理产品变更 */ +const onChangeProduct = (productId, row) => { + const product = productList.value.find((item) => item.id === productId) + if (product) { + row.productUnitName = product.unitName + row.productBarCode = product.barCode + row.productPrice = product.minPrice + } + // 加载库存 + setStockCount(row) +} + +/** 加载库存 */ +const setStockCount = async (row) => { + if (!row.productId || !row.warehouseId) { + return + } + const stock = await StockApi.getStock2(row.productId, row.warehouseId) + row.stockCount = stock ? stock.count : 0 +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + productList.value = await ProductApi.getProductSimpleList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + defaultWarehouse.value = warehouseList.value.find((item) => item.defaultStatus) + // 默认添加一个 + if (formData.value.length === 0) { + handleAdd() + } +}) +</script> diff --git a/src/views/erp/stock/in/index.vue b/src/views/erp/stock/in/index.vue new file mode 100644 index 0000000..5a8f6cf --- /dev/null +++ b/src/views/erp/stock/in/index.vue @@ -0,0 +1,376 @@ +<template> + <doc-alert title="【库存】其它入库、其它出库" url="https://doc.iocoder.cn/erp/stock-in-out/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="入库单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入入库单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="入库时间" prop="inTime"> + <el-date-picker + v-model="queryParams.inTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="供应商" prop="supplierId"> + <el-select + v-model="queryParams.supplierId" + clearable + filterable + placeholder="请选择供应商" + class="!w-240px" + > + <el-option + v-for="item in supplierList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="仓库" prop="warehouseId"> + <el-select + v-model="queryParams.warehouseId" + clearable + filterable + placeholder="请选择仓库" + class="!w-240px" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:stock-in:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:stock-in:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:stock-in:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="入库单号" align="center" prop="no" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column label="供应商" align="center" prop="supplierName" /> + <el-table-column + label="入库时间" + align="center" + prop="inTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:stock-in:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:stock-in:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:stock-in:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:stock-in:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:stock-in:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <StockInForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { StockInApi, StockInVO } from '@/api/erp/stock/in' +import StockInForm from './StockInForm.vue' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { erpCountTableColumnFormatter, erpPriceTableColumnFormatter } from '@/utils' + +/** ERP 其它入库单列表 */ +defineOptions({ name: 'ErpStockIn' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<StockInVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + supplierId: undefined, + inTime: [], + status: undefined, + remark: undefined, + creator: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const supplierList = ref<SupplierVO[]>([]) // 供应商列表 +const userList = ref<UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await StockInApi.getStockInPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await StockInApi.deleteStockIn(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该入库单吗?`) + // 发起审批 + await StockInApi.updateStockInStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await StockInApi.exportStockIn(queryParams) + download.excel(data, '其它入库单.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<StockInVO[]>([]) +const handleSelectionChange = (rows: StockInVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表、供应商 + productList.value = await ProductApi.getProductSimpleList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + supplierList.value = await SupplierApi.getSupplierSimpleList() + userList.value = await UserApi.getSimpleUserList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/stock/move/StockMoveForm.vue b/src/views/erp/stock/move/StockMoveForm.vue new file mode 100644 index 0000000..df942c6 --- /dev/null +++ b/src/views/erp/stock/move/StockMoveForm.vue @@ -0,0 +1,148 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1080"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="调度单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="调度时间" prop="moveTime"> + <el-date-picker + v-model="formData.moveTime" + type="date" + value-format="x" + placeholder="选择调度时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="调度产品清单" name="item"> + <StockMoveItemForm ref="itemFormRef" :items="formData.items" :disabled="disabled" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { StockMoveApi, StockMoveVO } from '@/api/erp/stock/move' +import StockMoveItemForm from './components/StockMoveItemForm.vue' + +/** ERP 库存调度单表单 */ +defineOptions({ name: 'StockMoveForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + customerId: undefined, + moveTime: undefined, + remark: undefined, + fileUrl: '', + items: [] +}) +const formRules = reactive({ + moveTime: [{ required: true, message: '调度时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await StockMoveApi.getStockMove(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as StockMoveVO + if (formType.value === 'create') { + await StockMoveApi.createStockMove(data) + message.success(t('common.createSuccess')) + } else { + await StockMoveApi.updateStockMove(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + customerId: undefined, + moveTime: undefined, + remark: undefined, + fileUrl: undefined, + items: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/stock/move/components/StockMoveItemForm.vue b/src/views/erp/stock/move/components/StockMoveItemForm.vue new file mode 100644 index 0000000..8971956 --- /dev/null +++ b/src/views/erp/stock/move/components/StockMoveItemForm.vue @@ -0,0 +1,292 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="调出仓库" min-width="125"> + <template #default="{ row, $index }"> + <el-form-item + :prop="`${$index}.fromWarehouseId`" + :rules="formRules.fromWarehouseId" + class="mb-0px!" + > + <el-select + v-model="row.fromWarehouseId" + clearable + filterable + placeholder="请选择调出仓库" + @change="onChangeWarehouse($event, row)" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="调入仓库" min-width="125"> + <template #default="{ row, $index }"> + <el-form-item + :prop="`${$index}.toWarehouseId`" + :rules="formRules.toWarehouseId" + class="mb-0px!" + > + <el-select + v-model="row.toWarehouseId" + clearable + filterable + placeholder="请选择调入仓库" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!"> + <el-select + v-model="row.productId" + clearable + filterable + @change="onChangeProduct($event, row)" + placeholder="请选择产品" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="库存" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productBarCode" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productUnitName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品单价" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productPrice`" class="mb-0px!"> + <el-input-number + v-model="row.productPrice" + controls-position="right" + :min="0.01" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="合计金额" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3" v-if="!disabled"> + <el-button @click="handleAdd" round>+ 添加调度产品</el-button> + </el-row> +</template> +<script setup lang="ts"> +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { StockApi } from '@/api/erp/stock/stock' +import { + erpCountInputFormatter, + erpPriceInputFormatter, + erpPriceMultiply, + getSumValue +} from '@/utils' + +const props = defineProps<{ + items: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + inId: [{ required: true, message: '调度编号不能为空', trigger: 'blur' }], + fromWarehouseId: [{ required: true, message: '调出仓库不能为空', trigger: 'blur' }], + toWarehouseId: [{ required: true, message: '调入仓库不能为空', trigger: 'blur' }], + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const productList = ref<ProductVO[]>([]) // 产品列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const defaultWarehouse = ref<WarehouseVO>(undefined) // 默认仓库 + +/** 初始化设置调度项 */ +watch( + () => props.items, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + item.totalPrice = erpPriceMultiply(item.productPrice, item.count) + }) + }, + { deep: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['count', 'totalPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = + column.property === 'count' ? erpCountInputFormatter(sum) : erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + + return sums +} + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + fromWarehouseId: defaultWarehouse.value?.id, + toWarehouseId: undefined, + productId: undefined, + productUnitName: undefined, // 产品单位 + productBarCode: undefined, // 产品条码 + productPrice: undefined, + stockCount: undefined, + count: 1, + totalPrice: undefined, + remark: undefined + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index) => { + formData.value.splice(index, 1) +} + +/** 处理仓库变更 */ +const onChangeWarehouse = (warehouseId, row) => { + // 加载库存 + setStockCount(row) +} + +/** 处理产品变更 */ +const onChangeProduct = (productId, row) => { + const product = productList.value.find((item) => item.id === productId) + if (product) { + row.productUnitName = product.unitName + row.productBarCode = product.barCode + row.productPrice = product.minPrice + } + // 加载库存 + setStockCount(row) +} + +/** 加载库存 */ +const setStockCount = async (row) => { + if (!row.productId || !row.fromWarehouseId) { + return + } + const stock = await StockApi.getStock2(row.productId, row.fromWarehouseId) + row.stockCount = stock ? stock.count : 0 +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + productList.value = await ProductApi.getProductSimpleList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + defaultWarehouse.value = warehouseList.value.find((item) => item.defaultStatus) + // 默认添加一个 + if (formData.value.length === 0) { + handleAdd() + } +}) +</script> diff --git a/src/views/erp/stock/move/index.vue b/src/views/erp/stock/move/index.vue new file mode 100644 index 0000000..76ea653 --- /dev/null +++ b/src/views/erp/stock/move/index.vue @@ -0,0 +1,359 @@ +<template> + <doc-alert + title="【库存】库存调拨、库存盘点" + url="https://doc.iocoder.cn/erp/stock-move-check/" + /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="调度单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入调度单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="调度时间" prop="moveTime"> + <el-date-picker + v-model="queryParams.moveTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="仓库" prop="fromWarehouseId"> + <el-select + v-model="queryParams.fromWarehouseId" + clearable + filterable + placeholder="请选择仓库" + class="!w-240px" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:stock-move:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:stock-move:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:stock-move:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="调度单号" align="center" prop="no" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column + label="调度时间" + align="center" + prop="moveTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:stock-move:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:stock-move:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:stock-move:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:stock-move:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:stock-move:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <StockMoveForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { StockMoveApi, StockMoveVO } from '@/api/erp/stock/move' +import StockMoveForm from './StockMoveForm.vue' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { erpCountTableColumnFormatter, erpPriceTableColumnFormatter } from '@/utils' + +/** ERP 库存调度单列表 */ +defineOptions({ name: 'ErpStockMove' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<StockMoveVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + fromWarehouseId: undefined, + moveTime: [], + status: undefined, + remark: undefined, + creator: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const userList = ref<UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await StockMoveApi.getStockMovePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await StockMoveApi.deleteStockMove(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该调度单吗?`) + // 发起审批 + await StockMoveApi.updateStockMoveStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await StockMoveApi.exportStockMove(queryParams) + download.excel(data, '库存调度单.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<StockMoveVO[]>([]) +const handleSelectionChange = (rows: StockMoveVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表、客户 + productList.value = await ProductApi.getProductSimpleList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + userList.value = await UserApi.getSimpleUserList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/stock/out/StockOutForm.vue b/src/views/erp/stock/out/StockOutForm.vue new file mode 100644 index 0000000..8ae8d63 --- /dev/null +++ b/src/views/erp/stock/out/StockOutForm.vue @@ -0,0 +1,170 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1080"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + :disabled="disabled" + > + <el-row :gutter="20"> + <el-col :span="8"> + <el-form-item label="出库单号" prop="no"> + <el-input disabled v-model="formData.no" placeholder="保存时自动生成" /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="出库时间" prop="outTime"> + <el-date-picker + v-model="formData.outTime" + type="date" + value-format="x" + placeholder="选择出库时间" + class="!w-1/1" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="formData.customerId" + clearable + filterable + placeholder="请选择客户" + class="!w-1/1" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="16"> + <el-form-item label="备注" prop="remark"> + <el-input + type="textarea" + v-model="formData.remark" + :rows="1" + placeholder="请输入备注" + /> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="附件" prop="fileUrl"> + <UploadFile :is-show-tip="false" v-model="formData.fileUrl" :limit="1" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <!-- 子表的表单 --> + <ContentWrap> + <el-tabs v-model="subTabsName" class="-mt-15px -mb-10px"> + <el-tab-pane label="出库产品清单" name="item"> + <StockOutItemForm ref="itemFormRef" :items="formData.items" :disabled="disabled" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading" v-if="!disabled"> + 确 定 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { StockOutApi, StockOutVO } from '@/api/erp/stock/out' +import StockOutItemForm from './components/StockOutItemForm.vue' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' + +/** ERP 其它出库单表单 */ +defineOptions({ name: 'StockOutForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改;detail - 详情 +const formData = ref({ + id: undefined, + customerId: undefined, + outTime: undefined, + remark: undefined, + fileUrl: '', + items: [] +}) +const formRules = reactive({ + outTime: [{ required: true, message: '出库时间不能为空', trigger: 'blur' }] +}) +const disabled = computed(() => formType.value === 'detail') +const formRef = ref() // 表单 Ref +const customerList = ref<CustomerVO[]>([]) // 客户列表 + +/** 子表的表单 */ +const subTabsName = ref('item') +const itemFormRef = ref() + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await StockOutApi.getStockOut(id) + } finally { + formLoading.value = false + } + } + // 加载客户列表 + customerList.value = await CustomerApi.getCustomerSimpleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + await itemFormRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as StockOutVO + if (formType.value === 'create') { + await StockOutApi.createStockOut(data) + message.success(t('common.createSuccess')) + } else { + await StockOutApi.updateStockOut(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + customerId: undefined, + outTime: undefined, + remark: undefined, + fileUrl: undefined, + items: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/stock/out/components/StockOutItemForm.vue b/src/views/erp/stock/out/components/StockOutItemForm.vue new file mode 100644 index 0000000..b09a569 --- /dev/null +++ b/src/views/erp/stock/out/components/StockOutItemForm.vue @@ -0,0 +1,267 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + :disabled="disabled" + > + <el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px"> + <el-table-column label="序号" type="index" align="center" width="60" /> + <el-table-column label="仓库名称" min-width="125"> + <template #default="{ row, $index }"> + <el-form-item + :prop="`${$index}.warehouseId`" + :rules="formRules.warehouseId" + class="mb-0px!" + > + <el-select + v-model="row.warehouseId" + clearable + filterable + placeholder="请选择仓库" + @change="onChangeWarehouse($event, row)" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品名称" min-width="180"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!"> + <el-select + v-model="row.productId" + clearable + filterable + @change="onChangeProduct($event, row)" + placeholder="请选择产品" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="库存" min-width="100"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="条码" min-width="150"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productBarCode" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="单位" min-width="80"> + <template #default="{ row }"> + <el-form-item class="mb-0px!"> + <el-input disabled v-model="row.productUnitName" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="数量" prop="count" fixed="right" min-width="140"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!"> + <el-input-number + v-model="row.count" + controls-position="right" + :min="0.001" + :precision="3" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="产品单价" fixed="right" min-width="120"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.productPrice`" class="mb-0px!"> + <el-input-number + v-model="row.productPrice" + controls-position="right" + :min="0.01" + :precision="2" + class="!w-100%" + /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="合计金额" prop="totalPrice" fixed="right" min-width="100"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.totalPrice`" class="mb-0px!"> + <el-input disabled v-model="row.totalPrice" :formatter="erpPriceInputFormatter" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="备注" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.remark`" class="mb-0px!"> + <el-input v-model="row.remark" placeholder="请输入备注" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3" v-if="!disabled"> + <el-button @click="handleAdd" round>+ 添加出库产品</el-button> + </el-row> +</template> +<script setup lang="ts"> +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { StockApi } from '@/api/erp/stock/stock' +import { + erpCountInputFormatter, + erpPriceInputFormatter, + erpPriceMultiply, + getSumValue +} from '@/utils' + +const props = defineProps<{ + items: undefined + disabled: false +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + inId: [{ required: true, message: '出库编号不能为空', trigger: 'blur' }], + warehouseId: [{ required: true, message: '仓库不能为空', trigger: 'blur' }], + productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }], + count: [{ required: true, message: '产品数量不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref +const productList = ref<ProductVO[]>([]) // 产品列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const defaultWarehouse = ref<WarehouseVO>(undefined) // 默认仓库 + +/** 初始化设置出库项 */ +watch( + () => props.items, + async (val) => { + formData.value = val + }, + { immediate: true } +) + +/** 监听合同产品变化,计算合同产品总价 */ +watch( + () => formData.value, + (val) => { + if (!val || val.length === 0) { + return + } + // 循环处理 + val.forEach((item) => { + item.totalPrice = erpPriceMultiply(item.productPrice, item.count) + }) + }, + { deep: true } +) + +/** 合计 */ +const getSummaries = (param: SummaryMethodProps) => { + const { columns, data } = param + const sums: string[] = [] + columns.forEach((column, index) => { + if (index === 0) { + sums[index] = '合计' + return + } + if (['count', 'totalPrice'].includes(column.property)) { + const sum = getSumValue(data.map((item) => Number(item[column.property]))) + sums[index] = + column.property === 'count' ? erpCountInputFormatter(sum) : erpPriceInputFormatter(sum) + } else { + sums[index] = '' + } + }) + + return sums +} + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + warehouseId: defaultWarehouse.value?.id, + productId: undefined, + productUnitName: undefined, // 产品单位 + productBarCode: undefined, // 产品条码 + productPrice: undefined, + stockCount: undefined, + count: 1, + totalPrice: undefined, + remark: undefined + } + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index) => { + formData.value.splice(index, 1) +} + +/** 处理仓库变更 */ +const onChangeWarehouse = (warehouseId, row) => { + // 加载库存 + setStockCount(row) +} + +/** 处理产品变更 */ +const onChangeProduct = (productId, row) => { + const product = productList.value.find((item) => item.id === productId) + if (product) { + row.productUnitName = product.unitName + row.productBarCode = product.barCode + row.productPrice = product.minPrice + } + // 加载库存 + setStockCount(row) +} + +/** 加载库存 */ +const setStockCount = async (row) => { + if (!row.productId || !row.warehouseId) { + return + } + const stock = await StockApi.getStock2(row.productId, row.warehouseId) + row.stockCount = stock ? stock.count : 0 +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} +defineExpose({ validate }) + +/** 初始化 */ +onMounted(async () => { + productList.value = await ProductApi.getProductSimpleList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + defaultWarehouse.value = warehouseList.value.find((item) => item.defaultStatus) + // 默认添加一个 + if (formData.value.length === 0) { + handleAdd() + } +}) +</script> diff --git a/src/views/erp/stock/out/index.vue b/src/views/erp/stock/out/index.vue new file mode 100644 index 0000000..555b985 --- /dev/null +++ b/src/views/erp/stock/out/index.vue @@ -0,0 +1,378 @@ +<template> + <doc-alert title="【库存】其它入库、其它出库" url="https://doc.iocoder.cn/erp/stock-in-out/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="出库单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入出库单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="出库时间" prop="outTime"> + <el-date-picker + v-model="queryParams.outTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="客户" prop="customerId"> + <el-select + v-model="queryParams.customerId" + clearable + filterable + placeholder="请选择供客户" + class="!w-240px" + > + <el-option + v-for="item in customerList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="仓库" prop="warehouseId"> + <el-select + v-model="queryParams.warehouseId" + clearable + filterable + placeholder="请选择仓库" + class="!w-240px" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="创建人" prop="creator"> + <el-select + v-model="queryParams.creator" + clearable + filterable + placeholder="请选择创建人" + class="!w-240px" + > + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_AUDIT_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input + v-model="queryParams.remark" + placeholder="请输入备注" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:stock-out:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:stock-out:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button + type="danger" + plain + @click="handleDelete(selectionList.map((item) => item.id))" + v-hasPermi="['erp:stock-out:delete']" + :disabled="selectionList.length === 0" + > + <Icon icon="ep:delete" class="mr-5px" /> 删除 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + @selection-change="handleSelectionChange" + > + <el-table-column width="30" label="选择" type="selection" /> + <el-table-column min-width="180" label="出库单号" align="center" prop="no" /> + <el-table-column label="产品信息" align="center" prop="productNames" min-width="200" /> + <el-table-column label="客户" align="center" prop="customerName" /> + <el-table-column + label="出库时间" + align="center" + prop="outTime" + :formatter="dateFormatter2" + width="120px" + /> + <el-table-column label="创建人" align="center" prop="creatorName" /> + <el-table-column + label="数量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="金额" + align="center" + prop="totalPrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="状态" align="center" fixed="right" width="90" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_AUDIT_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="220"> + <template #default="scope"> + <el-button + link + @click="openForm('detail', scope.row.id)" + v-hasPermi="['erp:stock-out:query']" + > + 详情 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:stock-out:update']" + :disabled="scope.row.status === 20" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="handleUpdateStatus(scope.row.id, 20)" + v-hasPermi="['erp:stock-out:update-status']" + v-if="scope.row.status === 10" + > + 审批 + </el-button> + <el-button + link + type="danger" + @click="handleUpdateStatus(scope.row.id, 10)" + v-hasPermi="['erp:stock-out:update-status']" + v-else + > + 反审批 + </el-button> + <el-button + link + type="danger" + @click="handleDelete([scope.row.id])" + v-hasPermi="['erp:stock-out:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <StockOutForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter2 } from '@/utils/formatTime' +import download from '@/utils/download' +import { StockOutApi, StockOutVO } from '@/api/erp/stock/out' +import StockOutForm from './StockOutForm.vue' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier' +import { UserVO } from '@/api/system/user' +import * as UserApi from '@/api/system/user' +import { erpCountTableColumnFormatter, erpPriceTableColumnFormatter } from '@/utils' +import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer' + +/** ERP 其它出库单列表 */ +defineOptions({ name: 'ErpStockOut' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<StockOutVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: undefined, + productId: undefined, + customerId: undefined, + warehouseId: undefined, + outTime: [], + status: undefined, + remark: undefined, + creator: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 +const customerList = ref<CustomerVO[]>([]) // 客户列表 +const userList = ref<UserVO[]>([]) // 用户列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await StockOutApi.getStockOutPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (ids: number[]) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await StockOutApi.deleteStockOut(ids) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + selectionList.value = selectionList.value.filter((item) => !ids.includes(item.id)) + } catch {} +} + +/** 审批/反审批操作 */ +const handleUpdateStatus = async (id: number, status: number) => { + try { + // 审批的二次确认 + await message.confirm(`确定${status === 20 ? '审批' : '反审批'}该出库单吗?`) + // 发起审批 + await StockOutApi.updateStockOutStatus(id, status) + message.success(`${status === 20 ? '审批' : '反审批'}成功`) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await StockOutApi.exportStockOut(queryParams) + download.excel(data, '其它出库单.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中操作 */ +const selectionList = ref<StockOutVO[]>([]) +const handleSelectionChange = (rows: StockOutVO[]) => { + selectionList.value = rows +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表、客户 + productList.value = await ProductApi.getProductSimpleList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() + customerList.value = await CustomerApi.getCustomerSimpleList() + userList.value = await UserApi.getSimpleUserList() +}) +// TODO 芋艿:可优化功能:列表界面,支持导入 +// TODO 芋艿:可优化功能:详情界面,支持打印 +</script> diff --git a/src/views/erp/stock/record/index.vue b/src/views/erp/stock/record/index.vue new file mode 100644 index 0000000..6946a19 --- /dev/null +++ b/src/views/erp/stock/record/index.vue @@ -0,0 +1,250 @@ +<!-- ERP 产品库存明细列表 --> +<template> + <doc-alert title="【库存】产品库存、库存明细" url="https://doc.iocoder.cn/erp/stock/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="仓库" prop="warehouseId"> + <el-select + v-model="queryParams.warehouseId" + clearable + filterable + placeholder="请选择仓库" + class="!w-240px" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="类型" prop="bizType"> + <el-select + v-model="queryParams.bizType" + placeholder="请选择类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.ERP_STOCK_RECORD_BIZ_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="业务单号" prop="bizNo"> + <el-input + v-model="queryParams.bizNo" + placeholder="请输入业务单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:stock-record:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:stock-record:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="产品名称" align="center" prop="productName" /> + <el-table-column label="产品分类" align="center" prop="categoryName" /> + <el-table-column label="产品单位" align="center" prop="unitName" /> + <el-table-column label="仓库编号" align="center" prop="warehouseName" /> + <el-table-column label="类型" align="center" prop="bizType" min-width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.ERP_STOCK_RECORD_BIZ_TYPE" :value="scope.row.bizType" /> + </template> + </el-table-column> + <el-table-column label="出入库单号" align="center" prop="bizNo" width="200" /> + <el-table-column + label="出入库日期" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column + label="出入库数量" + align="center" + prop="count" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column + label="库存量" + align="center" + prop="totalCount" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column label="操作人" align="center" prop="creatorName" /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import { StockRecordApi, StockRecordVO } from '@/api/erp/stock/record' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { erpCountTableColumnFormatter } from '@/utils' + +/** ERP 产品库存明细列表 */ +defineOptions({ name: 'ErpStockRecord' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<StockRecordVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + productId: undefined, + warehouseId: undefined, + bizType: undefined, + bizNo: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await StockRecordApi.getStockRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await StockRecordApi.deleteStockRecord(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await StockRecordApi.exportStockRecord(queryParams) + download.excel(data, '产品库存明细.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onActivated(() => { + getList() +}) + +onMounted(async () => { + await getList() + // 加载产品、仓库列表 + productList.value = await ProductApi.getProductSimpleList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() +}) +</script> diff --git a/src/views/erp/stock/stock/index.vue b/src/views/erp/stock/stock/index.vue new file mode 100644 index 0000000..4d80117 --- /dev/null +++ b/src/views/erp/stock/stock/index.vue @@ -0,0 +1,186 @@ +<!-- ERP 产品库存列表 --> +<template> + <doc-alert title="【库存】产品库存、库存明细" url="https://doc.iocoder.cn/erp/stock/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="产品" prop="productId"> + <el-select + v-model="queryParams.productId" + clearable + filterable + placeholder="请选择产品" + class="!w-240px" + > + <el-option + v-for="item in productList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="仓库" prop="warehouseId"> + <el-select + v-model="queryParams.warehouseId" + clearable + filterable + placeholder="请选择仓库" + class="!w-240px" + > + <el-option + v-for="item in warehouseList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:stock:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:stock:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="产品名称" align="center" prop="productName" /> + <el-table-column label="产品单位" align="center" prop="unitName" /> + <el-table-column label="产品分类" align="center" prop="categoryName" /> + <el-table-column + label="库存量" + align="center" + prop="count" + :formatter="erpCountTableColumnFormatter" + /> + <el-table-column label="仓库" align="center" prop="warehouseName" /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import download from '@/utils/download' +import { StockApi, StockVO } from '@/api/erp/stock/stock' +import { ProductApi, ProductVO } from '@/api/erp/product/product' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { erpCountTableColumnFormatter } from '@/utils' + +/** ERP 产品库存列表 */ +defineOptions({ name: 'ErpStock' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<StockVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + productId: undefined, + warehouseId: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const productList = ref<ProductVO[]>([]) // 产品列表 +const warehouseList = ref<WarehouseVO[]>([]) // 仓库列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await StockApi.getStockPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await StockApi.deleteStock(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await StockApi.exportStock(queryParams) + download.excel(data, '产品库存.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载产品、仓库列表 + productList.value = await ProductApi.getProductSimpleList() + warehouseList.value = await WarehouseApi.getWarehouseSimpleList() +}) +</script> diff --git a/src/views/erp/stock/warehouse/WarehouseForm.vue b/src/views/erp/stock/warehouse/WarehouseForm.vue new file mode 100644 index 0000000..ea88a18 --- /dev/null +++ b/src/views/erp/stock/warehouse/WarehouseForm.vue @@ -0,0 +1,157 @@ +<!-- ERP 仓库表单 --> +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="仓库名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入仓库名称" /> + </el-form-item> + <el-form-item label="仓库地址" prop="address"> + <el-input v-model="formData.address" placeholder="请输入仓库地址" /> + </el-form-item> + <el-form-item label="仓库状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="仓储费" prop="warehousePrice"> + <el-input-number + v-model="formData.warehousePrice" + placeholder="请输入仓储费,单位:元/天/KG" + :min="0" + :precision="2" + class="!w-1/1" + /> + </el-form-item> + <el-form-item label="搬运费" prop="truckagePrice"> + <el-input-number + v-model="formData.truckagePrice" + placeholder="请输入搬运费,单位:元" + :min="0" + :precision="2" + class="!w-1/1" + /> + </el-form-item> + <el-form-item label="负责人" prop="principal"> + <el-input v-model="formData.principal" placeholder="请输入负责人" /> + </el-form-item> + <el-form-item label="排序" prop="sort"> + <el-input-number + v-model="formData.sort" + placeholder="请输入排序" + :precision="0" + class="!w-1/1" + /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input type="textarea" v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import { CommonStatusEnum } from '@/utils/constants' + +/** ERP 仓库表单 */ +defineOptions({ name: 'WarehouseForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + address: undefined, + sort: undefined, + remark: undefined, + principal: undefined, + warehousePrice: undefined, + truckagePrice: undefined, + status: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '仓库名称不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await WarehouseApi.getWarehouse(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as WarehouseVO + if (formType.value === 'create') { + await WarehouseApi.createWarehouse(data) + message.success(t('common.createSuccess')) + } else { + await WarehouseApi.updateWarehouse(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + address: undefined, + sort: undefined, + remark: undefined, + principal: undefined, + warehousePrice: undefined, + truckagePrice: undefined, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/erp/stock/warehouse/index.vue b/src/views/erp/stock/warehouse/index.vue new file mode 100644 index 0000000..40bdebe --- /dev/null +++ b/src/views/erp/stock/warehouse/index.vue @@ -0,0 +1,242 @@ +<!-- ERP 仓库列表 --> +<template> + <doc-alert title="【库存】产品库存、库存明细" url="https://doc.iocoder.cn/erp/stock/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="仓库名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入仓库名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="仓库状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择仓库状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['erp:warehouse:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['erp:warehouse:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="仓库名称" align="center" prop="name" /> + <el-table-column label="仓库地址" align="center" prop="address" /> + <el-table-column + label="仓储费" + align="center" + prop="warehousePrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column + label="搬运费" + align="center" + prop="truckagePrice" + :formatter="erpPriceTableColumnFormatter" + /> + <el-table-column label="负责人" align="center" prop="principal" /> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column label="排序" align="center" prop="sort" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="是否默认" align="center" prop="defaultStatus"> + <template #default="scope"> + <el-switch + v-model="scope.row.defaultStatus" + :active-value="true" + :inactive-value="false" + @change="handleDefaultStatusChange(scope.row)" + /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['erp:warehouse:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['erp:warehouse:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <WarehouseForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse' +import WarehouseForm from './WarehouseForm.vue' +import { erpPriceTableColumnFormatter } from '@/utils' + +/** ERP 仓库列表 */ +defineOptions({ name: 'ErpWarehouse' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<WarehouseVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await WarehouseApi.getWarehousePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await WarehouseApi.deleteWarehouse(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 修改默认状态 */ +const handleDefaultStatusChange = async (row: WarehouseVO) => { + try { + // 修改状态的二次确认 + const text = row.defaultStatus ? '设置' : '取消' + await message.confirm('确认要' + text + '"' + row.name + '"默认吗?') + // 发起修改状态 + await WarehouseApi.updateWarehouseDefaultStatus(row.id, row.defaultStatus) + // 刷新列表 + await getList() + } catch (e) { + // 取消后,进行恢复按钮 + row.defaultStatus = !row.defaultStatus + } +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await WarehouseApi.exportWarehouse(queryParams) + download.excel(data, '仓库.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/apiAccessLog/ApiAccessLogDetail.vue b/src/views/infra/apiAccessLog/ApiAccessLogDetail.vue new file mode 100644 index 0000000..314fd26 --- /dev/null +++ b/src/views/infra/apiAccessLog/ApiAccessLogDetail.vue @@ -0,0 +1,79 @@ +<template> + <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="详情" width="800"> + <el-descriptions :column="1" border> + <el-descriptions-item label="日志主键" min-width="120"> + {{ detailData.id }} + </el-descriptions-item> + <el-descriptions-item label="链路追踪"> + {{ detailData.traceId }} + </el-descriptions-item> + <el-descriptions-item label="应用名"> + {{ detailData.applicationName }} + </el-descriptions-item> + <el-descriptions-item label="用户信息"> + {{ detailData.userId }} + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="detailData.userType" /> + </el-descriptions-item> + <el-descriptions-item label="用户 IP"> + {{ detailData.userIp }} + </el-descriptions-item> + <el-descriptions-item label="用户 UA"> + {{ detailData.userAgent }} + </el-descriptions-item> + <el-descriptions-item label="请求信息"> + {{ detailData.requestMethod }} {{ detailData.requestUrl }} + </el-descriptions-item> + <el-descriptions-item label="请求参数"> + {{ detailData.requestParams }} + </el-descriptions-item> + <el-descriptions-item label="请求结果"> + {{ detailData.responseBody }} + </el-descriptions-item> + <el-descriptions-item label="请求时间"> + {{ formatDate(detailData.beginTime) }} ~ {{ formatDate(detailData.endTime) }} + </el-descriptions-item> + <el-descriptions-item label="请求耗时">{{ detailData.duration }} ms</el-descriptions-item> + <el-descriptions-item label="操作结果"> + <div v-if="detailData.resultCode === 0">正常</div> + <div v-else-if="detailData.resultCode > 0"> + 失败 | {{ detailData.resultCode }} | {{ detailData.resultMsg }} + </div> + </el-descriptions-item> + <el-descriptions-item label="操作模块"> + {{ detailData.operateModule }} + </el-descriptions-item> + <el-descriptions-item label="操作名"> + {{ detailData.operateName }} + </el-descriptions-item> + <el-descriptions-item label="操作名"> + <dict-tag :type="DICT_TYPE.INFRA_OPERATE_TYPE" :value="detailData.operateType" /> + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> + +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import * as ApiAccessLog from '@/api/infra/apiAccessLog' + +defineOptions({ name: 'ApiAccessLogDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单地加载中 +const detailData = ref({} as ApiAccessLog.ApiAccessLogVO) // 详情数据 + +/** 打开弹窗 */ +const open = async (data: ApiAccessLog.ApiAccessLogVO) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = data + } finally { + detailLoading.value = false + } +} + +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/infra/apiAccessLog/index.vue b/src/views/infra/apiAccessLog/index.vue new file mode 100644 index 0000000..570f579 --- /dev/null +++ b/src/views/infra/apiAccessLog/index.vue @@ -0,0 +1,226 @@ +<template> + <doc-alert title="系统日志" url="https://doc.iocoder.cn/system-log/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-input + v-model="queryParams.userId" + placeholder="请输入用户编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="用户类型" prop="userType"> + <el-select + v-model="queryParams.userType" + placeholder="请选择用户类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="应用名" prop="applicationName"> + <el-input + v-model="queryParams.applicationName" + placeholder="请输入应用名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="请求时间" prop="beginTime"> + <el-date-picker + v-model="queryParams.beginTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="执行时长" prop="duration"> + <el-input + v-model="queryParams.duration" + placeholder="请输入执行时长" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="结果码" prop="resultCode"> + <el-input + v-model="queryParams.resultCode" + placeholder="请输入结果码" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:api-access-log:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="日志编号" align="center" prop="id" width="100" fix="right" /> + <el-table-column label="用户编号" align="center" prop="userId" /> + <el-table-column label="用户类型" align="center" prop="userType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" /> + </template> + </el-table-column> + <el-table-column label="应用名" align="center" prop="applicationName" width="150" /> + <el-table-column label="请求方法" align="center" prop="requestMethod" width="80" /> + <el-table-column label="请求地址" align="center" prop="requestUrl" width="500" /> + <el-table-column label="请求时间" align="center" prop="beginTime" width="180"> + <template #default="scope"> + <span>{{ formatDate(scope.row.beginTime) }}</span> + </template> + </el-table-column> + <el-table-column label="执行时长" align="center" prop="duration" width="180"> + <template #default="scope"> {{ scope.row.duration }} ms </template> + </el-table-column> + <el-table-column label="操作结果" align="center" prop="status"> + <template #default="scope"> + {{ scope.row.resultCode === 0 ? '成功' : '失败(' + scope.row.resultMsg + ')' }} + </template> + </el-table-column> + <el-table-column label="操作模块" align="center" prop="operateModule" width="180" /> + <el-table-column label="操作名" align="center" prop="operateName" width="180" /> + <el-table-column label="操作类型" align="center" prop="operateType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_OPERATE_TYPE" :value="scope.row.operateType" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" width="60"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openDetail(scope.row)" + v-hasPermi="['infra:api-access-log:query']" + > + 详细 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:详情 --> + <ApiAccessLogDetail ref="detailRef" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import download from '@/utils/download' +import { formatDate } from '@/utils/formatTime' +import * as ApiAccessLogApi from '@/api/infra/apiAccessLog' +import ApiAccessLogDetail from './ApiAccessLogDetail.vue' + +defineOptions({ name: 'InfraApiAccessLog' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: null, + userType: null, + applicationName: null, + requestUrl: null, + duration: null, + resultCode: null, + beginTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ApiAccessLogApi.getApiAccessLogPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 详情操作 */ +const detailRef = ref() +const openDetail = (data: ApiAccessLogApi.ApiAccessLogVO) => { + detailRef.value.open(data) +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ApiAccessLogApi.exportApiAccessLog(queryParams) + download.excel(data, 'API 访问日志.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/apiErrorLog/ApiErrorLogDetail.vue b/src/views/infra/apiErrorLog/ApiErrorLogDetail.vue new file mode 100644 index 0000000..41153a2 --- /dev/null +++ b/src/views/infra/apiErrorLog/ApiErrorLogDetail.vue @@ -0,0 +1,81 @@ +<template> + <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="详情" width="800"> + <el-descriptions :column="1" border> + <el-descriptions-item label="日志主键" min-width="120"> + {{ detailData.id }} + </el-descriptions-item> + <el-descriptions-item label="链路追踪"> + {{ detailData.traceId }} + </el-descriptions-item> + <el-descriptions-item label="应用名"> + {{ detailData.applicationName }} + </el-descriptions-item> + <el-descriptions-item label="用户编号"> + {{ detailData.userId }} + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="detailData.userType" /> + </el-descriptions-item> + <el-descriptions-item label="用户 IP"> + {{ detailData.userIp }} + </el-descriptions-item> + <el-descriptions-item label="用户 UA"> + {{ detailData.userAgent }} + </el-descriptions-item> + <el-descriptions-item label="请求信息"> + {{ detailData.requestMethod }} {{ detailData.requestUrl }} + </el-descriptions-item> + <el-descriptions-item label="请求参数"> + {{ detailData.requestParams }} + </el-descriptions-item> + <el-descriptions-item label="异常时间"> + {{ formatDate(detailData.exceptionTime) }} + </el-descriptions-item> + <el-descriptions-item label="异常名"> + {{ detailData.exceptionName }} + </el-descriptions-item> + <el-descriptions-item v-if="detailData.exceptionStackTrace" label="异常堆栈"> + <el-input + v-model="detailData.exceptionStackTrace" + :autosize="{ maxRows: 20 }" + :readonly="true" + type="textarea" + /> + </el-descriptions-item> + <el-descriptions-item label="处理状态"> + <dict-tag + :type="DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS" + :value="detailData.processStatus" + /> + </el-descriptions-item> + <el-descriptions-item v-if="detailData.processUserId" label="处理人"> + {{ detailData.processUserId }} + </el-descriptions-item> + <el-descriptions-item v-if="detailData.processTime" label="处理时间"> + {{ formatDate(detailData.processTime) }} + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import * as ApiErrorLog from '@/api/infra/apiErrorLog' + +defineOptions({ name: 'ApiErrorLogDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({} as ApiErrorLog.ApiErrorLogVO) // 详情数据 + +/** 打开弹窗 */ +const open = async (data: ApiErrorLog.ApiErrorLogVO) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = data + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/infra/apiErrorLog/index.vue b/src/views/infra/apiErrorLog/index.vue new file mode 100644 index 0000000..ca145a7 --- /dev/null +++ b/src/views/infra/apiErrorLog/index.vue @@ -0,0 +1,252 @@ +<template> + <doc-alert title="系统日志" url="https://doc.iocoder.cn/system-log/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-input + v-model="queryParams.userId" + placeholder="请输入用户编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="用户类型" prop="userType"> + <el-select + v-model="queryParams.userType" + placeholder="请选择用户类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="应用名" prop="applicationName"> + <el-input + v-model="queryParams.applicationName" + placeholder="请输入应用名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="异常时间" prop="exceptionTime"> + <el-date-picker + v-model="queryParams.exceptionTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="处理状态" prop="processStatus"> + <el-select + v-model="queryParams.processStatus" + placeholder="请选择处理状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:api-error-log:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="日志编号" align="center" prop="id" /> + <el-table-column label="用户编号" align="center" prop="userId" /> + <el-table-column label="用户类型" align="center" prop="userType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" /> + </template> + </el-table-column> + <el-table-column label="应用名" align="center" prop="applicationName" width="200" /> + <el-table-column label="请求方法" align="center" prop="requestMethod" width="80" /> + <el-table-column label="请求地址" align="center" prop="requestUrl" width="180" /> + <el-table-column + label="异常发生时间" + align="center" + prop="exceptionTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="异常名" align="center" prop="exceptionName" width="180" /> + <el-table-column label="处理状态" align="center" prop="processStatus"> + <template #default="scope"> + <dict-tag + :type="DICT_TYPE.INFRA_API_ERROR_LOG_PROCESS_STATUS" + :value="scope.row.processStatus" + /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" width="200"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openDetail(scope.row)" + v-hasPermi="['infra:api-error-log:query']" + > + 详细 + </el-button> + <el-button + link + type="primary" + v-if="scope.row.processStatus === InfraApiErrorLogProcessStatusEnum.INIT" + @click="handleProcess(scope.row.id, InfraApiErrorLogProcessStatusEnum.DONE)" + v-hasPermi="['infra:api-error-log:update-status']" + > + 已处理 + </el-button> + <el-button + link + type="primary" + v-if="scope.row.processStatus === InfraApiErrorLogProcessStatusEnum.INIT" + @click="handleProcess(scope.row.id, InfraApiErrorLogProcessStatusEnum.IGNORE)" + v-hasPermi="['infra:api-error-log:update-status']" + > + 已忽略 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:详情 --> + <ApiErrorLogDetail ref="detailRef" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as ApiErrorLogApi from '@/api/infra/apiErrorLog' +import ApiErrorLogDetail from './ApiErrorLogDetail.vue' +import { InfraApiErrorLogProcessStatusEnum } from '@/utils/constants' + +defineOptions({ name: 'InfraApiErrorLog' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: null, + userType: null, + applicationName: null, + requestUrl: null, + processStatus: null, + exceptionTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ApiErrorLogApi.getApiErrorLogPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 详情操作 */ +const detailRef = ref() +const openDetail = (data: ApiErrorLogApi.ApiErrorLogVO) => { + detailRef.value.open(data) +} + +/** 处理已处理 / 已忽略的操作 **/ +const handleProcess = async (id: number, processStatus: number) => { + try { + // 操作的二次确认 + const type = processStatus === InfraApiErrorLogProcessStatusEnum.DONE ? '已处理' : '已忽略' + await message.confirm('确认标记为' + type + '?') + // 执行操作 + await ApiErrorLogApi.updateApiErrorLogPage(id, processStatus) + await message.success(type) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ApiErrorLogApi.exportApiErrorLog(queryParams) + download.excel(data, '异常日志.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/build/index.vue b/src/views/infra/build/index.vue new file mode 100644 index 0000000..571acff --- /dev/null +++ b/src/views/infra/build/index.vue @@ -0,0 +1,142 @@ +<template> + <ContentWrap> + <el-row> + <el-col> + <div class="float-right mb-2"> + <el-button size="small" type="primary" @click="showJson">生成 JSON</el-button> + <el-button size="small" type="success" @click="showOption">生成 Options</el-button> + <el-button size="small" type="danger" @click="showTemplate">生成组件</el-button> + </div> + </el-col> + </el-row> + <!-- 表单设计器 --> + <FcDesigner ref="designer" height="780px" /> + </ContentWrap> + + <!-- 弹窗:表单预览 --> + <Dialog v-model="dialogVisible" :title="dialogTitle" max-height="600"> + <div v-if="dialogVisible" ref="editor"> + <el-button style="float: right" @click="copy(formData)"> + {{ t('common.copy') }} + </el-button> + <el-scrollbar height="580"> + <div> + <pre><code v-dompurify-html="highlightedCode(formData)" class="hljs"></code></pre> + </div> + </el-scrollbar> + </div> + </Dialog> +</template> +<script lang="ts" setup> +import { useFormCreateDesigner } from '@/components/FormCreate' +import { useClipboard } from '@vueuse/core' +import { isString } from '@/utils/is' + +import hljs from 'highlight.js' // 导入代码高亮文件 +import 'highlight.js/styles/github.css' // 导入代码高亮样式 +import xml from 'highlight.js/lib/languages/java' +import json from 'highlight.js/lib/languages/json' +import formCreate from '@form-create/element-ui' + +defineOptions({ name: 'InfraBuild' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息 + +const designer = ref() // 表单设计器 +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formType = ref(-1) // 表单的类型:0 - 生成 JSON;1 - 生成 Options;2 - 生成组件 +const formData = ref('') // 表单数据 +useFormCreateDesigner(designer) // 表单设计器增强 + +/** 打开弹窗 */ +const openModel = (title: string) => { + dialogVisible.value = true + dialogTitle.value = title +} + +/** 生成 JSON */ +const showJson = () => { + openModel('生成 JSON') + formType.value = 0 + formData.value = designer.value.getRule() +} + +/** 生成 Options */ +const showOption = () => { + openModel('生成 Options') + formType.value = 1 + formData.value = designer.value.getOption() +} + +/** 生成组件 */ +const showTemplate = () => { + openModel('生成组件') + formType.value = 2 + formData.value = makeTemplate() +} + +const makeTemplate = () => { + const rule = designer.value.getRule() + const opt = designer.value.getOption() + return `<template> + <form-create + v-model:api="fApi" + :rule="rule" + :option="option" + @submit="onSubmit" + ></form-create> + </template> + <script setup lang=ts> + const faps = ref(null) + const rule = ref('') + const option = ref('') + const init = () => { + rule.value = formCreate.parseJson('${formCreate.toJson(rule).replaceAll('\\', '\\\\')}') + option.value = formCreate.parseJson('${JSON.stringify(opt)}') + } + const onSubmit = (formData) => { + //todo 提交表单 + } + init() + <\/script>` +} + +/** 复制 **/ +const copy = async (text: string) => { + const { copy, copied, isSupported } = useClipboard({ source: text }) + if (!isSupported) { + message.error(t('common.copyError')) + } else { + await copy() + if (unref(copied)) { + message.success(t('common.copySuccess')) + } + } +} + +/** + * 代码高亮 + */ +const highlightedCode = (code) => { + // 处理语言和代码 + let language = 'json' + if (formType.value === 2) { + language = 'xml' + } + if (!isString(code)) { + code = JSON.stringify(code) + } + // 高亮 + const result = hljs.highlight(language, code, true) + return result.value || ' ' +} + +/** 初始化 **/ +onMounted(async () => { + // 注册代码高亮的各种语言 + hljs.registerLanguage('xml', xml) + hljs.registerLanguage('json', json) +}) +</script> diff --git a/src/views/infra/codegen/EditTable.vue b/src/views/infra/codegen/EditTable.vue new file mode 100644 index 0000000..f8473e3 --- /dev/null +++ b/src/views/infra/codegen/EditTable.vue @@ -0,0 +1,87 @@ +<template> + <ContentWrap v-loading="formLoading"> + <el-tabs v-model="activeName"> + <el-tab-pane label="基本信息" name="basicInfo"> + <basic-info-form ref="basicInfoRef" :table="formData.table" /> + </el-tab-pane> + <el-tab-pane label="字段信息" name="colum"> + <colum-info-form ref="columInfoRef" :columns="formData.columns" /> + </el-tab-pane> + <el-tab-pane label="生成信息" name="generateInfo"> + <generate-info-form + ref="generateInfoRef" + :table="formData.table" + :columns="formData.columns" + /> + </el-tab-pane> + </el-tabs> + <el-form> + <el-form-item style="float: right"> + <el-button :loading="formLoading" type="primary" @click="submitForm">保存</el-button> + <el-button @click="close">返回</el-button> + </el-form-item> + </el-form> + </ContentWrap> +</template> +<script lang="ts" setup> +import { useTagsViewStore } from '@/store/modules/tagsView' +import { BasicInfoForm, ColumInfoForm, GenerateInfoForm } from './components' +import * as CodegenApi from '@/api/infra/codegen' + +defineOptions({ name: 'InfraCodegenEditTable' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const { push, currentRoute } = useRouter() // 路由 +const { query } = useRoute() // 查询参数 +const { delView } = useTagsViewStore() // 视图操作 + +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const activeName = ref('colum') // Tag 激活的窗口 +const basicInfoRef = ref<ComponentRef<typeof BasicInfoForm>>() +const columInfoRef = ref<ComponentRef<typeof ColumInfoForm>>() +const generateInfoRef = ref<ComponentRef<typeof GenerateInfoForm>>() +const formData = ref<CodegenApi.CodegenUpdateReqVO>({ + table: {}, + columns: [] +}) + +/** 获得详情 */ +const getDetail = async () => { + const id = query.id as unknown as number + if (!id) { + return + } + formLoading.value = true + try { + formData.value = await CodegenApi.getCodegenTable(id) + } finally { + formLoading.value = false + } +} + +/** 提交按钮 */ +const submitForm = async () => { + // 参数校验 + if (!unref(formData)) return + await unref(basicInfoRef)?.validate() + await unref(generateInfoRef)?.validate() + try { + // 提交请求 + await CodegenApi.updateCodegenTable(formData.value) + message.success(t('common.updateSuccess')) + close() + } catch {} +} + +/** 关闭按钮 */ +const close = () => { + delView(unref(currentRoute)) + push('/infra/codegen') +} + +/** 初始化 */ +onMounted(() => { + getDetail() +}) +</script> diff --git a/src/views/infra/codegen/ImportTable.vue b/src/views/infra/codegen/ImportTable.vue new file mode 100644 index 0000000..6cd4610 --- /dev/null +++ b/src/views/infra/codegen/ImportTable.vue @@ -0,0 +1,151 @@ +<template> + <Dialog v-model="dialogVisible" title="导入表" width="800px"> + <!-- 搜索栏 --> + <el-form ref="queryFormRef" :inline="true" :model="queryParams" label-width="68px"> + <el-form-item label="数据源" prop="dataSourceConfigId"> + <el-select + v-model="queryParams.dataSourceConfigId" + class="!w-240px" + placeholder="请选择数据源" + > + <el-option + v-for="config in dataSourceConfigList" + :key="config.id" + :label="config.name" + :value="config.id" + /> + </el-select> + </el-form-item> + <el-form-item label="表名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入表名称" + @keyup.enter="getList" + /> + </el-form-item> + <el-form-item label="表描述" prop="comment"> + <el-input + v-model="queryParams.comment" + class="!w-240px" + clearable + placeholder="请输入表描述" + @keyup.enter="getList" + /> + </el-form-item> + <el-form-item> + <el-button @click="getList"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + <!-- 列表 --> + <el-row> + <el-table + ref="tableRef" + v-loading="dbTableLoading" + :data="dbTableList" + height="260px" + @row-click="handleRowClick" + @selection-change="handleSelectionChange" + > + <el-table-column type="selection" width="55" /> + <el-table-column :show-overflow-tooltip="true" label="表名称" prop="name" /> + <el-table-column :show-overflow-tooltip="true" label="表描述" prop="comment" /> + </el-table> + </el-row> + <!-- 操作 --> + <template #footer> + <el-button :disabled="tableList.length === 0" type="primary" @click="handleImportTable"> + 导入 + </el-button> + <el-button @click="close">关闭</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as CodegenApi from '@/api/infra/codegen' +import * as DataSourceConfigApi from '@/api/infra/dataSourceConfig' +import { ElTable } from 'element-plus' + +defineOptions({ name: 'InfraCodegenImportTable' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dbTableLoading = ref(true) // 数据源的加载中 +const dbTableList = ref<CodegenApi.DatabaseTableVO[]>([]) // 表的列表 +const queryParams = reactive({ + name: undefined, + comment: undefined, + dataSourceConfigId: 0 +}) +const queryFormRef = ref() // 搜索的表单 +const dataSourceConfigList = ref<DataSourceConfigApi.DataSourceConfigVO[]>([]) // 数据源列表 + +/** 查询表数据 */ +const getList = async () => { + dbTableLoading.value = true + try { + dbTableList.value = await CodegenApi.getSchemaTableList(queryParams) + } finally { + dbTableLoading.value = false + } +} + +/** 重置操作 */ +const resetQuery = async () => { + queryParams.name = undefined + queryParams.comment = undefined + queryParams.dataSourceConfigId = dataSourceConfigList.value[0].id as number + await getList() +} + +/** 打开弹窗 */ +const open = async () => { + // 加载数据源的列表 + dataSourceConfigList.value = await DataSourceConfigApi.getDataSourceConfigList() + queryParams.dataSourceConfigId = dataSourceConfigList.value[0].id as number + dialogVisible.value = true + // 加载表的列表 + await getList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 关闭弹窗 */ +const close = () => { + dialogVisible.value = false + tableList.value = [] +} + +const tableRef = ref<typeof ElTable>() // 表格的 Ref +const tableList = ref<string[]>([]) // 选中的表名 + +/** 处理某一行的点击 */ +const handleRowClick = (row) => { + unref(tableRef)?.toggleRowSelection(row) +} + +/** 多选框选中数据 */ +const handleSelectionChange = (selection) => { + tableList.value = selection.map((item) => item.name) +} + +/** 导入按钮操作 */ +const handleImportTable = async () => { + await CodegenApi.createCodegenList({ + dataSourceConfigId: queryParams.dataSourceConfigId, + tableNames: tableList.value + }) + message.success('导入成功') + emit('success') + close() +} +const emit = defineEmits(['success']) +</script> diff --git a/src/views/infra/codegen/PreviewCode.vue b/src/views/infra/codegen/PreviewCode.vue new file mode 100644 index 0000000..b6a307d --- /dev/null +++ b/src/views/infra/codegen/PreviewCode.vue @@ -0,0 +1,222 @@ +<template> + <Dialog + v-model="dialogVisible" + align-center + class="app-infra-codegen-preview-container" + title="代码预览" + width="80%" + > + <div class="flex"> + <!-- 代码目录树 --> + <el-card + v-loading="loading" + :gutter="12" + class="w-1/3" + element-loading-text="生成文件目录中..." + shadow="hover" + > + <el-scrollbar height="calc(100vh - 88px - 40px)"> + <el-tree + ref="treeRef" + :data="preview.fileTree" + :expand-on-click-node="false" + default-expand-all + highlight-current + node-key="id" + @node-click="handleNodeClick" + /> + </el-scrollbar> + </el-card> + <!-- 代码 --> + <el-card + v-loading="loading" + :gutter="12" + class="ml-3 w-2/3" + element-loading-text="加载代码中..." + shadow="hover" + > + <el-tabs v-model="preview.activeName"> + <el-tab-pane + v-for="item in previewCodegen" + :key="item.filePath" + :label="item.filePath.substring(item.filePath.lastIndexOf('/') + 1)" + :name="item.filePath" + > + <el-button class="float-right" text type="primary" @click="copy(item.code)"> + {{ t('common.copy') }} + </el-button> + <el-scrollbar height="600px"> + <pre><code v-dompurify-html="highlightedCode(item)" class="hljs"></code></pre> + </el-scrollbar> + </el-tab-pane> + </el-tabs> + </el-card> + </div> + </Dialog> +</template> +<script lang="ts" setup> +import { useClipboard } from '@vueuse/core' +import { handleTree2 } from '@/utils/tree' +import * as CodegenApi from '@/api/infra/codegen' + +import hljs from 'highlight.js' // 导入代码高亮文件 +import 'highlight.js/styles/github.css' // 导入代码高亮样式 +import java from 'highlight.js/lib/languages/java' +import xml from 'highlight.js/lib/languages/java' +import javascript from 'highlight.js/lib/languages/javascript' +import sql from 'highlight.js/lib/languages/sql' +import typescript from 'highlight.js/lib/languages/typescript' + +defineOptions({ name: 'InfraCodegenPreviewCode' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const loading = ref(false) // 加载中的状态 +const preview = reactive({ + fileTree: [], // 文件树 + activeName: '' // 激活的文件名 +}) +const previewCodegen = ref<CodegenApi.CodegenPreviewVO[]>() + +/** 点击文件 */ +const handleNodeClick = async (data, node) => { + if (node && !node.isLeaf) { + return false + } + preview.activeName = data.id +} + +/** 生成 files 目录 **/ +interface filesType { + id: string + label: string + parentId: string +} + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + try { + loading.value = true + // 生成代码 + const data = await CodegenApi.previewCodegen(id) + previewCodegen.value = data + // 处理文件 + let file = handleFiles(data) + preview.fileTree = handleTree2(file, 'id', 'parentId', 'children', '/') + // 点击首个文件 + preview.activeName = data[0].filePath + } finally { + loading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 处理文件 */ +const handleFiles = (datas: CodegenApi.CodegenPreviewVO[]) => { + let exists = {} // key:file 的 id;value:true + let files: filesType[] = [] + // 遍历每个元素 + for (const data of datas) { + let paths = data.filePath.split('/') + let fullPath = '' // 从头开始的路径,用于生成 id + // 特殊处理 java 文件 + if (paths[paths.length - 1].indexOf('.java') >= 0) { + let newPaths: string[] = [] + for (let i = 0; i < paths.length; i++) { + let path = paths[i] + if (path !== 'java') { + newPaths.push(path) + continue + } + newPaths.push(path) + // 特殊处理中间的 package,进行合并 + let tmp = '' + while (i < paths.length) { + path = paths[i + 1] + if ( + path === 'controller' || + path === 'convert' || + path === 'dal' || + path === 'enums' || + path === 'service' || + path === 'vo' || // 下面三个,主要是兜底。可能考虑到有人改了包结构 + path === 'mysql' || + path === 'dataobject' + ) { + break + } + tmp = tmp ? tmp + '.' + path : path + i++ + } + if (tmp) { + newPaths.push(tmp) + } + } + paths = newPaths + } + // 遍历每个 path, 拼接成树 + for (let i = 0; i < paths.length; i++) { + // 已经添加到 files 中,则跳过 + let oldFullPath = fullPath + // 下面的 replaceAll 的原因,是因为上面包处理了,导致和 tabs 不匹配,所以 replaceAll 下 + fullPath = fullPath.length === 0 ? paths[i] : fullPath.replaceAll('.', '/') + '/' + paths[i] + if (exists[fullPath]) { + continue + } + // 添加到 files 中 + exists[fullPath] = true + files.push({ + id: fullPath, + label: paths[i], + parentId: oldFullPath || '/' // "/" 为根节点 + }) + } + } + return files +} + +/** 复制 **/ +const copy = async (text: string) => { + const { copy, copied, isSupported } = useClipboard({ source: text }) + if (!isSupported) { + message.error(t('common.copyError')) + return + } + await copy() + if (unref(copied)) { + message.success(t('common.copySuccess')) + } +} + +/** + * 代码高亮 + */ +const highlightedCode = (item) => { + const language = item.filePath.substring(item.filePath.lastIndexOf('.') + 1) + const result = hljs.highlight(language, item.code || '', true) + return result.value || ' ' +} + +/** 初始化 **/ +onMounted(async () => { + // 注册代码高亮的各种语言 + hljs.registerLanguage('java', java) + hljs.registerLanguage('xml', xml) + hljs.registerLanguage('html', xml) + hljs.registerLanguage('vue', xml) + hljs.registerLanguage('javascript', javascript) + hljs.registerLanguage('sql', sql) + hljs.registerLanguage('typescript', typescript) +}) +</script> +<style lang="scss"> +.app-infra-codegen-preview-container { + .el-scrollbar .el-scrollbar__wrap .el-scrollbar__view { + display: inline-block; + white-space: nowrap; + } +} +</style> diff --git a/src/views/infra/codegen/components/BasicInfoForm.vue b/src/views/infra/codegen/components/BasicInfoForm.vue new file mode 100644 index 0000000..1859300 --- /dev/null +++ b/src/views/infra/codegen/components/BasicInfoForm.vue @@ -0,0 +1,87 @@ +<template> + <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px"> + <el-row> + <el-col :span="12"> + <el-form-item label="表名称" prop="tableName"> + <el-input v-model="formData.tableName" placeholder="请输入仓库名称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="表描述" prop="tableComment"> + <el-input v-model="formData.tableComment" placeholder="请输入" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item prop="className"> + <template #label> + <span> + 实体类名称 + <el-tooltip + content="默认去除表名的前缀。如果存在重复,则需要手动添加前缀,避免 MyBatis 报 Alias 重复的问题。" + placement="top" + > + <Icon class="" icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-input v-model="formData.className" placeholder="请输入" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="作者" prop="author"> + <el-input v-model="formData.author" placeholder="请输入" /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" :rows="3" type="textarea" /> + </el-form-item> + </el-col> + </el-row> + </el-form> +</template> +<script lang="ts" setup> +import * as CodegenApi from '@/api/infra/codegen' +import { PropType } from 'vue' + +defineOptions({ name: 'InfraCodegenBasicInfoForm' }) + +const props = defineProps({ + table: { + type: Object as PropType<Nullable<CodegenApi.CodegenTableVO>>, + default: () => null + } +}) + +const formRef = ref() +const formData = ref({ + tableName: '', + tableComment: '', + className: '', + author: '', + remark: '' +}) +const rules = reactive({ + tableName: [required], + tableComment: [required], + className: [required], + author: [required] +}) + +/** 监听 table 属性,复制给 formData 属性 */ +watch( + () => props.table, + (table) => { + if (!table) return + formData.value = table + }, + { + deep: true, + immediate: true + } +) + +defineExpose({ + validate: async () => unref(formRef)?.validate() +}) +</script> diff --git a/src/views/infra/codegen/components/ColumInfoForm.vue b/src/views/infra/codegen/components/ColumInfoForm.vue new file mode 100644 index 0000000..737c2e2 --- /dev/null +++ b/src/views/infra/codegen/components/ColumInfoForm.vue @@ -0,0 +1,153 @@ +<template> + <el-table ref="dragTable" :data="formData" :max-height="tableHeight" row-key="columnId"> + <el-table-column + :show-overflow-tooltip="true" + label="字段列名" + min-width="10%" + prop="columnName" + /> + <el-table-column label="字段描述" min-width="10%"> + <template #default="scope"> + <el-input v-model="scope.row.columnComment" /> + </template> + </el-table-column> + <el-table-column + :show-overflow-tooltip="true" + label="物理类型" + min-width="10%" + prop="dataType" + /> + <el-table-column label="Java类型" min-width="11%"> + <template #default="scope"> + <el-select v-model="scope.row.javaType"> + <el-option label="Long" value="Long" /> + <el-option label="String" value="String" /> + <el-option label="Integer" value="Integer" /> + <el-option label="Double" value="Double" /> + <el-option label="BigDecimal" value="BigDecimal" /> + <el-option label="LocalDateTime" value="LocalDateTime" /> + <el-option label="Boolean" value="Boolean" /> + </el-select> + </template> + </el-table-column> + <el-table-column label="java属性" min-width="10%"> + <template #default="scope"> + <el-input v-model="scope.row.javaField" /> + </template> + </el-table-column> + <el-table-column label="插入" min-width="4%"> + <template #default="scope"> + <el-checkbox v-model="scope.row.createOperation" false-label="false" true-label="true" /> + </template> + </el-table-column> + <el-table-column label="编辑" min-width="4%"> + <template #default="scope"> + <el-checkbox v-model="scope.row.updateOperation" false-label="false" true-label="true" /> + </template> + </el-table-column> + <el-table-column label="列表" min-width="4%"> + <template #default="scope"> + <el-checkbox + v-model="scope.row.listOperationResult" + false-label="false" + true-label="true" + /> + </template> + </el-table-column> + <el-table-column label="查询" min-width="4%"> + <template #default="scope"> + <el-checkbox v-model="scope.row.listOperation" false-label="false" true-label="true" /> + </template> + </el-table-column> + <el-table-column label="查询方式" min-width="10%"> + <template #default="scope"> + <el-select v-model="scope.row.listOperationCondition"> + <el-option label="=" value="=" /> + <el-option label="!=" value="!=" /> + <el-option label=">" value=">" /> + <el-option label=">=" value=">=" /> + <el-option label="<" value="<>" /> + <el-option label="<=" value="<=" /> + <el-option label="LIKE" value="LIKE" /> + <el-option label="BETWEEN" value="BETWEEN" /> + </el-select> + </template> + </el-table-column> + <el-table-column label="允许空" min-width="5%"> + <template #default="scope"> + <el-checkbox v-model="scope.row.nullable" false-label="false" true-label="true" /> + </template> + </el-table-column> + <el-table-column label="显示类型" min-width="12%"> + <template #default="scope"> + <el-select v-model="scope.row.htmlType"> + <el-option label="文本框" value="input" /> + <el-option label="文本域" value="textarea" /> + <el-option label="下拉框" value="select" /> + <el-option label="单选框" value="radio" /> + <el-option label="复选框" value="checkbox" /> + <el-option label="日期控件" value="datetime" /> + <el-option label="图片上传" value="imageUpload" /> + <el-option label="文件上传" value="fileUpload" /> + <el-option label="富文本控件" value="editor" /> + </el-select> + </template> + </el-table-column> + <el-table-column label="字典类型" min-width="12%"> + <template #default="scope"> + <el-select v-model="scope.row.dictType" clearable filterable placeholder="请选择"> + <el-option + v-for="dict in dictOptions" + :key="dict.id" + :label="dict.name" + :value="dict.type" + /> + </el-select> + </template> + </el-table-column> + <el-table-column label="示例" min-width="10%"> + <template #default="scope"> + <el-input v-model="scope.row.example" /> + </template> + </el-table-column> + </el-table> +</template> +<script lang="ts" setup> +import { PropType } from 'vue' +import * as CodegenApi from '@/api/infra/codegen' +import * as DictDataApi from '@/api/system/dict/dict.type' + +defineOptions({ name: 'InfraCodegenColumInfoForm' }) + +const props = defineProps({ + columns: { + type: Array as unknown as PropType<CodegenApi.CodegenColumnVO[]>, + default: () => null + } +}) + +const formData = ref<CodegenApi.CodegenColumnVO[]>([]) +const tableHeight = document.documentElement.scrollHeight - 350 + 'px' + +/** 查询字典下拉列表 */ +const dictOptions = ref<DictDataApi.DictTypeVO[]>() +const getDictOptions = async () => { + dictOptions.value = await DictDataApi.getSimpleDictTypeList() +} + +watch( + () => props.columns, + (columns) => { + if (!columns) return + formData.value = columns + }, + { + deep: true, + immediate: true + } +) + +onMounted(async () => { + await getDictOptions() +}) +</script> diff --git a/src/views/infra/codegen/components/GenerateInfoForm.vue b/src/views/infra/codegen/components/GenerateInfoForm.vue new file mode 100644 index 0000000..d2a01cc --- /dev/null +++ b/src/views/infra/codegen/components/GenerateInfoForm.vue @@ -0,0 +1,385 @@ +<template> + <el-form ref="formRef" :model="formData" :rules="rules" label-width="150px"> + <el-row> + <el-col :span="12"> + <el-form-item label="生成模板" prop="templateType"> + <el-select v-model="formData.templateType"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_CODEGEN_TEMPLATE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="前端类型" prop="frontType"> + <el-select v-model="formData.frontType"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_CODEGEN_FRONT_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + + <el-col :span="12"> + <el-form-item label="生成场景" prop="scene"> + <el-select v-model="formData.scene"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_CODEGEN_SCENE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item> + <template #label> + <span> + 上级菜单 + <el-tooltip content="分配到指定菜单下,例如 系统管理" placement="top"> + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-tree-select + v-model="formData.parentMenuId" + :data="menus" + :props="menuTreeProps" + check-strictly + node-key="id" + placeholder="请选择系统菜单" + /> + </el-form-item> + </el-col> + + <!-- <el-col :span="12">--> + <!-- <el-form-item prop="packageName">--> + <!-- <span slot="label">--> + <!-- 生成包路径--> + <!-- <el-tooltip content="生成在哪个java包下,例如 com.ruoyi.system" placement="top">--> + <!-- <i class="el-icon-question"></i>--> + <!-- </el-tooltip>--> + <!-- </span>--> + <!-- <el-input v-model="formData.packageName" />--> + <!-- </el-form-item>--> + <!-- </el-col>--> + + <el-col :span="12"> + <el-form-item prop="moduleName"> + <template #label> + <span> + 模块名 + <el-tooltip + content="模块名,即一级目录,例如 system、infra、tool 等等" + placement="top" + > + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-input v-model="formData.moduleName" /> + </el-form-item> + </el-col> + + <el-col :span="12"> + <el-form-item prop="businessName"> + <template #label> + <span> + 业务名 + <el-tooltip + content="业务名,即二级目录,例如 user、permission、dict 等等" + placement="top" + > + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-input v-model="formData.businessName" /> + </el-form-item> + </el-col> + + <!-- <el-col :span="12">--> + <!-- <el-form-item prop="businessPackage">--> + <!-- <span slot="label">--> + <!-- 业务包--> + <!-- <el-tooltip content="业务包,自定义二级目录。例如说,我们希望将 dictType 和 dictData 归类成 dict 业务" placement="top">--> + <!-- <i class="el-icon-question"></i>--> + <!-- </el-tooltip>--> + <!-- </span>--> + <!-- <el-input v-model="formData.businessPackage" />--> + <!-- </el-form-item>--> + <!-- </el-col>--> + + <el-col :span="12"> + <el-form-item prop="className"> + <template #label> + <span> + 类名称 + <el-tooltip + content="类名称(首字母大写),例如SysUser、SysMenu、SysDictData 等等" + placement="top" + > + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-input v-model="formData.className" /> + </el-form-item> + </el-col> + + <el-col :span="12"> + <el-form-item prop="classComment"> + <template #label> + <span> + 类描述 + <el-tooltip content="用作类描述,例如 用户" placement="top"> + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-input v-model="formData.classComment" /> + </el-form-item> + </el-col> + + <el-col v-if="formData.genType === '1'" :span="24"> + <el-form-item prop="genPath"> + <template #label> + <span> + 自定义路径 + <el-tooltip + content="填写磁盘绝对路径,若不填写,则生成到当前Web项目下" + placement="top" + > + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-input v-model="formData.genPath"> + <template #append> + <el-dropdown> + <el-button type="primary"> + 最近路径快速选择 + <i class="el-icon-arrow-down el-icon--right"></i> + </el-button> + <template #dropdown> + <el-dropdown-menu> + <el-dropdown-item @click="formData.genPath = '/'"> + 恢复默认的生成基础路径 + </el-dropdown-item> + </el-dropdown-menu> + </template> + </el-dropdown> + </template> + </el-input> + </el-form-item> + </el-col> + </el-row> + + <!-- 树表信息 --> + <el-row v-if="formData.templateType == 2"> + <el-col :span="24"> + <h4 class="form-header">树表信息</h4> + </el-col> + <el-col :span="12"> + <el-form-item prop="treeParentColumnId"> + <template #label> + <span> + 父编号字段 + <el-tooltip content="树显示的父编码字段名, 如:parent_Id" placement="top"> + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-select v-model="formData.treeParentColumnId" placeholder="请选择"> + <el-option + v-for="(column, index) in props.columns" + :key="index" + :label="column.columnName + ':' + column.columnComment" + :value="column.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item prop="treeNameColumnId"> + <template #label> + <span> + 树名称字段 + <el-tooltip content="树节点的显示名称字段名, 如:dept_name" placement="top"> + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-select v-model="formData.treeNameColumnId" placeholder="请选择"> + <el-option + v-for="(column, index) in props.columns" + :key="index" + :label="column.columnName + ':' + column.columnComment" + :value="column.id" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + + <!-- 主表信息 --> + <el-row v-if="formData.templateType == 15"> + <el-col :span="24"> + <h4 class="form-header">主表信息</h4> + </el-col> + <el-col :span="12"> + <el-form-item prop="masterTableId"> + <template #label> + <span> + 关联的主表 + <el-tooltip content="关联主表(父表)的表名, 如:system_user" placement="top"> + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-select v-model="formData.masterTableId" placeholder="请选择"> + <el-option + v-for="(table0, index) in tables" + :key="index" + :label="table0.tableName + ':' + table0.tableComment" + :value="table0.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item prop="subJoinColumnId"> + <template #label> + <span> + 子表关联的字段 + <el-tooltip content="子表关联的字段, 如:user_id" placement="top"> + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-select v-model="formData.subJoinColumnId" placeholder="请选择"> + <el-option + v-for="(column, index) in props.columns" + :key="index" + :label="column.columnName + ':' + column.columnComment" + :value="column.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item prop="subJoinMany"> + <template #label> + <span> + 关联关系 + <el-tooltip content="主表与子表的关联关系" placement="top"> + <Icon icon="ep:question-filled" /> + </el-tooltip> + </span> + </template> + <el-radio-group v-model="formData.subJoinMany" placeholder="请选择"> + <el-radio :label="true">一对多</el-radio> + <el-radio :label="false">一对一</el-radio> + </el-radio-group> + </el-form-item> + </el-col> + </el-row> + </el-form> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { handleTree } from '@/utils/tree' +import * as CodegenApi from '@/api/infra/codegen' +import * as MenuApi from '@/api/system/menu' +import { PropType } from 'vue' + +defineOptions({ name: 'InfraCodegenGenerateInfoForm' }) + +const message = useMessage() // 消息弹窗 +const props = defineProps({ + table: { + type: Object as PropType<Nullable<CodegenApi.CodegenTableVO>>, + default: () => null + }, + columns: { + type: Array as unknown as PropType<CodegenApi.CodegenColumnVO[]>, + default: () => null + } +}) + +const formRef = ref() +const formData = ref({ + templateType: null, + frontType: null, + scene: null, + moduleName: '', + businessName: '', + className: '', + classComment: '', + parentMenuId: null, + genPath: '', + genType: '', + masterTableId: undefined, + subJoinColumnId: undefined, + subJoinMany: undefined, + treeParentColumnId: undefined, + treeNameColumnId: undefined +}) + +const rules = reactive({ + templateType: [required], + frontType: [required], + scene: [required], + moduleName: [required], + businessName: [required], + businessPackage: [required], + className: [required], + classComment: [required], + masterTableId: [required], + subJoinColumnId: [required], + subJoinMany: [required], + treeParentColumnId: [required], + treeNameColumnId: [required] +}) + +const tables = ref([]) // 表定义列表 +const menus = ref<any[]>([]) +const menuTreeProps = { + label: 'name' +} + +watch( + () => props.table, + async (table) => { + if (!table) return + formData.value = table as any + // 加载表列表 + if (table.dataSourceConfigId >= 0) { + tables.value = await CodegenApi.getCodegenTableList(formData.value.dataSourceConfigId) + } + }, + { + deep: true, + immediate: true + } +) + +onMounted(async () => { + try { + // 加载菜单 + const resp = await MenuApi.getSimpleMenusList() + menus.value = handleTree(resp) + } catch {} +}) + +defineExpose({ + validate: async () => unref(formRef)?.validate() +}) +</script> diff --git a/src/views/infra/codegen/components/index.ts b/src/views/infra/codegen/components/index.ts new file mode 100644 index 0000000..1634a76 --- /dev/null +++ b/src/views/infra/codegen/components/index.ts @@ -0,0 +1,4 @@ +import BasicInfoForm from './BasicInfoForm.vue' +import ColumInfoForm from './ColumInfoForm.vue' +import GenerateInfoForm from './GenerateInfoForm.vue' +export { BasicInfoForm, ColumInfoForm, GenerateInfoForm } diff --git a/src/views/infra/codegen/index.vue b/src/views/infra/codegen/index.vue new file mode 100644 index 0000000..69c3d12 --- /dev/null +++ b/src/views/infra/codegen/index.vue @@ -0,0 +1,258 @@ +<template> + <doc-alert title="代码生成(单表)" url="https://doc.iocoder.cn/new-feature/" /> + <doc-alert title="代码生成(树表)" url="https://doc.iocoder.cn/new-feature/tree/" /> + <doc-alert title="代码生成(主子表)" url="https://doc.iocoder.cn/new-feature/master-sub/" /> + <doc-alert title="单元测试" url="https://doc.iocoder.cn/unit-test/" /> + + <!-- 搜索 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="表名称" prop="tableName"> + <el-input + v-model="queryParams.tableName" + class="!w-240px" + clearable + placeholder="请输入表名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="表描述" prop="tableComment"> + <el-input + v-model="queryParams.tableComment" + class="!w-240px" + clearable + placeholder="请输入表描述" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button v-hasPermi="['infra:codegen:create']" type="primary" @click="openImportTable()"> + <Icon class="mr-5px" icon="ep:zoom-in" /> + 导入 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="数据源"> + <template #default="scope"> + {{ + dataSourceConfigList.find((config) => config.id === scope.row.dataSourceConfigId)?.name + }} + </template> + </el-table-column> + <el-table-column align="center" label="表名称" prop="tableName" width="200" /> + <el-table-column + :show-overflow-tooltip="true" + align="center" + label="表描述" + prop="tableComment" + width="200" + /> + <el-table-column align="center" label="实体" prop="className" width="200" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" fixed="right" label="操作" width="300px"> + <template #default="scope"> + <el-button + v-hasPermi="['infra:codegen:preview']" + link + type="primary" + @click="handlePreview(scope.row)" + > + 预览 + </el-button> + <el-button + v-hasPermi="['infra:codegen:update']" + link + type="primary" + @click="handleUpdate(scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['infra:codegen:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + <el-button + v-hasPermi="['infra:codegen:update']" + link + type="primary" + @click="handleSyncDB(scope.row)" + > + 同步 + </el-button> + <el-button + v-hasPermi="['infra:codegen:download']" + link + type="primary" + @click="handleGenTable(scope.row)" + > + 生成代码 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 弹窗:导入表 --> + <ImportTable ref="importRef" @success="getList" /> + <!-- 弹窗:预览代码 --> + <PreviewCode ref="previewRef" /> +</template> +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as CodegenApi from '@/api/infra/codegen' +import * as DataSourceConfigApi from '@/api/infra/dataSourceConfig' +import ImportTable from './ImportTable.vue' +import PreviewCode from './PreviewCode.vue' + +defineOptions({ name: 'InfraCodegen' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const { push } = useRouter() // 路由跳转 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + tableName: undefined, + tableComment: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const dataSourceConfigList = ref<DataSourceConfigApi.DataSourceConfigVO[]>([]) // 数据源列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CodegenApi.getCodegenTablePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 导入操作 */ +const importRef = ref() +const openImportTable = () => { + importRef.value.open() +} + +/** 编辑操作 */ +const handleUpdate = (id: number) => { + push('/codegen/edit?id=' + id) +} + +/** 预览操作 */ +const previewRef = ref() +const handlePreview = (row: CodegenApi.CodegenTableVO) => { + previewRef.value.open(row.id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await CodegenApi.deleteCodegenTable(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 同步操作 */ +const handleSyncDB = async (row: CodegenApi.CodegenTableVO) => { + // 基于 DB 同步 + const tableName = row.tableName + try { + await message.confirm('确认要强制同步' + tableName + '表结构吗?', t('common.reminder')) + await CodegenApi.syncCodegenFromDB(row.id) + message.success('同步成功') + } catch {} +} + +/** 生成代码操作 */ +const handleGenTable = async (row: CodegenApi.CodegenTableVO) => { + const res = await CodegenApi.downloadCodegen(row.id) + download.zip(res, 'codegen-' + row.className + '.zip') +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载数据源列表 + dataSourceConfigList.value = await DataSourceConfigApi.getDataSourceConfigList() +}) +</script> diff --git a/src/views/infra/config/ConfigForm.vue b/src/views/infra/config/ConfigForm.vue new file mode 100644 index 0000000..f61face --- /dev/null +++ b/src/views/infra/config/ConfigForm.vue @@ -0,0 +1,131 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="参数分类" prop="category"> + <el-input v-model="formData.category" placeholder="请输入参数分类" /> + </el-form-item> + <el-form-item label="参数名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入参数名称" /> + </el-form-item> + <el-form-item label="参数键名" prop="key"> + <el-input v-model="formData.key" placeholder="请输入参数键名" /> + </el-form-item> + <el-form-item label="参数键值" prop="value"> + <el-input v-model="formData.value" placeholder="请输入参数键值" /> + </el-form-item> + <el-form-item label="是否可见" prop="visible"> + <el-radio-group v-model="formData.visible"> + <el-radio + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value as string" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getBoolDictOptions } from '@/utils/dict' +import * as ConfigApi from '@/api/infra/config' + +defineOptions({ name: 'InfraConfigForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + category: '', + name: '', + key: '', + value: '', + visible: true, + remark: '' +}) +const formRules = reactive({ + category: [{ required: true, message: '参数分类不能为空', trigger: 'blur' }], + name: [{ required: true, message: '参数名称不能为空', trigger: 'blur' }], + key: [{ required: true, message: '参数键名不能为空', trigger: 'blur' }], + value: [{ required: true, message: '参数键值不能为空', trigger: 'blur' }], + visible: [{ required: true, message: '是否可见不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ConfigApi.getConfig(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as ConfigApi.ConfigVO + if (formType.value === 'create') { + await ConfigApi.createConfig(data) + message.success(t('common.createSuccess')) + } else { + await ConfigApi.updateConfig(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + category: '', + name: '', + key: '', + value: '', + visible: true, + remark: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/infra/config/index.vue b/src/views/infra/config/index.vue new file mode 100644 index 0000000..c7838c2 --- /dev/null +++ b/src/views/infra/config/index.vue @@ -0,0 +1,228 @@ +<template> + <doc-alert title="配置中心" url="https://doc.iocoder.cn/config-center/" /> + + <!-- 搜索 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="参数名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入参数名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="参数键名" prop="key"> + <el-input + v-model="queryParams.key" + placeholder="请输入参数键名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="系统内置" prop="type"> + <el-select + v-model="queryParams.type" + placeholder="请选择系统内置" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_CONFIG_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['infra:config:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:config:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="参数主键" align="center" prop="id" /> + <el-table-column label="参数分类" align="center" prop="category" /> + <el-table-column label="参数名称" align="center" prop="name" :show-overflow-tooltip="true" /> + <el-table-column label="参数键名" align="center" prop="key" :show-overflow-tooltip="true" /> + <el-table-column label="参数键值" align="center" prop="value" /> + <el-table-column label="是否可见" align="center" prop="visible"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.visible" /> + </template> + </el-table-column> + <el-table-column label="系统内置" align="center" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_CONFIG_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['infra:config:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['infra:config:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ConfigForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as ConfigApi from '@/api/infra/config' +import ConfigForm from './ConfigForm.vue' + +defineOptions({ name: 'InfraConfig' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + key: undefined, + type: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ConfigApi.getConfigPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ConfigApi.deleteConfig(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ConfigApi.exportConfig(queryParams) + download.excel(data, '参数配置.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/dataSourceConfig/DataSourceConfigForm.vue b/src/views/infra/dataSourceConfig/DataSourceConfigForm.vue new file mode 100644 index 0000000..e2a4eaa --- /dev/null +++ b/src/views/infra/dataSourceConfig/DataSourceConfigForm.vue @@ -0,0 +1,111 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item label="数据源名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入参数名称" /> + </el-form-item> + <el-form-item label="数据源连接" prop="url"> + <el-input v-model="formData.url" placeholder="请输入数据源连接" /> + </el-form-item> + <el-form-item label="用户名" prop="username"> + <el-input v-model="formData.username" placeholder="请输入用户名" /> + </el-form-item> + <el-form-item label="密码" prop="password"> + <el-input v-model="formData.password" placeholder="请输入密码" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as DataSourceConfigApi from '@/api/infra/dataSourceConfig' + +defineOptions({ name: 'InfraDataSourceConfigForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref<DataSourceConfigApi.DataSourceConfigVO>({ + id: undefined, + name: '', + url: '', + username: '', + password: '' +}) +const formRules = reactive({ + name: [{ required: true, message: '数据源名称不能为空', trigger: 'blur' }], + url: [{ required: true, message: '数据源连接不能为空', trigger: 'blur' }], + username: [{ required: true, message: '用户名不能为空', trigger: 'blur' }], + password: [{ required: true, message: '密码不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await DataSourceConfigApi.getDataSourceConfig(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as DataSourceConfigApi.DataSourceConfigVO + if (formType.value === 'create') { + await DataSourceConfigApi.createDataSourceConfig(data) + message.success(t('common.createSuccess')) + } else { + await DataSourceConfigApi.updateDataSourceConfig(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + url: '', + username: '', + password: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/infra/dataSourceConfig/index.vue b/src/views/infra/dataSourceConfig/index.vue new file mode 100644 index 0000000..92bd301 --- /dev/null +++ b/src/views/infra/dataSourceConfig/index.vue @@ -0,0 +1,106 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form class="-mb-15px" :inline="true"> + <el-form-item> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['infra:data-source-config:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="主键编号" align="center" prop="id" /> + <el-table-column label="数据源名称" align="center" prop="name" /> + <el-table-column label="数据源连接" align="center" prop="url" :show-overflow-tooltip="true" /> + <el-table-column label="用户名" align="center" prop="username" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['infra:data-source-config:update']" + :disabled="scope.row.id === 0" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['infra:data-source-config:delete']" + :disabled="scope.row.id === 0" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <DataSourceConfigForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as DataSourceConfigApi from '@/api/infra/dataSourceConfig' +import DataSourceConfigForm from './DataSourceConfigForm.vue' + +defineOptions({ name: 'InfraDataSourceConfig' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref([]) // 列表的数据 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + list.value = await DataSourceConfigApi.getDataSourceConfigList() + } finally { + loading.value = false + } +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DataSourceConfigApi.deleteDataSourceConfig(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/demo/demo01/Demo01ContactForm.vue b/src/views/infra/demo/demo01/Demo01ContactForm.vue new file mode 100644 index 0000000..5d28112 --- /dev/null +++ b/src/views/infra/demo/demo01/Demo01ContactForm.vue @@ -0,0 +1,126 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="性别" prop="sex"> + <el-radio-group v-model="formData.sex"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="出生年" prop="birthday"> + <el-date-picker + v-model="formData.birthday" + type="date" + value-format="x" + placeholder="选择出生年" + /> + </el-form-item> + <el-form-item label="简介" prop="description"> + <Editor v-model="formData.description" height="150px" /> + </el-form-item> + <el-form-item label="头像" prop="avatar"> + <UploadImg v-model="formData.avatar" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import * as Demo01ContactApi from '@/api/infra/demo/demo01' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + sex: undefined, + birthday: undefined, + description: undefined, + avatar: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }], + birthday: [{ required: true, message: '出生年不能为空', trigger: 'blur' }], + description: [{ required: true, message: '简介不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await Demo01ContactApi.getDemo01Contact(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as Demo01ContactApi.Demo01ContactVO + if (formType.value === 'create') { + await Demo01ContactApi.createDemo01Contact(data) + message.success(t('common.createSuccess')) + } else { + await Demo01ContactApi.updateDemo01Contact(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + sex: undefined, + birthday: undefined, + description: undefined, + avatar: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/infra/demo/demo01/index.vue b/src/views/infra/demo/demo01/index.vue new file mode 100644 index 0000000..e111ab6 --- /dev/null +++ b/src/views/infra/demo/demo01/index.vue @@ -0,0 +1,214 @@ +<template> + <doc-alert title="代码生成(单表)" url="https://doc.iocoder.cn/new-feature/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名字" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="性别" prop="sex"> + <el-select v-model="queryParams.sex" placeholder="请选择性别" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['infra:demo01-contact:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:demo01-contact:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column label="性别" align="center" prop="sex"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" /> + </template> + </el-table-column> + <el-table-column + label="出生年" + align="center" + prop="birthday" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="简介" align="center" prop="description" /> + <el-table-column label="头像" align="center" prop="avatar" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['infra:demo01-contact:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['infra:demo01-contact:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <Demo01ContactForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as Demo01ContactApi from '@/api/infra/demo/demo01' +import Demo01ContactForm from './Demo01ContactForm.vue' + +defineOptions({ name: 'Demo01Contact' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + sex: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await Demo01ContactApi.getDemo01ContactPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await Demo01ContactApi.deleteDemo01Contact(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await Demo01ContactApi.exportDemo01Contact(queryParams) + download.excel(data, '示例联系人.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/demo/demo02/Demo02CategoryForm.vue b/src/views/infra/demo/demo02/Demo02CategoryForm.vue new file mode 100644 index 0000000..f4c5f8e --- /dev/null +++ b/src/views/infra/demo/demo02/Demo02CategoryForm.vue @@ -0,0 +1,114 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="父级编号" prop="parentId"> + <el-tree-select + v-model="formData.parentId" + :data="demo02CategoryTree" + :props="defaultProps" + check-strictly + default-expand-all + placeholder="请选择父级编号" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as Demo02CategoryApi from '@/api/infra/demo/demo02' +import { defaultProps, handleTree } from '@/utils/tree' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + parentId: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + parentId: [{ required: true, message: '父级编号不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const demo02CategoryTree = ref() // 树形结构 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await Demo02CategoryApi.getDemo02Category(id) + } finally { + formLoading.value = false + } + } + await getDemo02CategoryTree() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as Demo02CategoryApi.Demo02CategoryVO + if (formType.value === 'create') { + await Demo02CategoryApi.createDemo02Category(data) + message.success(t('common.createSuccess')) + } else { + await Demo02CategoryApi.updateDemo02Category(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + parentId: undefined + } + formRef.value?.resetFields() +} + +/** 获得示例分类树 */ +const getDemo02CategoryTree = async () => { + demo02CategoryTree.value = [] + const data = await Demo02CategoryApi.getDemo02CategoryList() + const root: Tree = { id: 0, name: '顶级示例分类', children: [] } + root.children = handleTree(data, 'id', 'parentId') + demo02CategoryTree.value.push(root) +} +</script> diff --git a/src/views/infra/demo/demo02/index.vue b/src/views/infra/demo/demo02/index.vue new file mode 100644 index 0000000..9faa8c9 --- /dev/null +++ b/src/views/infra/demo/demo02/index.vue @@ -0,0 +1,207 @@ +<template> + <doc-alert title="代码生成(树表)" url="https://doc.iocoder.cn/new-feature/tree/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名字" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['infra:demo02-category:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:demo02-category:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button type="danger" plain @click="toggleExpandAll"> + <Icon icon="ep:sort" class="mr-5px" /> 展开/折叠 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :stripe="true" + :show-overflow-tooltip="true" + row-key="id" + :default-expand-all="isExpandAll" + v-if="refreshTable" + > + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['infra:demo02-category:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['infra:demo02-category:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <Demo02CategoryForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import { handleTree } from '@/utils/tree' +import download from '@/utils/download' +import * as Demo02CategoryApi from '@/api/infra/demo/demo02' +import Demo02CategoryForm from './Demo02CategoryForm.vue' + +defineOptions({ name: 'Demo02Category' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + name: null, + parentId: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await Demo02CategoryApi.getDemo02CategoryList(queryParams) + list.value = handleTree(data, 'id', 'parentId') + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await Demo02CategoryApi.deleteDemo02Category(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await Demo02CategoryApi.exportDemo02Category(queryParams) + download.excel(data, '示例分类.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 展开/折叠操作 */ +const isExpandAll = ref(true) // 是否展开,默认全部展开 +const refreshTable = ref(true) // 重新渲染表格状态 +const toggleExpandAll = async () => { + refreshTable.value = false + isExpandAll.value = !isExpandAll.value + await nextTick() + refreshTable.value = true +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/demo/demo03/erp/Demo03StudentForm.vue b/src/views/infra/demo/demo03/erp/Demo03StudentForm.vue new file mode 100644 index 0000000..758c7e5 --- /dev/null +++ b/src/views/infra/demo/demo03/erp/Demo03StudentForm.vue @@ -0,0 +1,121 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="性别" prop="sex"> + <el-radio-group v-model="formData.sex"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="出生日期" prop="birthday"> + <el-date-picker + v-model="formData.birthday" + type="date" + value-format="x" + placeholder="选择出生日期" + /> + </el-form-item> + <el-form-item label="简介" prop="description"> + <Editor v-model="formData.description" height="150px" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import * as Demo03StudentApi from '@/api/infra/demo/demo03/erp' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + sex: undefined, + birthday: undefined, + description: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }], + birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }], + description: [{ required: true, message: '简介不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await Demo03StudentApi.getDemo03Student(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as Demo03StudentApi.Demo03StudentVO + if (formType.value === 'create') { + await Demo03StudentApi.createDemo03Student(data) + message.success(t('common.createSuccess')) + } else { + await Demo03StudentApi.updateDemo03Student(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + sex: undefined, + birthday: undefined, + description: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/infra/demo/demo03/erp/components/Demo03CourseForm.vue b/src/views/infra/demo/demo03/erp/components/Demo03CourseForm.vue new file mode 100644 index 0000000..9d3888d --- /dev/null +++ b/src/views/infra/demo/demo03/erp/components/Demo03CourseForm.vue @@ -0,0 +1,99 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="分数" prop="score"> + <el-input v-model="formData.score" placeholder="请输入分数" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as Demo03StudentApi from '@/api/infra/demo/demo03/erp' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + studentId: undefined, + name: undefined, + score: undefined +}) +const formRules = reactive({ + studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }], + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + score: [{ required: true, message: '分数不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number, studentId: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + formData.value.studentId = studentId + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await Demo03StudentApi.getDemo03Course(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value + if (formType.value === 'create') { + await Demo03StudentApi.createDemo03Course(data) + message.success(t('common.createSuccess')) + } else { + await Demo03StudentApi.updateDemo03Course(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + studentId: undefined, + name: undefined, + score: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/infra/demo/demo03/erp/components/Demo03CourseList.vue b/src/views/infra/demo/demo03/erp/components/Demo03CourseList.vue new file mode 100644 index 0000000..3dce77e --- /dev/null +++ b/src/views/infra/demo/demo03/erp/components/Demo03CourseList.vue @@ -0,0 +1,130 @@ +<template> + <!-- 列表 --> + <ContentWrap> + <el-button + v-hasPermi="['infra:demo03-student:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="编号" prop="id" /> + <el-table-column align="center" label="名字" prop="name" /> + <el-table-column align="center" label="分数" prop="score" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['infra:demo03-student:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['infra:demo03-student:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <!-- 表单弹窗:添加/修改 --> + <Demo03CourseForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as Demo03StudentApi from '@/api/infra/demo/demo03/erp' +import Demo03CourseForm from './Demo03CourseForm.vue' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const props = defineProps<{ + studentId?: number // 学生编号(主表的关联字段) +}>() +const loading = ref(false) // 列表的加载中 +const list = ref([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + studentId: undefined as unknown +}) + +/** 监听主表的关联字段的变化,加载对应的子表数据 */ +watch( + () => props.studentId, + (val: number) => { + if (!val) { + return + } + queryParams.studentId = val + handleQuery() + }, + { immediate: true, deep: true } +) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await Demo03StudentApi.getDemo03CoursePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + if (!props.studentId) { + message.error('请选择一个学生') + return + } + formRef.value.open(type, id, props.studentId) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await Demo03StudentApi.deleteDemo03Course(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} +</script> diff --git a/src/views/infra/demo/demo03/erp/components/Demo03GradeForm.vue b/src/views/infra/demo/demo03/erp/components/Demo03GradeForm.vue new file mode 100644 index 0000000..5687294 --- /dev/null +++ b/src/views/infra/demo/demo03/erp/components/Demo03GradeForm.vue @@ -0,0 +1,99 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="班主任" prop="teacher"> + <el-input v-model="formData.teacher" placeholder="请输入班主任" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as Demo03StudentApi from '@/api/infra/demo/demo03/erp' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + studentId: undefined, + name: undefined, + teacher: undefined +}) +const formRules = reactive({ + studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }], + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number, studentId: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + formData.value.studentId = studentId + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await Demo03StudentApi.getDemo03Grade(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value + if (formType.value === 'create') { + await Demo03StudentApi.createDemo03Grade(data) + message.success(t('common.createSuccess')) + } else { + await Demo03StudentApi.updateDemo03Grade(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + studentId: undefined, + name: undefined, + teacher: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/infra/demo/demo03/erp/components/Demo03GradeList.vue b/src/views/infra/demo/demo03/erp/components/Demo03GradeList.vue new file mode 100644 index 0000000..635f321 --- /dev/null +++ b/src/views/infra/demo/demo03/erp/components/Demo03GradeList.vue @@ -0,0 +1,130 @@ +<template> + <!-- 列表 --> + <ContentWrap> + <el-button + v-hasPermi="['infra:demo03-student:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="编号" prop="id" /> + <el-table-column align="center" label="名字" prop="name" /> + <el-table-column align="center" label="班主任" prop="teacher" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['infra:demo03-student:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['infra:demo03-student:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <!-- 表单弹窗:添加/修改 --> + <Demo03GradeForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as Demo03StudentApi from '@/api/infra/demo/demo03/erp' +import Demo03GradeForm from './Demo03GradeForm.vue' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const props = defineProps<{ + studentId?: number // 学生编号(主表的关联字段) +}>() +const loading = ref(false) // 列表的加载中 +const list = ref([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + studentId: undefined as unknown +}) + +/** 监听主表的关联字段的变化,加载对应的子表数据 */ +watch( + () => props.studentId, + (val: number) => { + if (!val) { + return + } + queryParams.studentId = val + handleQuery() + }, + { immediate: true, deep: true } +) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await Demo03StudentApi.getDemo03GradePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + if (!props.studentId) { + message.error('请选择一个学生') + return + } + formRef.value.open(type, id, props.studentId) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await Demo03StudentApi.deleteDemo03Grade(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} +</script> diff --git a/src/views/infra/demo/demo03/erp/index.vue b/src/views/infra/demo/demo03/erp/index.vue new file mode 100644 index 0000000..e0f1cf4 --- /dev/null +++ b/src/views/infra/demo/demo03/erp/index.vue @@ -0,0 +1,248 @@ +<template> + <doc-alert title="代码生成(主子表)" url="https://doc.iocoder.cn/new-feature/master-sub/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="名字" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入名字" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="性别" prop="sex"> + <el-select v-model="queryParams.sex" class="!w-240px" clearable placeholder="请选择性别"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['infra:demo03-student:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button + v-hasPermi="['infra:demo03-student:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :show-overflow-tooltip="true" + :stripe="true" + highlight-current-row + @current-change="handleCurrentChange" + > + <el-table-column align="center" label="编号" prop="id" /> + <el-table-column align="center" label="名字" prop="name" /> + <el-table-column align="center" label="性别" prop="sex"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="出生日期" + prop="birthday" + width="180px" + /> + <el-table-column align="center" label="简介" prop="description" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['infra:demo03-student:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['infra:demo03-student:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <Demo03StudentForm ref="formRef" @success="getList" /> + <!-- 子表的列表 --> + <ContentWrap> + <el-tabs model-value="demo03Course"> + <el-tab-pane label="学生课程" name="demo03Course"> + <Demo03CourseList :student-id="currentRow?.id" /> + </el-tab-pane> + <el-tab-pane label="学生班级" name="demo03Grade"> + <Demo03GradeList :student-id="currentRow?.id" /> + </el-tab-pane> + </el-tabs> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as Demo03StudentApi from '@/api/infra/demo/demo03/erp' +import Demo03StudentForm from './Demo03StudentForm.vue' +import Demo03CourseList from './components/Demo03CourseList.vue' +import Demo03GradeList from './components/Demo03GradeList.vue' + +defineOptions({ name: 'Demo03Student' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + sex: null, + description: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await Demo03StudentApi.getDemo03StudentPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await Demo03StudentApi.deleteDemo03Student(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await Demo03StudentApi.exportDemo03Student(queryParams) + download.excel(data, '学生.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 选中行操作 */ +const currentRow = ref({}) // 选中行 +const handleCurrentChange = (row) => { + currentRow.value = row +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/demo/demo03/inner/Demo03StudentForm.vue b/src/views/infra/demo/demo03/inner/Demo03StudentForm.vue new file mode 100644 index 0000000..98c1b7b --- /dev/null +++ b/src/views/infra/demo/demo03/inner/Demo03StudentForm.vue @@ -0,0 +1,153 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="性别" prop="sex"> + <el-radio-group v-model="formData.sex"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="出生日期" prop="birthday"> + <el-date-picker + v-model="formData.birthday" + type="date" + value-format="x" + placeholder="选择出生日期" + /> + </el-form-item> + <el-form-item label="简介" prop="description"> + <Editor v-model="formData.description" height="150px" /> + </el-form-item> + </el-form> + <!-- 子表的表单 --> + <el-tabs v-model="subTabsName"> + <el-tab-pane label="学生课程" name="demo03Course"> + <Demo03CourseForm ref="demo03CourseFormRef" :student-id="formData.id" /> + </el-tab-pane> + <el-tab-pane label="学生班级" name="demo03Grade"> + <Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData.id" /> + </el-tab-pane> + </el-tabs> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner' +import Demo03CourseForm from './components/Demo03CourseForm.vue' +import Demo03GradeForm from './components/Demo03GradeForm.vue' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + sex: undefined, + birthday: undefined, + description: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }], + birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }], + description: [{ required: true, message: '简介不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 子表的表单 */ +const subTabsName = ref('demo03Course') +const demo03CourseFormRef = ref() +const demo03GradeFormRef = ref() + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await Demo03StudentApi.getDemo03Student(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 校验子表单 + try { + await demo03CourseFormRef.value.validate() + } catch (e) { + subTabsName.value = 'demo03Course' + return + } + try { + await demo03GradeFormRef.value.validate() + } catch (e) { + subTabsName.value = 'demo03Grade' + return + } + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as Demo03StudentApi.Demo03StudentVO + // 拼接子表的数据 + data.demo03Courses = demo03CourseFormRef.value.getData() + data.demo03Grade = demo03GradeFormRef.value.getData() + if (formType.value === 'create') { + await Demo03StudentApi.createDemo03Student(data) + message.success(t('common.createSuccess')) + } else { + await Demo03StudentApi.updateDemo03Student(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + sex: undefined, + birthday: undefined, + description: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/infra/demo/demo03/inner/components/Demo03CourseForm.vue b/src/views/infra/demo/demo03/inner/components/Demo03CourseForm.vue new file mode 100644 index 0000000..77da45f --- /dev/null +++ b/src/views/infra/demo/demo03/inner/components/Demo03CourseForm.vue @@ -0,0 +1,100 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + > + <el-table :data="formData" class="-mt-10px"> + <el-table-column label="序号" type="index" width="100" /> + <el-table-column label="名字" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.name`" :rules="formRules.name" class="mb-0px!"> + <el-input v-model="row.name" placeholder="请输入名字" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="分数" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.score`" :rules="formRules.score" class="mb-0px!"> + <el-input v-model="row.score" placeholder="请输入分数" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3"> + <el-button @click="handleAdd" round>+ 添加学生课程</el-button> + </el-row> +</template> +<script setup lang="ts"> +import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner' + +const props = defineProps<{ + studentId: undefined // 学生编号(主表的关联字段) +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }], + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + score: [{ required: true, message: '分数不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 监听主表的关联字段的变化,加载对应的子表数据 */ +watch( + () => props.studentId, + async (val) => { + // 1. 重置表单 + formData.value = [] + // 2. val 非空,则加载数据 + if (!val) { + return + } + try { + formLoading.value = true + formData.value = await Demo03StudentApi.getDemo03CourseListByStudentId(val) + } finally { + formLoading.value = false + } + }, + { immediate: true } +) + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + studentId: undefined, + name: undefined, + score: undefined + } + row.studentId = props.studentId + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index) => { + formData.value.splice(index, 1) +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} + +/** 表单值 */ +const getData = () => { + return formData.value +} + +defineExpose({ validate, getData }) +</script> diff --git a/src/views/infra/demo/demo03/inner/components/Demo03CourseList.vue b/src/views/infra/demo/demo03/inner/components/Demo03CourseList.vue new file mode 100644 index 0000000..965b473 --- /dev/null +++ b/src/views/infra/demo/demo03/inner/components/Demo03CourseList.vue @@ -0,0 +1,51 @@ +<template> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column label="分数" align="center" prop="score" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + </el-table> + </ContentWrap> +</template> +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const props = defineProps<{ + studentId: undefined // 学生编号(主表的关联字段) +}>() +const loading = ref(false) // 列表的加载中 +const list = ref([]) // 列表的数据 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + list.value = await Demo03StudentApi.getDemo03CourseListByStudentId(props.studentId) + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/demo/demo03/inner/components/Demo03GradeForm.vue b/src/views/infra/demo/demo03/inner/components/Demo03GradeForm.vue new file mode 100644 index 0000000..e14bac4 --- /dev/null +++ b/src/views/infra/demo/demo03/inner/components/Demo03GradeForm.vue @@ -0,0 +1,72 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="班主任" prop="teacher"> + <el-input v-model="formData.teacher" placeholder="请输入班主任" /> + </el-form-item> + </el-form> +</template> +<script setup lang="ts"> +import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner' + +const props = defineProps<{ + studentId: undefined // 学生编号(主表的关联字段) +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }], + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 监听主表的关联字段的变化,加载对应的子表数据 */ +watch( + () => props.studentId, + async (val) => { + // 1. 重置表单 + formData.value = { + id: undefined, + studentId: undefined, + name: undefined, + teacher: undefined + } + // 2. val 非空,则加载数据 + if (!val) { + return + } + try { + formLoading.value = true + const data = await Demo03StudentApi.getDemo03GradeByStudentId(val) + if (!data) { + return + } + formData.value = data + } finally { + formLoading.value = false + } + }, + { immediate: true } +) + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} + +/** 表单值 */ +const getData = () => { + return formData.value +} + +defineExpose({ validate, getData }) +</script> diff --git a/src/views/infra/demo/demo03/inner/components/Demo03GradeList.vue b/src/views/infra/demo/demo03/inner/components/Demo03GradeList.vue new file mode 100644 index 0000000..e631384 --- /dev/null +++ b/src/views/infra/demo/demo03/inner/components/Demo03GradeList.vue @@ -0,0 +1,55 @@ +<template> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column label="班主任" align="center" prop="teacher" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + </el-table> + </ContentWrap> +</template> +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const props = defineProps<{ + studentId: undefined // 学生编号(主表的关联字段) +}>() +const loading = ref(false) // 列表的加载中 +const list = ref([]) // 列表的数据 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await Demo03StudentApi.getDemo03GradeByStudentId(props.studentId) + if (!data) { + return + } + list.value.push(data) + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/demo/demo03/inner/index.vue b/src/views/infra/demo/demo03/inner/index.vue new file mode 100644 index 0000000..9b32460 --- /dev/null +++ b/src/views/infra/demo/demo03/inner/index.vue @@ -0,0 +1,229 @@ +<template> + <doc-alert title="代码生成(主子表)" url="https://doc.iocoder.cn/new-feature/master-sub/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名字" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="性别" prop="sex"> + <el-select v-model="queryParams.sex" placeholder="请选择性别" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['infra:demo03-student:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:demo03-student:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <!-- 子表的列表 --> + <el-table-column type="expand"> + <template #default="scope"> + <el-tabs model-value="demo03Course"> + <el-tab-pane label="学生课程" name="demo03Course"> + <Demo03CourseList :student-id="scope.row.id" /> + </el-tab-pane> + <el-tab-pane label="学生班级" name="demo03Grade"> + <Demo03GradeList :student-id="scope.row.id" /> + </el-tab-pane> + </el-tabs> + </template> + </el-table-column> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column label="性别" align="center" prop="sex"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" /> + </template> + </el-table-column> + <el-table-column + label="出生日期" + align="center" + prop="birthday" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="简介" align="center" prop="description" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['infra:demo03-student:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['infra:demo03-student:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <Demo03StudentForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as Demo03StudentApi from '@/api/infra/demo/demo03/inner' +import Demo03StudentForm from './Demo03StudentForm.vue' +import Demo03CourseList from './components/Demo03CourseList.vue' +import Demo03GradeList from './components/Demo03GradeList.vue' + +defineOptions({ name: 'Demo03Student' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + sex: null, + description: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await Demo03StudentApi.getDemo03StudentPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await Demo03StudentApi.deleteDemo03Student(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await Demo03StudentApi.exportDemo03Student(queryParams) + download.excel(data, '学生.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/demo/demo03/normal/Demo03StudentForm.vue b/src/views/infra/demo/demo03/normal/Demo03StudentForm.vue new file mode 100644 index 0000000..4815357 --- /dev/null +++ b/src/views/infra/demo/demo03/normal/Demo03StudentForm.vue @@ -0,0 +1,153 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="性别" prop="sex"> + <el-radio-group v-model="formData.sex"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="出生日期" prop="birthday"> + <el-date-picker + v-model="formData.birthday" + type="date" + value-format="x" + placeholder="选择出生日期" + /> + </el-form-item> + <el-form-item label="简介" prop="description"> + <Editor v-model="formData.description" height="150px" /> + </el-form-item> + </el-form> + <!-- 子表的表单 --> + <el-tabs v-model="subTabsName"> + <el-tab-pane label="学生课程" name="demo03Course"> + <Demo03CourseForm ref="demo03CourseFormRef" :student-id="formData.id" /> + </el-tab-pane> + <el-tab-pane label="学生班级" name="demo03Grade"> + <Demo03GradeForm ref="demo03GradeFormRef" :student-id="formData.id" /> + </el-tab-pane> + </el-tabs> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import * as Demo03StudentApi from '@/api/infra/demo/demo03/normal' +import Demo03CourseForm from './components/Demo03CourseForm.vue' +import Demo03GradeForm from './components/Demo03GradeForm.vue' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + sex: undefined, + birthday: undefined, + description: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + sex: [{ required: true, message: '性别不能为空', trigger: 'blur' }], + birthday: [{ required: true, message: '出生日期不能为空', trigger: 'blur' }], + description: [{ required: true, message: '简介不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 子表的表单 */ +const subTabsName = ref('demo03Course') +const demo03CourseFormRef = ref() +const demo03GradeFormRef = ref() + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await Demo03StudentApi.getDemo03Student(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 校验子表单 + try { + await demo03CourseFormRef.value.validate() + } catch (e) { + subTabsName.value = 'demo03Course' + return + } + try { + await demo03GradeFormRef.value.validate() + } catch (e) { + subTabsName.value = 'demo03Grade' + return + } + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as Demo03StudentApi.Demo03StudentVO + // 拼接子表的数据 + data.demo03Courses = demo03CourseFormRef.value.getData() + data.demo03Grade = demo03GradeFormRef.value.getData() + if (formType.value === 'create') { + await Demo03StudentApi.createDemo03Student(data) + message.success(t('common.createSuccess')) + } else { + await Demo03StudentApi.updateDemo03Student(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + sex: undefined, + birthday: undefined, + description: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/infra/demo/demo03/normal/components/Demo03CourseForm.vue b/src/views/infra/demo/demo03/normal/components/Demo03CourseForm.vue new file mode 100644 index 0000000..f439c3f --- /dev/null +++ b/src/views/infra/demo/demo03/normal/components/Demo03CourseForm.vue @@ -0,0 +1,100 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + v-loading="formLoading" + label-width="0px" + :inline-message="true" + > + <el-table :data="formData" class="-mt-10px"> + <el-table-column label="序号" type="index" width="100" /> + <el-table-column label="名字" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.name`" :rules="formRules.name" class="mb-0px!"> + <el-input v-model="row.name" placeholder="请输入名字" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column label="分数" min-width="150"> + <template #default="{ row, $index }"> + <el-form-item :prop="`${$index}.score`" :rules="formRules.score" class="mb-0px!"> + <el-input v-model="row.score" placeholder="请输入分数" /> + </el-form-item> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="60"> + <template #default="{ $index }"> + <el-button @click="handleDelete($index)" link>—</el-button> + </template> + </el-table-column> + </el-table> + </el-form> + <el-row justify="center" class="mt-3"> + <el-button @click="handleAdd" round>+ 添加学生课程</el-button> + </el-row> +</template> +<script setup lang="ts"> +import * as Demo03StudentApi from '@/api/infra/demo/demo03/normal' + +const props = defineProps<{ + studentId: undefined // 学生编号(主表的关联字段) +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }], + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + score: [{ required: true, message: '分数不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 监听主表的关联字段的变化,加载对应的子表数据 */ +watch( + () => props.studentId, + async (val) => { + // 1. 重置表单 + formData.value = [] + // 2. val 非空,则加载数据 + if (!val) { + return + } + try { + formLoading.value = true + formData.value = await Demo03StudentApi.getDemo03CourseListByStudentId(val) + } finally { + formLoading.value = false + } + }, + { immediate: true } +) + +/** 新增按钮操作 */ +const handleAdd = () => { + const row = { + id: undefined, + studentId: undefined, + name: undefined, + score: undefined + } + row.studentId = props.studentId + formData.value.push(row) +} + +/** 删除按钮操作 */ +const handleDelete = (index) => { + formData.value.splice(index, 1) +} + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} + +/** 表单值 */ +const getData = () => { + return formData.value +} + +defineExpose({ validate, getData }) +</script> diff --git a/src/views/infra/demo/demo03/normal/components/Demo03GradeForm.vue b/src/views/infra/demo/demo03/normal/components/Demo03GradeForm.vue new file mode 100644 index 0000000..c711954 --- /dev/null +++ b/src/views/infra/demo/demo03/normal/components/Demo03GradeForm.vue @@ -0,0 +1,72 @@ +<template> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名字" /> + </el-form-item> + <el-form-item label="班主任" prop="teacher"> + <el-input v-model="formData.teacher" placeholder="请输入班主任" /> + </el-form-item> + </el-form> +</template> +<script setup lang="ts"> +import * as Demo03StudentApi from '@/api/infra/demo/demo03/normal' + +const props = defineProps<{ + studentId: undefined // 学生编号(主表的关联字段) +}>() +const formLoading = ref(false) // 表单的加载中 +const formData = ref([]) +const formRules = reactive({ + studentId: [{ required: true, message: '学生编号不能为空', trigger: 'blur' }], + name: [{ required: true, message: '名字不能为空', trigger: 'blur' }], + teacher: [{ required: true, message: '班主任不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 监听主表的关联字段的变化,加载对应的子表数据 */ +watch( + () => props.studentId, + async (val) => { + // 1. 重置表单 + formData.value = { + id: undefined, + studentId: undefined, + name: undefined, + teacher: undefined + } + // 2. val 非空,则加载数据 + if (!val) { + return + } + try { + formLoading.value = true + const data = await Demo03StudentApi.getDemo03GradeByStudentId(val) + if (!data) { + return + } + formData.value = data + } finally { + formLoading.value = false + } + }, + { immediate: true } +) + +/** 表单校验 */ +const validate = () => { + return formRef.value.validate() +} + +/** 表单值 */ +const getData = () => { + return formData.value +} + +defineExpose({ validate, getData }) +</script> diff --git a/src/views/infra/demo/demo03/normal/index.vue b/src/views/infra/demo/demo03/normal/index.vue new file mode 100644 index 0000000..8a5dc1a --- /dev/null +++ b/src/views/infra/demo/demo03/normal/index.vue @@ -0,0 +1,214 @@ +<template> + <doc-alert title="代码生成(主子表)" url="https://doc.iocoder.cn/new-feature/master-sub/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名字" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="性别" prop="sex"> + <el-select v-model="queryParams.sex" placeholder="请选择性别" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['infra:demo03-student:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:demo03-student:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="名字" align="center" prop="name" /> + <el-table-column label="性别" align="center" prop="sex"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="scope.row.sex" /> + </template> + </el-table-column> + <el-table-column + label="出生日期" + align="center" + prop="birthday" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="简介" align="center" prop="description" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['infra:demo03-student:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['infra:demo03-student:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <Demo03StudentForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as Demo03StudentApi from '@/api/infra/demo/demo03/normal' +import Demo03StudentForm from './Demo03StudentForm.vue' + +defineOptions({ name: 'Demo03Student' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + sex: null, + description: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await Demo03StudentApi.getDemo03StudentPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await Demo03StudentApi.deleteDemo03Student(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await Demo03StudentApi.exportDemo03Student(queryParams) + download.excel(data, '学生.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/druid/index.vue b/src/views/infra/druid/index.vue new file mode 100644 index 0000000..bc047d7 --- /dev/null +++ b/src/views/infra/druid/index.vue @@ -0,0 +1,28 @@ +<template> + <doc-alert title="数据库 MyBatis" url="https://doc.iocoder.cn/mybatis/" /> + <doc-alert title="多数据源(读写分离)" url="https://doc.iocoder.cn/dynamic-datasource/" /> + + <ContentWrap> + <IFrame v-if="!loading" :src="url" /> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ConfigApi from '@/api/infra/config' + +defineOptions({ name: 'InfraDruid' }) + +const loading = ref(true) // 是否加载中 +const url = ref(import.meta.env.VITE_BASE_URL + '/druid/index.html') + +/** 初始化 */ +onMounted(async () => { + try { + const data = await ConfigApi.getConfigKey('url.druid') + if (data && data.length > 0) { + url.value = data + } + } finally { + loading.value = false + } +}) +</script> diff --git a/src/views/infra/file/FileForm.vue b/src/views/infra/file/FileForm.vue new file mode 100644 index 0000000..1de1e25 --- /dev/null +++ b/src/views/infra/file/FileForm.vue @@ -0,0 +1,99 @@ +<template> + <Dialog v-model="dialogVisible" title="上传文件"> + <el-upload + ref="uploadRef" + v-model:file-list="fileList" + :action="uploadUrl" + :auto-upload="false" + :data="data" + :disabled="formLoading" + :limit="1" + :on-change="handleFileChange" + :on-error="submitFormError" + :on-exceed="handleExceed" + :on-success="submitFormSuccess" + :http-request="httpRequest" + accept=".jpg, .png, .gif" + drag + > + <i class="el-icon-upload"></i> + <div class="el-upload__text"> 将文件拖到此处,或 <em>点击上传</em></div> + <template #tip> + <div class="el-upload__tip" style="color: red"> + 提示:仅允许导入 jpg、png、gif 格式文件! + </div> + </template> + </el-upload> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitFileForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { useUpload } from '@/components/UploadFile/src/useUpload' + +defineOptions({ name: 'InfraFileForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const fileList = ref([]) // 文件列表 +const data = ref({ path: '' }) +const uploadRef = ref() + +const { uploadUrl, httpRequest } = useUpload() + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + resetForm() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 处理上传的文件发生变化 */ +const handleFileChange = (file) => { + data.value.path = file.name +} + +/** 提交表单 */ +const submitFileForm = () => { + if (fileList.value.length == 0) { + message.error('请上传文件') + return + } + unref(uploadRef)?.submit() +} + +/** 文件上传成功处理 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitFormSuccess = () => { + // 清理 + dialogVisible.value = false + formLoading.value = false + unref(uploadRef)?.clearFiles() + // 提示成功,并刷新 + message.success(t('common.createSuccess')) + emit('success') +} + +/** 上传错误提示 */ +const submitFormError = (): void => { + message.error('上传失败,请您重新上传!') + formLoading.value = false +} + +/** 重置表单 */ +const resetForm = () => { + // 重置上传状态和文件 + formLoading.value = false + uploadRef.value?.clearFiles() +} + +/** 文件数超出提示 */ +const handleExceed = (): void => { + message.error('最多只能上传一个文件!') +} +</script> diff --git a/src/views/infra/file/index.vue b/src/views/infra/file/index.vue new file mode 100644 index 0000000..2ff3364 --- /dev/null +++ b/src/views/infra/file/index.vue @@ -0,0 +1,189 @@ +<template> + <doc-alert title="上传下载" url="https://doc.iocoder.cn/file/" /> + <!-- 搜索 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="文件路径" prop="path"> + <el-input + v-model="queryParams.path" + placeholder="请输入文件路径" + clearable + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="文件类型" prop="type" width="80"> + <el-input + v-model="queryParams.type" + placeholder="请输入文件类型" + clearable + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button type="primary" plain @click="openForm"> + <Icon icon="ep:upload" class="mr-5px" /> 上传文件 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="文件名" align="center" prop="name" :show-overflow-tooltip="true" /> + <el-table-column label="文件路径" align="center" prop="path" :show-overflow-tooltip="true" /> + <el-table-column label="URL" align="center" prop="url" :show-overflow-tooltip="true" /> + <el-table-column + label="文件大小" + align="center" + prop="size" + width="120" + :formatter="fileSizeFormatter" + /> + <el-table-column label="文件类型" align="center" prop="type" width="180px" /> + <el-table-column label="文件内容" align="center" prop="url" width="110px"> + <template #default="{ row }"> + <el-image + v-if="row.type.includes('image')" + class="h-80px w-80px" + lazy + :src="row.url" + :preview-src-list="[row.url]" + preview-teleported + fit="cover" + /> + <el-link + v-else-if="row.type.includes('pdf')" + type="primary" + :href="row.url" + :underline="false" + target="_blank" + >预览</el-link + > + <el-link v-else type="primary" download :href="row.url" :underline="false" target="_blank" + >下载</el-link + > + </template> + </el-table-column> + <el-table-column + label="上传时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['infra:file:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <FileForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { fileSizeFormatter } from '@/utils' +import { dateFormatter } from '@/utils/formatTime' +import * as FileApi from '@/api/infra/file' +import FileForm from './FileForm.vue' + +defineOptions({ name: 'InfraFile' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + type: undefined, + path: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await FileApi.getFilePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = () => { + formRef.value.open() +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await FileApi.deleteFile(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/fileConfig/FileConfigForm.vue b/src/views/infra/fileConfig/FileConfigForm.vue new file mode 100644 index 0000000..4d89d0a --- /dev/null +++ b/src/views/infra/fileConfig/FileConfigForm.vue @@ -0,0 +1,196 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="120px" + > + <el-form-item label="配置名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入配置名" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + <el-form-item label="存储器" prop="storage"> + <el-select + v-model="formData.storage" + :disabled="formData.id !== undefined" + placeholder="请选择存储器" + > + <el-option + v-for="dict in getDictOptions(DICT_TYPE.INFRA_FILE_STORAGE)" + :key="dict.value" + :label="dict.label" + :value="parseInt(dict.value)" + /> + </el-select> + </el-form-item> + <!-- DB --> + <!-- Local / FTP / SFTP --> + <el-form-item + v-if="formData.storage >= 10 && formData.storage <= 12" + label="基础路径" + prop="config.basePath" + > + <el-input v-model="formData.config.basePath" placeholder="请输入基础路径" /> + </el-form-item> + <el-form-item + v-if="formData.storage >= 11 && formData.storage <= 12" + label="主机地址" + prop="config.host" + > + <el-input v-model="formData.config.host" placeholder="请输入主机地址" /> + </el-form-item> + <el-form-item + v-if="formData.storage >= 11 && formData.storage <= 12" + label="主机端口" + prop="config.port" + > + <el-input-number v-model="formData.config.port" :min="0" placeholder="请输入主机端口" /> + </el-form-item> + <el-form-item + v-if="formData.storage >= 11 && formData.storage <= 12" + label="用户名" + prop="config.username" + > + <el-input v-model="formData.config.username" placeholder="请输入密码" /> + </el-form-item> + <el-form-item + v-if="formData.storage >= 11 && formData.storage <= 12" + label="密码" + prop="config.password" + > + <el-input v-model="formData.config.password" placeholder="请输入密码" /> + </el-form-item> + <el-form-item v-if="formData.storage === 11" label="连接模式" prop="config.mode"> + <el-radio-group v-model="formData.config.mode"> + <el-radio key="Active" label="Active">主动模式</el-radio> + <el-radio key="Passive" label="Passive">被动模式</el-radio> + </el-radio-group> + </el-form-item> + <!-- S3 --> + <el-form-item v-if="formData.storage === 20" label="节点地址" prop="config.endpoint"> + <el-input v-model="formData.config.endpoint" placeholder="请输入节点地址" /> + </el-form-item> + <el-form-item v-if="formData.storage === 20" label="存储 bucket" prop="config.bucket"> + <el-input v-model="formData.config.bucket" placeholder="请输入 bucket" /> + </el-form-item> + <el-form-item v-if="formData.storage === 20" label="accessKey" prop="config.accessKey"> + <el-input v-model="formData.config.accessKey" placeholder="请输入 accessKey" /> + </el-form-item> + <el-form-item v-if="formData.storage === 20" label="accessSecret" prop="config.accessSecret"> + <el-input v-model="formData.config.accessSecret" placeholder="请输入 accessSecret" /> + </el-form-item> + <!-- 通用 --> + <el-form-item v-if="formData.storage === 20" label="自定义域名"> + <!-- 无需参数校验,所以去掉 prop --> + <el-input v-model="formData.config.domain" placeholder="请输入自定义域名" /> + </el-form-item> + <el-form-item v-else-if="formData.storage" label="自定义域名" prop="config.domain"> + <el-input v-model="formData.config.domain" placeholder="请输入自定义域名" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getDictOptions } from '@/utils/dict' +import * as FileConfigApi from '@/api/infra/fileConfig' +import { FormRules } from 'element-plus' + +defineOptions({ name: 'InfraFileConfigForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + storage: 0, + remark: '', + config: {} as FileConfigApi.FileClientConfig +}) +const formRules = reactive<FormRules>({ + name: [{ required: true, message: '配置名不能为空', trigger: 'blur' }], + storage: [{ required: true, message: '存储器不能为空', trigger: 'change' }], + config: { + basePath: [{ required: true, message: '基础路径不能为空', trigger: 'blur' }], + host: [{ required: true, message: '主机地址不能为空', trigger: 'blur' }], + port: [{ required: true, message: '主机端口不能为空', trigger: 'blur' }], + username: [{ required: true, message: '用户名不能为空', trigger: 'blur' }], + password: [{ required: true, message: '密码不能为空', trigger: 'blur' }], + mode: [{ required: true, message: '连接模式不能为空', trigger: 'change' }], + endpoint: [{ required: true, message: '节点地址不能为空', trigger: 'blur' }], + bucket: [{ required: true, message: '存储 bucket 不能为空', trigger: 'blur' }], + accessKey: [{ required: true, message: 'accessKey 不能为空', trigger: 'blur' }], + accessSecret: [{ required: true, message: 'accessSecret 不能为空', trigger: 'blur' }], + domain: [{ required: true, message: '自定义域名不能为空', trigger: 'blur' }] + } as FormRules +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await FileConfigApi.getFileConfig(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as FileConfigApi.FileConfigVO + if (formType.value === 'create') { + await FileConfigApi.createFileConfig(data) + message.success(t('common.createSuccess')) + } else { + await FileConfigApi.updateFileConfig(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + storage: undefined!, + remark: '', + config: {} as FileConfigApi.FileClientConfig + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/infra/fileConfig/index.vue b/src/views/infra/fileConfig/index.vue new file mode 100644 index 0000000..3eea106 --- /dev/null +++ b/src/views/infra/fileConfig/index.vue @@ -0,0 +1,218 @@ +<template> + <doc-alert title="上传下载" url="https://doc.iocoder.cn/file/" /> + + <!-- 搜索 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="配置名" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入配置名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="存储器" prop="storage"> + <el-select + v-model="queryParams.storage" + placeholder="请选择存储器" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_FILE_STORAGE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['infra:file-config:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="配置名" align="center" prop="name" /> + <el-table-column label="存储器" align="center" prop="storage"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_FILE_STORAGE" :value="scope.row.storage" /> + </template> + </el-table-column> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column label="主配置" align="center" prop="primary"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.master" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center" width="240px"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['infra:file-config:update']" + > + 编辑 + </el-button> + <el-button + link + type="primary" + :disabled="scope.row.master" + @click="handleMaster(scope.row.id)" + v-hasPermi="['infra:file-config:update']" + > + 主配置 + </el-button> + <el-button link type="primary" @click="handleTest(scope.row.id)"> 测试 </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['infra:file-config:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <FileConfigForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import * as FileConfigApi from '@/api/infra/fileConfig' +import FileConfigForm from './FileConfigForm.vue' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' + +defineOptions({ name: 'InfraFileConfig' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + storage: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await FileConfigApi.getFileConfigPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await FileConfigApi.deleteFileConfig(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 主配置按钮操作 */ +const handleMaster = async (id) => { + try { + await message.confirm('是否确认修改配置编号为"' + id + '"的数据项为主配置?') + await FileConfigApi.updateFileConfigMaster(id) + message.success(t('common.updateSuccess')) + await getList() + } catch {} +} + +/** 测试按钮操作 */ +const handleTest = async (id) => { + try { + const response = await FileConfigApi.testFileConfig(id) + await message.confirm('是否要访问该文件?', '测试上传成功') + window.open(response, '_blank') + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/job/JobDetail.vue b/src/views/infra/job/JobDetail.vue new file mode 100644 index 0000000..d2e8c46 --- /dev/null +++ b/src/views/infra/job/JobDetail.vue @@ -0,0 +1,73 @@ +<template> + <Dialog v-model="dialogVisible" title="任务详细" width="700px"> + <el-descriptions :column="1" border> + <el-descriptions-item label="任务编号" min-width="60"> + {{ detailData.id }} + </el-descriptions-item> + <el-descriptions-item label="任务名称"> + {{ detailData.name }} + </el-descriptions-item> + <el-descriptions-item label="任务名称"> + <dict-tag :type="DICT_TYPE.INFRA_JOB_STATUS" :value="detailData.status" /> + </el-descriptions-item> + <el-descriptions-item label="处理器的名字"> + {{ detailData.handlerName }} + </el-descriptions-item> + <el-descriptions-item label="处理器的参数"> + {{ detailData.handlerParam }} + </el-descriptions-item> + <el-descriptions-item label="Cron 表达式"> + {{ detailData.cronExpression }} + </el-descriptions-item> + <el-descriptions-item label="重试次数"> + {{ detailData.retryCount }} + </el-descriptions-item> + <el-descriptions-item label="重试间隔"> + {{ detailData.retryInterval + ' 毫秒' }} + </el-descriptions-item> + <el-descriptions-item label="监控超时时间"> + {{ detailData.monitorTimeout > 0 ? detailData.monitorTimeout + ' 毫秒' : '未开启' }} + </el-descriptions-item> + <el-descriptions-item label="后续执行时间"> + <el-timeline> + <el-timeline-item + v-for="(nextTime, index) in nextTimes" + :key="index" + :timestamp="formatDate(nextTime)" + > + 第 {{ index + 1 }} 次 + </el-timeline-item> + </el-timeline> + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import * as JobApi from '@/api/infra/job' + +defineOptions({ name: 'InfraJobDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({} as JobApi.JobVO) // 详情数据 +const nextTimes = ref([]) // 下一轮执行时间的数组 + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + // 查看,设置数据 + if (id) { + detailLoading.value = true + try { + detailData.value = await JobApi.getJob(id) + // 获取下一次执行时间 + nextTimes.value = await JobApi.getJobNextTimes(id) + } finally { + detailLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/infra/job/JobForm.vue b/src/views/infra/job/JobForm.vue new file mode 100644 index 0000000..79f85e7 --- /dev/null +++ b/src/views/infra/job/JobForm.vue @@ -0,0 +1,137 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label="任务名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入任务名称" /> + </el-form-item> + <el-form-item label="处理器的名字" prop="handlerName"> + <el-input + :readonly="formData.id !== undefined" + v-model="formData.handlerName" + placeholder="请输入处理器的名字" + /> + </el-form-item> + <el-form-item label="处理器的参数" prop="handlerParam"> + <el-input v-model="formData.handlerParam" placeholder="请输入处理器的参数" /> + </el-form-item> + <el-form-item label="CRON 表达式" prop="cronExpression"> + <crontab v-model="formData.cronExpression" /> + </el-form-item> + <el-form-item label="重试次数" prop="retryCount"> + <el-input + v-model="formData.retryCount" + placeholder="请输入重试次数。设置为 0 时,不进行重试" + /> + </el-form-item> + <el-form-item label="重试间隔" prop="retryInterval"> + <el-input + v-model="formData.retryInterval" + placeholder="请输入重试间隔,单位:毫秒。设置为 0 时,无需间隔" + /> + </el-form-item> + <el-form-item label="监控超时时间" prop="monitorTimeout"> + <el-input v-model="formData.monitorTimeout" placeholder="请输入监控超时时间,单位:毫秒" /> + </el-form-item> + </el-form> + <template #footer> + <el-button type="primary" @click="submitForm" :loading="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as JobApi from '@/api/infra/job' + +defineOptions({ name: 'JobForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + handlerName: '', + handlerParam: '', + cronExpression: '', + retryCount: undefined, + retryInterval: undefined, + monitorTimeout: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '任务名称不能为空', trigger: 'blur' }], + handlerName: [{ required: true, message: '处理器的名字不能为空', trigger: 'blur' }], + cronExpression: [{ required: true, message: 'CRON 表达式不能为空', trigger: 'blur' }], + retryCount: [{ required: true, message: '重试次数不能为空', trigger: 'blur' }], + retryInterval: [{ required: true, message: '重试间隔不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await JobApi.getJob(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交按钮 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as JobApi.JobVO + if (formType.value === 'create') { + await JobApi.createJob(data) + message.success(t('common.createSuccess')) + } else { + await JobApi.updateJob(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + handlerName: '', + handlerParam: '', + cronExpression: '', + retryCount: undefined, + retryInterval: undefined, + monitorTimeout: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/infra/job/index.vue b/src/views/infra/job/index.vue new file mode 100644 index 0000000..2946003 --- /dev/null +++ b/src/views/infra/job/index.vue @@ -0,0 +1,303 @@ +<template> + <doc-alert title="定时任务" url="https://doc.iocoder.cn/job/" /> + <doc-alert title="异步任务" url="https://doc.iocoder.cn/async-task/" /> + <doc-alert title="消息队列" url="https://doc.iocoder.cn/message-queue/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="100px" + > + <el-form-item label="任务名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入任务名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="任务状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择任务状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_JOB_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="处理器的名字" prop="handlerName"> + <el-input + v-model="queryParams.handlerName" + placeholder="请输入处理器的名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['infra:job:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:job:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + <el-button type="info" plain @click="handleJobLog()" v-hasPermi="['infra:job:query']"> + <Icon icon="ep:zoom-in" class="mr-5px" /> 执行日志 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="任务编号" align="center" prop="id" /> + <el-table-column label="任务名称" align="center" prop="name" /> + <el-table-column label="任务状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_JOB_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="处理器的名字" align="center" prop="handlerName" /> + <el-table-column label="处理器的参数" align="center" prop="handlerParam" /> + <el-table-column label="CRON 表达式" align="center" prop="cronExpression" /> + <el-table-column label="操作" align="center" width="200"> + <template #default="scope"> + <el-button + type="primary" + link + @click="openForm('update', scope.row.id)" + v-hasPermi="['infra:job:update']" + > + 修改 + </el-button> + <el-button + type="primary" + link + @click="handleChangeStatus(scope.row)" + v-hasPermi="['infra:job:update']" + > + {{ scope.row.status === InfraJobStatusEnum.STOP ? '开启' : '暂停' }} + </el-button> + <el-button + type="danger" + link + @click="handleDelete(scope.row.id)" + v-hasPermi="['infra:job:delete']" + > + 删除 + </el-button> + <el-dropdown + @command="(command) => handleCommand(command, scope.row)" + v-hasPermi="['infra:job:trigger', 'infra:job:query']" + > + <el-button type="primary" link><Icon icon="ep:d-arrow-right" /> 更多</el-button> + <template #dropdown> + <el-dropdown-menu> + <el-dropdown-item command="handleRun" v-if="checkPermi(['infra:job:trigger'])"> + 执行一次 + </el-dropdown-item> + <el-dropdown-item command="openDetail" v-if="checkPermi(['infra:job:query'])"> + 任务详细 + </el-dropdown-item> + <el-dropdown-item command="handleJobLog" v-if="checkPermi(['infra:job:query'])"> + 调度日志 + </el-dropdown-item> + </el-dropdown-menu> + </template> + </el-dropdown> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <JobForm ref="formRef" @success="getList" /> + <!-- 表单弹窗:查看 --> + <JobDetail ref="detailRef" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { checkPermi } from '@/utils/permission' +import JobForm from './JobForm.vue' +import JobDetail from './JobDetail.vue' +import download from '@/utils/download' +import * as JobApi from '@/api/infra/job' +import { InfraJobStatusEnum } from '@/utils/constants' + +defineOptions({ name: 'InfraJob' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const { push } = useRouter() // 路由 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined, + handlerName: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await JobApi.getJobPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await JobApi.exportJob(queryParams) + download.excel(data, '定时任务.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 修改状态操作 */ +const handleChangeStatus = async (row: JobApi.JobVO) => { + try { + // 修改状态的二次确认 + const text = row.status === InfraJobStatusEnum.STOP ? '开启' : '关闭' + await message.confirm( + '确认要' + text + '定时任务编号为"' + row.id + '"的数据项?', + t('common.reminder') + ) + const status = + row.status === InfraJobStatusEnum.STOP ? InfraJobStatusEnum.NORMAL : InfraJobStatusEnum.STOP + await JobApi.updateJobStatus(row.id, status) + message.success(text + '成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await JobApi.deleteJob(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** '更多'操作按钮 */ +const handleCommand = (command, row) => { + switch (command) { + case 'handleRun': + handleRun(row) + break + case 'openDetail': + openDetail(row.id) + break + case 'handleJobLog': + handleJobLog(row?.id) + break + default: + break + } +} + +/** 执行一次 */ +const handleRun = async (row: JobApi.JobVO) => { + try { + // 二次确认 + await message.confirm('确认要立即执行一次' + row.name + '?', t('common.reminder')) + // 提交执行 + await JobApi.runJob(row.id) + message.success('执行成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 查看操作 */ +const detailRef = ref() +const openDetail = (id: number) => { + detailRef.value.open(id) +} + +/** 跳转执行日志 */ +const handleJobLog = (id?: number) => { + if (id && id > 0) { + push('/job/job-log?id=' + id) + } else { + push('/job/job-log') + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/job/logger/JobLogDetail.vue b/src/views/infra/job/logger/JobLogDetail.vue new file mode 100644 index 0000000..7216f52 --- /dev/null +++ b/src/views/infra/job/logger/JobLogDetail.vue @@ -0,0 +1,59 @@ +<template> + <Dialog v-model="dialogVisible" title="任务详细" width="700px"> + <el-descriptions :column="1" border> + <el-descriptions-item label="日志编号" min-width="60"> + {{ detailData.id }} + </el-descriptions-item> + <el-descriptions-item label="任务编号"> + {{ detailData.jobId }} + </el-descriptions-item> + <el-descriptions-item label="处理器的名字"> + {{ detailData.handlerName }} + </el-descriptions-item> + <el-descriptions-item label="处理器的参数"> + {{ detailData.handlerParam }} + </el-descriptions-item> + <el-descriptions-item label="第几次执行"> + {{ detailData.executeIndex }} + </el-descriptions-item> + <el-descriptions-item label="执行时间"> + {{ formatDate(detailData.beginTime) + ' ~ ' + formatDate(detailData.endTime) }} + </el-descriptions-item> + <el-descriptions-item label="执行时长"> + {{ detailData.duration + ' 毫秒' }} + </el-descriptions-item> + <el-descriptions-item label="任务状态"> + <dict-tag :type="DICT_TYPE.INFRA_JOB_LOG_STATUS" :value="detailData.status" /> + </el-descriptions-item> + <el-descriptions-item label="执行结果"> + {{ detailData.result }} + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import * as JobLogApi from '@/api/infra/jobLog' + +defineOptions({ name: 'JobLogDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({} as JobLogApi.JobLogVO) // 详情数据 + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + // 查看,设置数据 + if (id) { + detailLoading.value = true + try { + detailData.value = await JobLogApi.getJobLog(id) + } finally { + detailLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/infra/job/logger/index.vue b/src/views/infra/job/logger/index.vue new file mode 100644 index 0000000..3d2be80 --- /dev/null +++ b/src/views/infra/job/logger/index.vue @@ -0,0 +1,200 @@ +<template> + <doc-alert title="定时任务" url="https://doc.iocoder.cn/job/" /> + <doc-alert title="异步任务" url="https://doc.iocoder.cn/async-task/" /> + <doc-alert title="消息队列" url="https://doc.iocoder.cn/message-queue/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="120px" + > + <el-form-item label="处理器的名字" prop="handlerName"> + <el-input + v-model="queryParams.handlerName" + placeholder="请输入处理器的名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="开始执行时间" prop="beginTime"> + <el-date-picker + v-model="queryParams.beginTime" + type="date" + value-format="YYYY-MM-DD HH:mm:ss" + placeholder="选择开始执行时间" + clearable + class="!w-240px" + /> + </el-form-item> + <el-form-item label="结束执行时间" prop="endTime"> + <el-date-picker + v-model="queryParams.endTime" + type="date" + value-format="YYYY-MM-DD HH:mm:ss" + placeholder="选择结束执行时间" + clearable + :default-time="new Date('1 23:59:59')" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="任务状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择任务状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_JOB_LOG_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:job:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="日志编号" align="center" prop="id" /> + <el-table-column label="任务编号" align="center" prop="jobId" /> + <el-table-column label="处理器的名字" align="center" prop="handlerName" /> + <el-table-column label="处理器的参数" align="center" prop="handlerParam" /> + <el-table-column label="第几次执行" align="center" prop="executeIndex" /> + <el-table-column label="执行时间" align="center" width="170s"> + <template #default="scope"> + <span>{{ formatDate(scope.row.beginTime) + ' ~ ' + formatDate(scope.row.endTime) }}</span> + </template> + </el-table-column> + <el-table-column label="执行时长" align="center" prop="duration"> + <template #default="scope"> + <span>{{ scope.row.duration + ' 毫秒' }}</span> + </template> + </el-table-column> + <el-table-column label="任务状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_JOB_LOG_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + type="primary" + link + @click="openDetail(scope.row.id)" + v-hasPermi="['infra:job:query']" + > + 详细 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:查看 --> + <JobLogDetail ref="detailRef" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import download from '@/utils/download' +import JobLogDetail from './JobLogDetail.vue' +import * as JobLogApi from '@/api/infra/jobLog' + +defineOptions({ name: 'InfraJobLog' }) + +const message = useMessage() // 消息弹窗 +const { query } = useRoute() // 查询参数 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + jobId: query.id, + handlerName: undefined, + beginTime: undefined, + endTime: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await JobLogApi.getJobLogPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 查看操作 */ +const detailRef = ref() +const openDetail = (rowId?: number) => { + detailRef.value.open(rowId) +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await JobLogApi.exportJobLog(queryParams) + download.excel(data, '定时任务执行日志.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/infra/redis/index.vue b/src/views/infra/redis/index.vue new file mode 100644 index 0000000..e047d74 --- /dev/null +++ b/src/views/infra/redis/index.vue @@ -0,0 +1,268 @@ +<template> + <doc-alert title="Redis 缓存" url="https://doc.iocoder.cn/redis-cache/" /> + <doc-alert title="本地缓存" url="https://doc.iocoder.cn/local-cache/" /> + <el-scrollbar height="calc(100vh - 88px - 40px - 50px)"> + <el-row> + <!-- 基本信息 --> + <el-col :span="24" class="card-box" shadow="hover"> + <el-card> + <el-descriptions title="基本信息" :column="6" border> + <el-descriptions-item label="Redis版本 :"> + {{ cache?.info?.redis_version }} + </el-descriptions-item> + <el-descriptions-item label="运行模式 :"> + {{ cache?.info?.redis_mode == 'standalone' ? '单机' : '集群' }} + </el-descriptions-item> + <el-descriptions-item label="端口 :"> + {{ cache?.info?.tcp_port }} + </el-descriptions-item> + <el-descriptions-item label="客户端数 :"> + {{ cache?.info?.connected_clients }} + </el-descriptions-item> + <el-descriptions-item label="运行时间(天) :"> + {{ cache?.info?.uptime_in_days }} + </el-descriptions-item> + <el-descriptions-item label="使用内存 :"> + {{ cache?.info?.used_memory_human }} + </el-descriptions-item> + <el-descriptions-item label="使用CPU :"> + {{ cache?.info ? parseFloat(cache?.info?.used_cpu_user_children).toFixed(2) : '' }} + </el-descriptions-item> + <el-descriptions-item label="内存配置 :"> + {{ cache?.info?.maxmemory_human }} + </el-descriptions-item> + <el-descriptions-item label="AOF是否开启 :"> + {{ cache?.info?.aof_enabled == '0' ? '否' : '是' }} + </el-descriptions-item> + <el-descriptions-item label="RDB是否成功 :"> + {{ cache?.info?.rdb_last_bgsave_status }} + </el-descriptions-item> + <el-descriptions-item label="Key数量 :"> + {{ cache?.dbSize }} + </el-descriptions-item> + <el-descriptions-item label="网络入口/出口 :"> + {{ cache?.info?.instantaneous_input_kbps }}kps/ + {{ cache?.info?.instantaneous_output_kbps }}kps + </el-descriptions-item> + </el-descriptions> + </el-card> + </el-col> + <!-- 命令统计 --> + <el-col :span="12" class="mt-3"> + <el-card :gutter="12" shadow="hover"> + <Echart :options="commandStatsRefChika" :height="420" /> + </el-card> + </el-col> + <!-- 内存使用量统计 --> + <el-col :span="12" class="mt-3"> + <el-card class="ml-3" :gutter="12" shadow="hover"> + <Echart :options="usedmemoryEchartChika" :height="420" /> + </el-card> + </el-col> + </el-row> + </el-scrollbar> +</template> +<script lang="ts" setup> +import * as RedisApi from '@/api/infra/redis' +import { RedisMonitorInfoVO } from '@/api/infra/redis/types' +const cache = ref<RedisMonitorInfoVO>() + +// 基本信息 +const readRedisInfo = async () => { + const data = await RedisApi.getCache() + cache.value = data +} + +// 内存使用情况 +const usedmemoryEchartChika = reactive<any>({ + title: { + // 仪表盘标题。 + text: '内存使用情况', + left: 'center', + show: true, // 是否显示标题,默认 true。 + offsetCenter: [0, '20%'], //相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。 + color: 'yellow', // 文字的颜色,默认 #333。 + fontSize: 20 // 文字的字体大小,默认 15。 + }, + toolbox: { + show: false, + feature: { + restore: { show: true }, + saveAsImage: { show: true } + } + }, + series: [ + { + name: '峰值', + type: 'gauge', + min: 0, + max: 50, + splitNumber: 10, + //这是指针的颜色 + color: '#F5C74E', + radius: '85%', + center: ['50%', '50%'], + startAngle: 225, + endAngle: -45, + axisLine: { + // 坐标轴线 + lineStyle: { + // 属性lineStyle控制线条样式 + color: [ + [0.2, '#7FFF00'], + [0.8, '#00FFFF'], + [1, '#FF0000'] + ], + //width: 6 外框的大小(环的宽度) + width: 10 + } + }, + axisTick: { + // 坐标轴小标记 + //里面的线长是5(短线) + length: 5, // 属性length控制线长 + lineStyle: { + // 属性lineStyle控制线条样式 + color: '#76D9D7' + } + }, + splitLine: { + // 分隔线 + length: 20, // 属性length控制线长 + lineStyle: { + // 属性lineStyle(详见lineStyle)控制线条样式 + color: '#76D9D7' + } + }, + axisLabel: { + color: '#76D9D7', + distance: 15, + fontSize: 15 + }, + pointer: { + // 指针的大小 + width: 7, + show: true + }, + detail: { + textStyle: { + fontWeight: 'normal', + // 里面文字下的数值大小(50) + fontSize: 15, + color: '#FFFFFF' + }, + valueAnimation: true + }, + progress: { + show: true + } + } + ] +}) + +// 指令使用情况 +const commandStatsRefChika = reactive({ + title: { + text: '命令统计', + left: 'center' + }, + tooltip: { + trigger: 'item', + formatter: '{a} <br/>{b} : {c} ({d}%)' + }, + legend: { + type: 'scroll', + orient: 'vertical', + right: 30, + top: 10, + bottom: 20, + data: [] as any[], + textStyle: { + color: '#a1a1a1' + } + }, + series: [ + { + name: '命令', + type: 'pie', + radius: [20, 120], + center: ['40%', '60%'], + data: [] as any[], + roseType: 'radius', + label: { + show: true + }, + emphasis: { + label: { + show: true + }, + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)' + } + } + } + ] +}) + +/** 加载数据 */ +const getSummary = () => { + // 初始化命令图表 + initCommandStatsChart() + usedMemoryInstance() +} + +/** 命令使用情况 */ +const initCommandStatsChart = async () => { + usedmemoryEchartChika.series[0].data = [] + // 发起请求 + try { + const data = await RedisApi.getCache() + cache.value = data + // 处理数据 + const commandStats = [] as any[] + const nameList = [] as string[] + data.commandStats.forEach((row) => { + commandStats.push({ + name: row.command, + value: row.calls + }) + nameList.push(row.command) + }) + commandStatsRefChika.legend.data = nameList + commandStatsRefChika.series[0].data = commandStats + } catch {} +} +const usedMemoryInstance = async () => { + try { + const data = await RedisApi.getCache() + cache.value = data + // 仪表盘详情,用于显示数据。 + usedmemoryEchartChika.series[0].detail = { + show: true, // 是否显示详情,默认 true。 + offsetCenter: [0, '50%'], // 相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。 + color: 'auto', // 文字的颜色,默认 auto。 + fontSize: 30, // 文字的字体大小,默认 15。 + formatter: cache.value!.info.used_memory_human // 格式化函数或者字符串 + } + + usedmemoryEchartChika.series[0].data[0] = { + value: cache.value!.info.used_memory_human, + name: '内存消耗' + } + console.log(cache.value!.info) + usedmemoryEchartChika.tooltip = { + formatter: '{b} <br/>{a} : ' + cache.value!.info.used_memory_human + } + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + // 读取 redis 信息 + readRedisInfo() + // 加载数据 + getSummary() +}) +</script> diff --git a/src/views/infra/server/index.vue b/src/views/infra/server/index.vue new file mode 100644 index 0000000..b9a157a --- /dev/null +++ b/src/views/infra/server/index.vue @@ -0,0 +1,30 @@ +<template> + <doc-alert title="服务监控" url="https://doc.iocoder.cn/server-monitor/" /> + + <ContentWrap> + <IFrame v-if="!loading" v-loading="loading" :src="src" /> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ConfigApi from '@/api/infra/config' + +defineOptions({ name: 'InfraAdminServer' }) + +const loading = ref(true) // 是否加载中 +const src = ref(import.meta.env.VITE_BASE_URL + '/admin/applications') + +/** 初始化 */ +onMounted(async () => { + try { + // 友情提示:如果访问出现 404 问题: + // 1)boot 参考 https://doc.iocoder.cn/server-monitor/ 解决; + // 2)cloud 参考 https://cloud.iocoder.cn/server-monitor/ 解决 + const data = await ConfigApi.getConfigKey('url.spring-boot-admin') + if (data && data.length > 0) { + src.value = data + } + } finally { + loading.value = false + } +}) +</script> diff --git a/src/views/infra/skywalking/index.vue b/src/views/infra/skywalking/index.vue new file mode 100644 index 0000000..d576ddb --- /dev/null +++ b/src/views/infra/skywalking/index.vue @@ -0,0 +1,27 @@ +<template> + <doc-alert title="服务监控" url="https://doc.iocoder.cn/server-monitor/" /> + + <ContentWrap> + <IFrame v-if="!loading" v-loading="loading" :src="src" /> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ConfigApi from '@/api/infra/config' + +defineOptions({ name: 'InfraSkyWalking' }) + +const loading = ref(true) // 是否加载中 +const src = ref('http://skywalking.shop.iocoder.cn') + +/** 初始化 */ +onMounted(async () => { + try { + const data = await ConfigApi.getConfigKey('url.skywalking') + if (data && data.length > 0) { + src.value = data + } + } finally { + loading.value = false + } +}) +</script> diff --git a/src/views/infra/swagger/index.vue b/src/views/infra/swagger/index.vue new file mode 100644 index 0000000..f844ba6 --- /dev/null +++ b/src/views/infra/swagger/index.vue @@ -0,0 +1,28 @@ +<template> + <doc-alert title="接口文档" url="https://doc.iocoder.cn/api-doc/" /> + + <ContentWrap> + <IFrame :src="src" /> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ConfigApi from '@/api/infra/config' + +defineOptions({ name: 'InfraSwagger' }) + +const loading = ref(true) // 是否加载中 +const src = ref(import.meta.env.VITE_BASE_URL + '/doc.html') // Knife4j UI +// const src = ref(import.meta.env.VITE_BASE_URL + '/swagger-ui') // Swagger UI + +/** 初始化 */ +onMounted(async () => { + try { + const data = await ConfigApi.getConfigKey('url.swagger') + if (data && data.length > 0) { + src.value = data + } + } finally { + loading.value = false + } +}) +</script> diff --git a/src/views/infra/webSocket/index.vue b/src/views/infra/webSocket/index.vue new file mode 100644 index 0000000..a794344 --- /dev/null +++ b/src/views/infra/webSocket/index.vue @@ -0,0 +1,183 @@ +<template> + <doc-alert title="WebSocket 实时通信" url="https://doc.iocoder.cn/websocket/" /> + + <div class="flex"> + <!-- 左侧:建立连接、发送消息 --> + <el-card :gutter="12" class="w-1/2" shadow="always"> + <template #header> + <div class="card-header"> + <span>连接</span> + </div> + </template> + <div class="flex items-center"> + <span class="mr-4 text-lg font-medium"> 连接状态: </span> + <el-tag :color="getTagColor">{{ status }}</el-tag> + </div> + <hr class="my-4" /> + <div class="flex"> + <el-input v-model="server" disabled> + <template #prepend>服务地址</template> + </el-input> + <el-button :type="getIsOpen ? 'danger' : 'primary'" @click="toggleConnectStatus"> + {{ getIsOpen ? '关闭连接' : '开启连接' }} + </el-button> + </div> + <p class="mt-4 text-lg font-medium">消息输入框</p> + <hr class="my-4" /> + <el-input + v-model="sendText" + :autosize="{ minRows: 2, maxRows: 4 }" + :disabled="!getIsOpen" + clearable + placeholder="请输入你要发送的消息" + type="textarea" + /> + <el-select v-model="sendUserId" class="mt-4" placeholder="请选择发送人"> + <el-option key="" label="所有人" value="" /> + <el-option + v-for="user in userList" + :key="user.id" + :label="user.nickname" + :value="user.id" + /> + </el-select> + <el-button :disabled="!getIsOpen" block class="ml-2 mt-4" type="primary" @click="handlerSend"> + 发送 + </el-button> + </el-card> + <!-- 右侧:消息记录 --> + <el-card :gutter="12" class="w-1/2" shadow="always"> + <template #header> + <div class="card-header"> + <span>消息记录</span> + </div> + </template> + <div class="max-h-80 overflow-auto"> + <ul> + <li v-for="msg in messageReverseList" :key="msg.time" class="mt-2"> + <div class="flex items-center"> + <span class="text-primary mr-2 font-medium">收到消息:</span> + <span>{{ formatDate(msg.time) }}</span> + </div> + <div> + {{ msg.text }} + </div> + </li> + </ul> + </div> + </el-card> + </div> +</template> +<script lang="ts" setup> +import { formatDate } from '@/utils/formatTime' +import { useWebSocket } from '@vueuse/core' +import { getAccessToken } from '@/utils/auth' +import * as UserApi from '@/api/system/user' + +defineOptions({ name: 'InfraWebSocket' }) + +const message = useMessage() // 消息弹窗 + +const server = ref( + (import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') + '?token=' + getAccessToken() +) // WebSocket 服务地址 +const getIsOpen = computed(() => status.value === 'OPEN') // WebSocket 连接是否打开 +const getTagColor = computed(() => (getIsOpen.value ? 'success' : 'red')) // WebSocket 连接的展示颜色 + +/** 发起 WebSocket 连接 */ +const { status, data, send, close, open } = useWebSocket(server.value, { + autoReconnect: true, + heartbeat: true +}) + +/** 监听接收到的数据 */ +const messageList = ref([] as { time: number; text: string }[]) // 消息列表 +const messageReverseList = computed(() => messageList.value.slice().reverse()) +watchEffect(() => { + if (!data.value) { + return + } + try { + // 1. 收到心跳 + if (data.value === 'pong') { + // state.recordList.push({ + // text: '【心跳】', + // time: new Date().getTime() + // }) + return + } + + // 2.1 解析 type 消息类型 + const jsonMessage = JSON.parse(data.value) + const type = jsonMessage.type + const content = JSON.parse(jsonMessage.content) + if (!type) { + message.error('未知的消息类型:' + data.value) + return + } + // 2.2 消息类型:demo-message-receive + if (type === 'demo-message-receive') { + const single = content.single + if (single) { + messageList.value.push({ + text: `【单发】用户编号(${content.fromUserId}):${content.text}`, + time: new Date().getTime() + }) + } else { + messageList.value.push({ + text: `【群发】用户编号(${content.fromUserId}):${content.text}`, + time: new Date().getTime() + }) + } + return + } + // 2.3 消息类型:notice-push + if (type === 'notice-push') { + messageList.value.push({ + text: `【系统通知】:${content.title}`, + time: new Date().getTime() + }) + return + } + message.error('未处理消息:' + data.value) + } catch (error) { + message.error('处理消息发生异常:' + data.value) + console.error(error) + } +}) + +/** 发送消息 */ +const sendText = ref('') // 发送内容 +const sendUserId = ref('') // 发送人 +const handlerSend = () => { + // 1.1 先 JSON 化 message 消息内容 + const messageContent = JSON.stringify({ + text: sendText.value, + toUserId: sendUserId.value + }) + // 1.2 再 JSON 化整个消息 + const jsonMessage = JSON.stringify({ + type: 'demo-message-send', + content: messageContent + }) + // 2. 最后发送消息 + send(jsonMessage) + sendText.value = '' +} + +/** 切换 websocket 连接状态 */ +const toggleConnectStatus = () => { + if (getIsOpen.value) { + close() + } else { + open() + } +} + +/** 初始化 **/ +const userList = ref<any[]>([]) // 用户列表 +onMounted(async () => { + // 获取用户列表 + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/mall/home/components/ComparisonCard.vue b/src/views/mall/home/components/ComparisonCard.vue new file mode 100644 index 0000000..ee1c2f0 --- /dev/null +++ b/src/views/mall/home/components/ComparisonCard.vue @@ -0,0 +1,42 @@ +<template> + <div class="flex flex-col gap-2 bg-[var(--el-bg-color-overlay)] p-6"> + <div class="flex items-center justify-between text-gray-500"> + <span>{{ title }}</span> + <el-tag>{{ tag }}</el-tag> + </div> + <div class="flex flex-row items-baseline justify-between"> + <CountTo :prefix="prefix" :end-val="value" :decimals="decimals" class="text-3xl" /> + <span :class="toNumber(percent) > 0 ? 'text-red-500' : 'text-green-500'"> + {{ Math.abs(toNumber(percent)) }}% + <Icon :icon="toNumber(percent) > 0 ? 'ep:caret-top' : 'ep:caret-bottom'" class="!text-sm" /> + </span> + </div> + <el-divider class="mb-1! mt-2!" /> + <div class="flex flex-row items-center justify-between text-sm"> + <span class="text-gray-500">昨日数据</span> + <span>{{ prefix || '' }}{{ reference }}</span> + </div> + </div> +</template> +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { toNumber } from 'lodash-es' +import { calculateRelativeRate } from '@/utils' + +/** 交易对照卡片 */ +defineOptions({ name: 'ComparisonCard' }) + +const props = defineProps({ + title: propTypes.string.def('').isRequired, + tag: propTypes.string.def(''), + prefix: propTypes.string.def(''), + value: propTypes.number.def(0).isRequired, + reference: propTypes.number.def(0).isRequired, + decimals: propTypes.number.def(0) +}) + +// 计算环比 +const percent = computed(() => + calculateRelativeRate(props.value as number, props.reference as number) +) +</script> diff --git a/src/views/mall/home/components/MemberStatisticsCard.vue b/src/views/mall/home/components/MemberStatisticsCard.vue new file mode 100644 index 0000000..2f9d7ab --- /dev/null +++ b/src/views/mall/home/components/MemberStatisticsCard.vue @@ -0,0 +1,91 @@ +<template> + <el-card shadow="never"> + <template #header> + <CardTitle title="用户统计" /> + </template> + <!-- 折线图 --> + <Echart :height="300" :options="lineChartOptions" /> + </el-card> +</template> +<script lang="ts" setup> +import dayjs from 'dayjs' +import { EChartsOption } from 'echarts' +import * as MemberStatisticsApi from '@/api/mall/statistics/member' +import { formatDate } from '@/utils/formatTime' +import { CardTitle } from '@/components/Card' + +/** 会员用户统计卡片 */ +defineOptions({ name: 'MemberStatisticsCard' }) + +const loading = ref(true) // 加载中 +/** 折线图配置 */ +const lineChartOptions = reactive<EChartsOption>({ + dataset: { + dimensions: ['date', 'count'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + top: 80, + containLabel: true + }, + legend: { + top: 50 + }, + series: [{ name: '注册量', type: 'line', smooth: true, areaStyle: {} }], + toolbox: { + feature: { + // 数据区域缩放 + dataZoom: { + yAxisIndex: false // Y轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '会员统计' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross' + }, + padding: [5, 10] + }, + xAxis: { + type: 'category', + boundaryGap: false, + axisTick: { + show: false + }, + axisLabel: { + formatter: (date: string) => formatDate(date, 'MM-DD') + } + }, + yAxis: { + axisTick: { + show: false + } + } +}) as EChartsOption + +const getMemberRegisterCountList = async () => { + loading.value = true + // 查询最近一月数据 + const beginTime = dayjs().subtract(30, 'd').startOf('d') + const endTime = dayjs().endOf('d') + const list = await MemberStatisticsApi.getMemberRegisterCountList(beginTime, endTime) + // 更新 Echarts 数据 + if (lineChartOptions.dataset && lineChartOptions.dataset['source']) { + lineChartOptions.dataset['source'] = list + } + loading.value = false +} + +/** 初始化 **/ +onMounted(() => { + getMemberRegisterCountList() +}) +</script> diff --git a/src/views/mall/home/components/OperationDataCard.vue b/src/views/mall/home/components/OperationDataCard.vue new file mode 100644 index 0000000..a69571a --- /dev/null +++ b/src/views/mall/home/components/OperationDataCard.vue @@ -0,0 +1,107 @@ +<template> + <el-card shadow="never"> + <template #header> + <CardTitle title="运营数据" /> + </template> + <div class="flex flex-row flex-wrap items-center gap-8 p-4"> + <div + v-for="item in data" + :key="item.name" + class="h-20 w-20% flex flex-col cursor-pointer items-center justify-center gap-2" + @click="handleClick(item.routerName)" + > + <CountTo + :prefix="item.prefix" + :end-val="item.value" + :decimals="item.decimals" + class="text-3xl" + /> + <span class="text-center">{{ item.name }}</span> + </div> + </div> + </el-card> +</template> +<script lang="ts" setup> +import * as ProductSpuApi from '@/api/mall/product/spu' +import * as TradeStatisticsApi from '@/api/mall/statistics/trade' +import * as PayStatisticsApi from '@/api/mall/statistics/pay' +import { CardTitle } from '@/components/Card' + +/** 运营数据卡片 */ +defineOptions({ name: 'OperationDataCard' }) + +const router = useRouter() // 路由 + +/** 数据 */ +const data = reactive({ + orderUndelivered: { name: '待发货订单', value: 9, routerName: 'TradeOrder' }, + orderAfterSaleApply: { name: '退款中订单', value: 4, routerName: 'TradeAfterSale' }, + orderWaitePickUp: { name: '待核销订单', value: 0, routerName: 'TradeOrder' }, + productAlertStock: { name: '库存预警', value: 0, routerName: 'ProductSpu' }, + productForSale: { name: '上架商品', value: 0, routerName: 'ProductSpu' }, + productInWarehouse: { name: '仓库商品', value: 0, routerName: 'ProductSpu' }, + withdrawAuditing: { name: '提现待审核', value: 0, routerName: 'TradeBrokerageWithdraw' }, + rechargePrice: { + name: '账户充值', + value: 0.0, + prefix: '¥', + decimals: 2, + routerName: 'PayWalletRecharge' + } +}) + +/** 查询订单数据 */ +const getOrderData = async () => { + const orderCount = await TradeStatisticsApi.getOrderCount() + if (orderCount.undelivered != null) { + data.orderUndelivered.value = orderCount.undelivered + } + if (orderCount.afterSaleApply != null) { + data.orderAfterSaleApply.value = orderCount.afterSaleApply + } + if (orderCount.pickUp != null) { + data.orderWaitePickUp.value = orderCount.pickUp + } + if (orderCount.auditingWithdraw != null) { + data.withdrawAuditing.value = orderCount.auditingWithdraw + } +} + +/** 查询商品数据 */ +const getProductData = async () => { + // TODO: @芋艿:这个接口的返回值,是不是用命名字段更好些? + const productCount = await ProductSpuApi.getTabsCount() + data.productForSale.value = productCount['0'] + data.productInWarehouse.value = productCount['1'] + data.productAlertStock.value = productCount['3'] +} + +/** 查询钱包充值数据 */ +const getWalletRechargeData = async () => { + const paySummary = await PayStatisticsApi.getWalletRechargePrice() + data.rechargePrice.value = paySummary.rechargePrice +} + +/** + * 跳转到对应页面 + * + * @param routerName 路由页面组件的名称 + */ +const handleClick = (routerName: string) => { + router.push({ name: routerName }) +} + +/** 激活时 */ +onActivated(() => { + getOrderData() + getProductData() + getWalletRechargeData() +}) + +/** 初始化 **/ +onMounted(() => { + getOrderData() + getProductData() + getWalletRechargeData() +}) +</script> diff --git a/src/views/mall/home/components/ShortcutCard.vue b/src/views/mall/home/components/ShortcutCard.vue new file mode 100644 index 0000000..cea9113 --- /dev/null +++ b/src/views/mall/home/components/ShortcutCard.vue @@ -0,0 +1,82 @@ +<template> + <el-card shadow="never"> + <template #header> + <CardTitle title="快捷入口" /> + </template> + <div class="flex flex-row flex-wrap gap-8 p-4"> + <div + v-for="menu in menuList" + :key="menu.name" + class="h-20 w-20% flex flex-col cursor-pointer items-center justify-center gap-2" + @click="handleMenuClick(menu.routerName)" + > + <div + :class="menu.bgColor" + class="h-48px w-48px flex items-center justify-center rounded text-white" + > + <Icon :icon="menu.icon" class="text-7.5!" /> + </div> + <span>{{ menu.name }}</span> + </div> + </div> + </el-card> +</template> +<script lang="ts" setup> +/** 快捷入口卡片 */ +import { CardTitle } from '@/components/Card' + +defineOptions({ name: 'ShortcutCard' }) + +const router = useRouter() // 路由 + +/** 菜单列表 */ +const menuList = [ + { name: '用户管理', icon: 'ep:user-filled', bgColor: 'bg-red-400', routerName: 'MemberUser' }, + { + name: '商品管理', + icon: 'fluent-mdl2:product', + bgColor: 'bg-orange-400', + routerName: 'ProductSpu' + }, + { name: '订单管理', icon: 'ep:list', bgColor: 'bg-yellow-500', routerName: 'TradeOrder' }, + { + name: '售后管理', + icon: 'ri:refund-2-line', + bgColor: 'bg-green-600', + routerName: 'TradeAfterSale' + }, + { + name: '分销管理', + icon: 'fa-solid:project-diagram', + bgColor: 'bg-cyan-500', + routerName: 'TradeBrokerageUser' + }, + { + name: '优惠券', + icon: 'ep:ticket', + bgColor: 'bg-blue-500', + routerName: 'PromotionCoupon' + }, + { + name: '拼团活动', + icon: 'fa:group', + bgColor: 'bg-purple-500', + routerName: 'PromotionBargainActivity' + }, + { + name: '佣金提现', + icon: 'vaadin:money-withdraw', + bgColor: 'bg-rose-500', + routerName: 'TradeBrokerageWithdraw' + } +] + +/** + * 跳转到菜单对应页面 + * + * @param routerName 路由页面组件的名称 + */ +const handleMenuClick = (routerName: string) => { + router.push({ name: routerName }) +} +</script> diff --git a/src/views/mall/home/components/TradeTrendCard.vue b/src/views/mall/home/components/TradeTrendCard.vue new file mode 100644 index 0000000..7930e21 --- /dev/null +++ b/src/views/mall/home/components/TradeTrendCard.vue @@ -0,0 +1,208 @@ +<template> + <el-card shadow="never"> + <template #header> + <div class="flex flex-row items-center justify-between"> + <CardTitle title="交易量趋势" /> + <!-- 查询条件 --> + <div class="flex flex-row items-center gap-2"> + <el-radio-group v-model="timeRangeType" @change="handleTimeRangeTypeChange"> + <el-radio-button v-for="[key, value] in timeRange.entries()" :key="key" :label="key"> + {{ value.name }} + </el-radio-button> + </el-radio-group> + </div> + </div> + </template> + <!-- 折线图 --> + <Echart :height="300" :options="eChartOptions" /> + </el-card> +</template> +<script lang="ts" setup> +import dayjs, { Dayjs } from 'dayjs' +import { EChartsOption } from 'echarts' +import * as TradeStatisticsApi from '@/api/mall/statistics/trade' +import { fenToYuan } from '@/utils' +import { formatDate } from '@/utils/formatTime' +import { CardTitle } from '@/components/Card' + +/** 交易量趋势 */ +defineOptions({ name: 'TradeTrendCard' }) + +enum TimeRangeTypeEnum { + DAY30 = 1, + WEEK = 7, + MONTH = 30, + YEAR = 365 +} // 日期类型 +const timeRangeType = ref(TimeRangeTypeEnum.DAY30) // 日期快捷选择按钮, 默认30天 +const loading = ref(true) // 加载中 +// 时间范围 Map +const timeRange = new Map() + .set(TimeRangeTypeEnum.DAY30, { + name: '30天', + series: [ + { name: '订单金额', type: 'bar', smooth: true, data: [] }, + { name: '订单数量', type: 'line', smooth: true, data: [] } + ] + }) + .set(TimeRangeTypeEnum.WEEK, { + name: '周', + series: [ + { name: '上周金额', type: 'bar', smooth: true, data: [] }, + { name: '本周金额', type: 'bar', smooth: true, data: [] }, + { name: '上周数量', type: 'line', smooth: true, data: [] }, + { name: '本周数量', type: 'line', smooth: true, data: [] } + ] + }) + .set(TimeRangeTypeEnum.MONTH, { + name: '月', + series: [ + { name: '上月金额', type: 'bar', smooth: true, data: [] }, + { name: '本月金额', type: 'bar', smooth: true, data: [] }, + { name: '上月数量', type: 'line', smooth: true, data: [] }, + { name: '本月数量', type: 'line', smooth: true, data: [] } + ] + }) + .set(TimeRangeTypeEnum.YEAR, { + name: '年', + series: [ + { name: '去年金额', type: 'bar', smooth: true, data: [] }, + { name: '今年金额', type: 'bar', smooth: true, data: [] }, + { name: '去年数量', type: 'line', smooth: true, data: [] }, + { name: '今年数量', type: 'line', smooth: true, data: [] } + ] + }) +/** 图表配置 */ +const eChartOptions = reactive<EChartsOption>({ + grid: { + left: 20, + right: 20, + bottom: 20, + top: 80, + containLabel: true + }, + legend: { + top: 50, + data: [] + }, + series: [], + toolbox: { + feature: { + // 数据区域缩放 + dataZoom: { + yAxisIndex: false // Y轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '订单量趋势' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross' + }, + padding: [5, 10] + }, + xAxis: { + type: 'category', + inverse: true, + boundaryGap: false, + axisTick: { + show: false + }, + data: [], + axisLabel: { + formatter: (date: string) => { + switch (timeRangeType.value) { + case TimeRangeTypeEnum.DAY30: + return formatDate(date, 'MM-DD') + case TimeRangeTypeEnum.WEEK: + let weekDay = formatDate(date, 'ddd') + if (weekDay == '0') weekDay = '日' + return '周' + weekDay + case TimeRangeTypeEnum.MONTH: + return formatDate(date, 'D') + case TimeRangeTypeEnum.YEAR: + return formatDate(date, 'M') + '月' + default: + return date + } + } + } + }, + yAxis: { + axisTick: { + show: false + } + } +}) as EChartsOption + +/** 时间范围类型单选按钮选中 */ +const handleTimeRangeTypeChange = async () => { + // 设置时间范围 + let beginTime: Dayjs + let endTime: Dayjs + switch (timeRangeType.value) { + case TimeRangeTypeEnum.WEEK: + beginTime = dayjs().startOf('week') + endTime = dayjs().endOf('week') + break + case TimeRangeTypeEnum.MONTH: + beginTime = dayjs().startOf('month') + endTime = dayjs().endOf('month') + break + case TimeRangeTypeEnum.YEAR: + beginTime = dayjs().startOf('year') + endTime = dayjs().endOf('year') + break + case TimeRangeTypeEnum.DAY30: + default: + beginTime = dayjs().subtract(30, 'day').startOf('d') + endTime = dayjs().endOf('d') + break + } + // 发送时间范围选中事件 + await getOrderCountTrendComparison(beginTime, endTime) +} + +/** 查询订单数量趋势对照数据 */ +const getOrderCountTrendComparison = async ( + beginTime: dayjs.ConfigType, + endTime: dayjs.ConfigType +) => { + loading.value = true + // 查询数据 + const list = await TradeStatisticsApi.getOrderCountTrendComparison( + timeRangeType.value, + beginTime, + endTime + ) + // 处理数据 + const dates: string[] = [] + const series = [...timeRange.get(timeRangeType.value).series] + for (let item of list) { + dates.push(item.value.date) + if (series.length === 2) { + series[0].data.push(fenToYuan(item?.value?.orderPayPrice || 0)) // 当前金额 + series[1].data.push(item?.value?.orderPayCount || 0) // 当前数量 + } else { + series[0].data.push(fenToYuan(item?.reference?.orderPayPrice || 0)) // 对照金额 + series[1].data.push(fenToYuan(item?.value?.orderPayPrice || 0)) // 当前金额 + series[2].data.push(item?.reference?.orderPayCount || 0) // 对照数量 + series[3].data.push(item?.value?.orderPayCount || 0) // 当前数量 + } + } + eChartOptions.xAxis!['data'] = dates + eChartOptions.series = series + // legend在4个切换到2个的时候,还是显示成4个,需要手动配置一下 + eChartOptions.legend['data'] = series.map((item) => item.name) + loading.value = false +} + +/** 初始化 **/ +onMounted(() => { + handleTimeRangeTypeChange() +}) +</script> diff --git a/src/views/mall/home/index.vue b/src/views/mall/home/index.vue new file mode 100644 index 0000000..89baf33 --- /dev/null +++ b/src/views/mall/home/index.vue @@ -0,0 +1,113 @@ +<template> + <doc-alert title="商城手册(功能开启)" url="https://doc.iocoder.cn/mall/build/" /> + + <div class="flex flex-col"> + <!-- 数据对照 --> + <el-row :gutter="16" class="row"> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <ComparisonCard + tag="今日" + title="销售额" + prefix="¥" + :decimals="2" + :value="fenToYuan(orderComparison?.value?.orderPayPrice || 0)" + :reference="fenToYuan(orderComparison?.reference?.orderPayPrice || 0)" + /> + </el-col> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <ComparisonCard + tag="今日" + title="用户访问量" + :value="userComparison?.value?.visitUserCount || 0" + :reference="userComparison?.reference?.visitUserCount || 0" + /> + </el-col> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <ComparisonCard + tag="今日" + title="订单量" + :value="orderComparison?.value?.orderPayCount || 0" + :reference="orderComparison?.reference?.orderPayCount || 0" + /> + </el-col> + <el-col :md="6" :sm="12" :xs="24" :loading="loading"> + <ComparisonCard + tag="今日" + title="新增用户" + :value="userComparison?.value?.registerUserCount || 0" + :reference="userComparison?.reference?.registerUserCount || 0" + /> + </el-col> + </el-row> + <el-row :gutter="16" class="row"> + <el-col :md="12"> + <!-- 快捷入口 --> + <ShortcutCard /> + </el-col> + <el-col :md="12"> + <!-- 运营数据 --> + <OperationDataCard /> + </el-col> + </el-row> + <el-row :gutter="16" class="mb-4"> + <el-col :md="18" :sm="24"> + <!-- 会员概览 --> + <MemberFunnelCard /> + </el-col> + <el-col :md="6" :sm="24"> + <!-- 会员终端 --> + <MemberTerminalCard /> + </el-col> + </el-row> + <!-- 交易量趋势 --> + <TradeTrendCard class="mb-4" /> + <!-- 会员统计 --> + <MemberStatisticsCard /> + </div> +</template> +<script lang="ts" setup> +import * as TradeStatisticsApi from '@/api/mall/statistics/trade' +import * as MemberStatisticsApi from '@/api/mall/statistics/member' +import { DataComparisonRespVO } from '@/api/mall/statistics/common' +import { TradeOrderSummaryRespVO } from '@/api/mall/statistics/trade' +import { MemberCountRespVO } from '@/api/mall/statistics/member' +import { fenToYuan } from '@/utils' +import ComparisonCard from './components/ComparisonCard.vue' +import MemberStatisticsCard from './components/MemberStatisticsCard.vue' +import OperationDataCard from './components/OperationDataCard.vue' +import ShortcutCard from './components/ShortcutCard.vue' +import TradeTrendCard from './components/TradeTrendCard.vue' +import MemberTerminalCard from '@/views/mall/statistics/member/components/MemberTerminalCard.vue' +import MemberFunnelCard from '@/views/mall/statistics/member/components/MemberFunnelCard.vue' + +/** 商城首页 */ +defineOptions({ name: 'MallHome' }) + +const loading = ref(true) // 加载中 +const orderComparison = ref<DataComparisonRespVO<TradeOrderSummaryRespVO>>() // 交易对照数据 +const userComparison = ref<DataComparisonRespVO<MemberCountRespVO>>() // 用户对照数据 + +/** 查询交易对照卡片数据 */ +const getOrderComparison = async () => { + orderComparison.value = await TradeStatisticsApi.getOrderComparison() +} + +/** 查询会员用户数量对照卡片数据 */ +const getUserCountComparison = async () => { + userComparison.value = await MemberStatisticsApi.getUserCountComparison() +} + +/** 初始化 **/ +onMounted(async () => { + loading.value = true + await Promise.all([getOrderComparison(), getUserCountComparison()]) + loading.value = false +}) +</script> +<style lang="scss" scoped> +.row { + .el-col { + margin-bottom: 1rem; + } +} +</style> diff --git a/src/views/mall/product/brand/BrandForm.vue b/src/views/mall/product/brand/BrandForm.vue new file mode 100644 index 0000000..ab34737 --- /dev/null +++ b/src/views/mall/product/brand/BrandForm.vue @@ -0,0 +1,123 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="80px" + v-loading="formLoading" + > + <el-form-item label="品牌名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入品牌名称" /> + </el-form-item> + <el-form-item label="品牌图片" prop="picUrl"> + <UploadImg v-model="formData.picUrl" :limit="1" :is-show-tip="false" /> + </el-form-item> + <el-form-item label="品牌排序" prop="sort"> + <el-input-number v-model="formData.sort" controls-position="right" :min="0" /> + </el-form-item> + <el-form-item label="品牌状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="品牌描述"> + <el-input v-model="formData.description" type="textarea" placeholder="请输入品牌描述" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import * as ProductBrandApi from '@/api/mall/product/brand' + +defineOptions({ name: 'ProductBrandForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + picUrl: '', + status: CommonStatusEnum.ENABLE, + description: '' +}) +const formRules = reactive({ + name: [{ required: true, message: '品牌名称不能为空', trigger: 'blur' }], + picUrl: [{ required: true, message: '品牌图片不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '品牌排序不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ProductBrandApi.getBrand(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as ProductBrandApi.BrandVO + if (formType.value === 'create') { + await ProductBrandApi.createBrand(data) + message.success(t('common.createSuccess')) + } else { + await ProductBrandApi.updateBrand(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + picUrl: '', + status: CommonStatusEnum.ENABLE, + description: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/product/brand/index.vue b/src/views/mall/product/brand/index.vue new file mode 100644 index 0000000..3e34b93 --- /dev/null +++ b/src/views/mall/product/brand/index.vue @@ -0,0 +1,182 @@ +<template> + <doc-alert title="商城手册(功能开启)" url="https://doc.iocoder.cn/mall/build/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="品牌名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入品牌名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['product:brand:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" row-key="id" default-expand-all> + <el-table-column label="品牌名称" prop="name" sortable /> + <el-table-column label="品牌图片" align="center" prop="picUrl"> + <template #default="scope"> + <img v-if="scope.row.picUrl" :src="scope.row.picUrl" alt="品牌图片" class="h-30px" /> + </template> + </el-table-column> + <el-table-column label="品牌排序" align="center" prop="sort" /> + <el-table-column label="开启状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['product:brand:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['product:brand:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <BrandForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as ProductBrandApi from '@/api/mall/product/brand' +import BrandForm from './BrandForm.vue' + +defineOptions({ name: 'ProductBrand' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref<any[]>([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductBrandApi.getBrandParam(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ProductBrandApi.deleteBrand(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/product/category/CategoryForm.vue b/src/views/mall/product/category/CategoryForm.vue new file mode 100644 index 0000000..7f20927 --- /dev/null +++ b/src/views/mall/product/category/CategoryForm.vue @@ -0,0 +1,135 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label="上级分类" prop="parentId"> + <el-select v-model="formData.parentId" placeholder="请选择上级分类"> + <el-option :key="0" label="顶级分类" :value="0" /> + <el-option + v-for="item in categoryList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="分类名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入分类名称" /> + </el-form-item> + <el-form-item label="移动端分类图" prop="picUrl"> + <UploadImg v-model="formData.picUrl" :limit="1" :is-show-tip="false" /> + <div style="font-size: 10px" class="pl-10px">推荐 180x180 图片分辨率</div> + </el-form-item> + <el-form-item label="分类排序" prop="sort"> + <el-input-number v-model="formData.sort" controls-position="right" :min="0" /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import * as ProductCategoryApi from '@/api/mall/product/category' + +defineOptions({ name: 'ProductCategory' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + picUrl: '', + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive({ + parentId: [{ required: true, message: '请选择上级分类', trigger: 'blur' }], + name: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }], + picUrl: [{ required: true, message: '分类图片不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '分类排序不能为空', trigger: 'blur' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const categoryList = ref<any[]>([]) // 分类树 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ProductCategoryApi.getCategory(id) + } finally { + formLoading.value = false + } + } + // 获得分类树 + categoryList.value = await ProductCategoryApi.getCategoryList({ parentId: 0 }) +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as ProductCategoryApi.CategoryVO + if (formType.value === 'create') { + await ProductCategoryApi.createCategory(data) + message.success(t('common.createSuccess')) + } else { + await ProductCategoryApi.updateCategory(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + picUrl: '', + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/product/category/components/ProductCategorySelect.vue b/src/views/mall/product/category/components/ProductCategorySelect.vue new file mode 100644 index 0000000..c1810f5 --- /dev/null +++ b/src/views/mall/product/category/components/ProductCategorySelect.vue @@ -0,0 +1,51 @@ +<template> + <el-tree-select + v-model="selectCategoryId" + :data="categoryList" + :props="defaultProps" + :multiple="multiple" + :show-checkbox="multiple" + class="w-1/1" + node-key="id" + placeholder="请选择商品分类" + /> +</template> +<script lang="ts" setup> +import { defaultProps, handleTree } from '@/utils/tree' +import * as ProductCategoryApi from '@/api/mall/product/category' +import { oneOfType } from 'vue-types' +import { propTypes } from '@/utils/propTypes' + +/** 商品分类选择组件 */ +defineOptions({ name: 'ProductCategorySelect' }) + +const props = defineProps({ + // 选中的ID + modelValue: oneOfType<number | number[]>([Number, Array<Number>]), + // 是否多选 + multiple: propTypes.bool.def(false), + // 上级品类的编号 + parentId: propTypes.number.def(undefined) +}) + +/** 选中的分类 ID */ +const selectCategoryId = computed({ + get: () => { + return props.modelValue + }, + set: (val: number | number[]) => { + emit('update:modelValue', val) + } +}) + +/** 分类选择 */ +const emit = defineEmits(['update:modelValue']) + +/** 初始化 **/ +const categoryList = ref<ProductCategoryApi.CategoryVO[]>([]) // 分类树 +onMounted(async () => { + // 获得分类树 + const data = await ProductCategoryApi.getCategoryList({ parentId: props.parentId }) + categoryList.value = handleTree(data, 'id', 'parentId') +}) +</script> diff --git a/src/views/mall/product/category/index.vue b/src/views/mall/product/category/index.vue new file mode 100644 index 0000000..a47684b --- /dev/null +++ b/src/views/mall/product/category/index.vue @@ -0,0 +1,167 @@ +<template> + <doc-alert title="【商品】商品分类" url="https://doc.iocoder.cn/mall/product-category/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="分类名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入分类名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['product:category:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" row-key="id" default-expand-all> + <el-table-column label="名称" min-width="240" prop="name" sortable /> + <el-table-column label="分类图标" align="center" min-width="80" prop="picUrl"> + <template #default="scope"> + <img :src="scope.row.picUrl" alt="移动端分类图" class="h-36px" /> + </template> + </el-table-column> + <el-table-column label="排序" align="center" min-width="150" prop="sort" /> + <el-table-column label="状态" align="center" min-width="150" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center" min-width="180"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['product:category:update']" + > + 编辑 + </el-button> + <el-button + link + type="primary" + v-if="scope.row.parentId > 0" + @click="handleViewSpu(scope.row.id)" + v-hasPermi="['product:spu:query']" + > + 查看商品 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['product:category:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <CategoryForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { handleTree } from '@/utils/tree' +import { dateFormatter } from '@/utils/formatTime' +import * as ProductCategoryApi from '@/api/mall/product/category' +import CategoryForm from './CategoryForm.vue' + +defineOptions({ name: 'ProductCategory' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<any[]>([]) // 列表的数据 +const queryParams = reactive({ + name: undefined +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductCategoryApi.getCategoryList(queryParams) + list.value = handleTree(data, 'id', 'parentId') + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ProductCategoryApi.deleteCategory(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 查看商品操作 */ +const router = useRouter() // 路由 +const handleViewSpu = (id: number) => { + router.push({ + name: 'ProductSpu', + query: { categoryId: id } + }) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/product/comment/CommentForm.vue b/src/views/mall/product/comment/CommentForm.vue new file mode 100644 index 0000000..b8d700c --- /dev/null +++ b/src/views/mall/product/comment/CommentForm.vue @@ -0,0 +1,167 @@ +<template> + <Dialog v-model="dialogVisible" title="添加虚拟评论"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item label="商品" prop="spuId"> + <SpuShowcase v-model="formData.spuId" :limit="1" /> + </el-form-item> + <el-form-item v-if="formData.spuId" label="商品规格" prop="skuId"> + <div class="h-60px w-60px" @click="handleSelectSku"> + <div v-if="skuData && skuData.picUrl"> + <el-image :src="skuData.picUrl" /> + </div> + <div v-else class="select-box"> + <Icon icon="ep:plus" /> + </div> + </div> + </el-form-item> + <el-form-item label="用户头像" prop="userAvatar"> + <UploadImg v-model="formData.userAvatar" height="60px" width="60px" /> + </el-form-item> + <el-form-item label="用户名称" prop="userNickname"> + <el-input v-model="formData.userNickname" placeholder="请输入用户名称" /> + </el-form-item> + <el-form-item label="评论内容" prop="content"> + <el-input v-model="formData.content" type="textarea" /> + </el-form-item> + <el-form-item label="描述星级" prop="descriptionScores"> + <el-rate v-model="formData.descriptionScores" /> + </el-form-item> + <el-form-item label="服务星级" prop="benefitScores"> + <el-rate v-model="formData.benefitScores" /> + </el-form-item> + <el-form-item label="评论图片" prop="picUrls"> + <UploadImgs v-model="formData.picUrls" :limit="9" height="60px" width="60px" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <SkuTableSelect ref="skuTableSelectRef" :spu-id="formData.spuId" @change="handleSkuChange" /> +</template> +<script lang="ts" setup> +import * as CommentApi from '@/api/mall/product/comment' +import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue' +import * as ProductSpuApi from '@/api/mall/product/spu' +import SkuTableSelect from '@/views/mall/product/spu/components/SkuTableSelect.vue' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + userId: undefined, + userNickname: undefined, + userAvatar: undefined, + spuId: 0, + skuId: undefined, + descriptionScores: 5, + benefitScores: 5, + content: undefined, + picUrls: [] +}) +const formRules = reactive({ + spuId: [{ required: true, message: '商品不能为空', trigger: 'blur' }], + skuId: [{ required: true, message: '规格不能为空', trigger: 'blur' }], + userAvatar: [{ required: true, message: '用户头像不能为空', trigger: 'blur' }], + userNickname: [{ required: true, message: '用户名称不能为空', trigger: 'blur' }], + content: [{ required: true, message: '评论内容不能为空', trigger: 'blur' }], + descriptionScores: [{ required: true, message: '描述星级不能为空', trigger: 'blur' }], + benefitScores: [{ required: true, message: '服务星级不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const skuData = ref({ + id: -1, + name: '', + picUrl: '' +}) + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await CommentApi.getComment(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + if (formType.value === 'create') { + await CommentApi.createComment(unref(formData.value) as any) + message.success(t('common.createSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + userId: undefined, + userNickname: undefined, + userAvatar: undefined, + spuId: 0, + skuId: undefined, + descriptionScores: 5, + benefitScores: 5, + content: undefined, + picUrls: [] + } + formRef.value?.resetFields() +} + +/** SKU 表格选择 */ +const skuTableSelectRef = ref() +const handleSelectSku = () => { + skuTableSelectRef.value.open() +} +const handleSkuChange = (sku: ProductSpuApi.Sku) => { + skuData.value = sku + formData.value.skuId = sku.id +} +</script> +<style> +.select-box { + display: flex; + width: 100%; + height: 100%; + border: 1px dashed var(--el-border-color-darker); + border-radius: 8px; + align-items: center; + justify-content: center; +} +</style> diff --git a/src/views/mall/product/comment/ReplyForm.vue b/src/views/mall/product/comment/ReplyForm.vue new file mode 100644 index 0000000..4c8bd4d --- /dev/null +++ b/src/views/mall/product/comment/ReplyForm.vue @@ -0,0 +1,76 @@ +<template> + <Dialog title="回复" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="回复内容" prop="replyContent"> + <el-input type="textarea" v-model="formData.replyContent" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitReplyForm" type="primary" :disabled="formLoading">确 定 </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> + +<script setup lang="ts"> +import * as CommentApi from '@/api/mall/product/comment' +import { ElInput } from 'element-plus' + +defineOptions({ name: 'ProductComment' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, + replyContent: undefined +}) +const formRules = reactive({ + replyContent: [{ required: true, message: '回复内容不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (id?: number) => { + resetForm() + formData.value.id = id + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitReplyForm = async () => { + // 校验表单 + const valid = await formRef?.value?.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await CommentApi.replyComment(formData.value) + message.success(t('common.createSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + replyContent: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/product/comment/index.vue b/src/views/mall/product/comment/index.vue new file mode 100644 index 0000000..8854fdb --- /dev/null +++ b/src/views/mall/product/comment/index.vue @@ -0,0 +1,244 @@ +<template> + <doc-alert title="【商品】商品评价" url="https://doc.iocoder.cn/mall/product-comment/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="回复状态" prop="replyStatus"> + <el-select v-model="queryParams.replyStatus"> + <el-option label="已回复" :value="true" /> + <el-option label="未回复" :value="false" /> + </el-select> + </el-form-item> + <el-form-item label="商品名称" prop="spuName"> + <el-input v-model="queryParams.spuName" placeholder="请输入商品名称" /> + </el-form-item> + <el-form-item label="用户名称" prop="userNickname"> + <el-input v-model="queryParams.userNickname" placeholder="请输入用户名称" /> + </el-form-item> + <el-form-item label="订单编号" prop="orderId"> + <el-input v-model="queryParams.orderId" placeholder="请输入订单编号" /> + </el-form-item> + <el-form-item label="评论时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon icon="ep:search" class="mr-5px" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon icon="ep:refresh" class="mr-5px" /> + 重置 + </el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['product:comment:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> + 添加虚拟评论 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="false"> + <el-table-column label="评论编号" align="center" prop="id" min-width="80" /> + <el-table-column label="商品信息" align="center" min-width="400"> + <template #default="scope"> + <div class="row flex items-center gap-x-4px"> + <el-image + v-if="scope.row.skuPicUrl" + :src="scope.row.skuPicUrl" + :preview-src-list="[scope.row.skuPicUrl]" + class="h-40px w-40px shrink-0" + preview-teleported + /> + <div>{{ scope.row.spuName }}</div> + <el-tag + v-for="property in scope.row.skuProperties" + :key="property.propertyId" + class="mr-10px" + > + {{ property.propertyName }}: {{ property.valueName }} + </el-tag> + </div> + </template> + </el-table-column> + <el-table-column label="用户名称" align="center" prop="userNickname" width="100" /> + <el-table-column label="商品评分" align="center" prop="descriptionScores" width="90" /> + <el-table-column label="服务评分" align="center" prop="benefitScores" width="90" /> + <el-table-column label="评论内容" align="center" prop="content" min-width="210"> + <template #default="scope"> + <p>{{ scope.row.content }}</p> + <div class="flex justify-center gap-x-4px"> + <el-image + v-for="(picUrl, index) in scope.row.picUrls" + :key="index" + :src="picUrl" + :preview-src-list="scope.row.picUrls" + :initial-index="index" + class="h-40px w-40px" + preview-teleported + /> + </div> + </template> + </el-table-column> + <el-table-column + label="回复内容" + align="center" + prop="replyContent" + min-width="250" + show-overflow-tooltip + /> + <el-table-column + label="评论时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column label="是否展示" align="center" width="80px"> + <template #default="scope"> + <el-switch + v-model="scope.row.visible" + :active-value="true" + :inactive-value="false" + v-hasPermi="['product:comment:update']" + @change="handleVisibleChange(scope.row)" + /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" min-width="60px" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="handleReply(scope.row.id)" + v-hasPermi="['product:comment:update']" + > + 回复 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <CommentForm ref="formRef" @success="getList" /> + <!-- 回复表单弹窗 --> + <ReplyForm ref="replyFormRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as CommentApi from '@/api/mall/product/comment' +import CommentForm from './CommentForm.vue' +import ReplyForm from './ReplyForm.vue' + +defineOptions({ name: 'ProductComment' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + replyStatus: null, + spuName: null, + userNickname: null, + orderId: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CommentApi.getCommentPage(queryParams) + // visible 如果为 null,会导致刷新的时候触发 e-switch 的 change 事件 + data.list.forEach((item) => { + if (!item.visible) { + item.visible = false + } + }) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 回复按钮操作 **/ +const replyFormRef = ref() +const handleReply = (id: number) => { + replyFormRef.value.open(id) +} + +/** 显示/隐藏 **/ +const handleVisibleChange = async (row: CommentApi.CommentVO) => { + if (loading.value) { + return + } + let changedValue = row.visible + try { + await message.confirm(changedValue ? '是否显示评论?' : '是否隐藏评论?') + await CommentApi.updateCommentVisible({ id: row.id, visible: changedValue }) + await getList() + } catch { + row.visible = !changedValue + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/product/property/PropertyForm.vue b/src/views/mall/product/property/PropertyForm.vue new file mode 100644 index 0000000..db90beb --- /dev/null +++ b/src/views/mall/product/property/PropertyForm.vue @@ -0,0 +1,96 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as PropertyApi from '@/api/mall/product/property' + +defineOptions({ name: 'ProductPropertyForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '' +}) +const formRules = reactive({ + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await PropertyApi.getProperty(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as PropertyApi.PropertyVO + if (formType.value === 'create') { + await PropertyApi.createProperty(data) + message.success(t('common.createSuccess')) + } else { + await PropertyApi.updateProperty(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/product/property/index.vue b/src/views/mall/product/property/index.vue new file mode 100644 index 0000000..ac3401a --- /dev/null +++ b/src/views/mall/product/property/index.vue @@ -0,0 +1,177 @@ +<template> + <doc-alert title="【商品】商品属性" url="https://doc.iocoder.cn/mall/product-property/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['product:property:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="编号" min-width="60" prop="id" /> + <el-table-column align="center" label="属性名称" prop="name" min-width="150" /> + <el-table-column :show-overflow-tooltip="true" align="center" label="备注" prop="remark" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['product:property:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button link type="primary" @click="goValueList(scope.row.id)">属性值</el-button> + <el-button + v-hasPermi="['product:property:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <PropertyForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as PropertyApi from '@/api/mall/product/property' +import PropertyForm from './PropertyForm.vue' + +const { push } = useRouter() + +defineOptions({ name: 'ProductProperty' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PropertyApi.getPropertyPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await PropertyApi.deleteProperty(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 跳转商品属性列表 */ +const goValueList = (id: number) => { + push({ name: 'ProductPropertyValue', params: { propertyId: id } }) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/product/property/value/ValueForm.vue b/src/views/mall/product/property/value/ValueForm.vue new file mode 100644 index 0000000..9e72c09 --- /dev/null +++ b/src/views/mall/product/property/value/ValueForm.vue @@ -0,0 +1,105 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="属性编号" prop="category"> + <el-input v-model="formData.propertyId" disabled="" /> + </el-form-item> + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as PropertyApi from '@/api/mall/product/property' + +defineOptions({ name: 'ProductPropertyValueForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + propertyId: undefined, + name: '', + remark: '' +}) +const formRules = reactive({ + propertyId: [{ required: true, message: '属性不能为空', trigger: 'blur' }], + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, propertyId: number, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + formData.value.propertyId = propertyId + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await PropertyApi.getPropertyValue(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as PropertyApi.PropertyValueVO + if (formType.value === 'create') { + await PropertyApi.createPropertyValue(data) + message.success(t('common.createSuccess')) + } else { + await PropertyApi.updatePropertyValue(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + propertyId: undefined, + name: '', + remark: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/product/property/value/index.vue b/src/views/mall/product/property/value/index.vue new file mode 100644 index 0000000..d708172 --- /dev/null +++ b/src/views/mall/product/property/value/index.vue @@ -0,0 +1,163 @@ +<template> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="属性项" prop="propertyId"> + <el-select v-model="queryParams.propertyId" class="!w-240px" disabled> + <el-option + v-for="item in propertyOptions" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + plain + type="primary" + @click="openForm('create')" + v-hasPermi="['product:property:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" min-width="60" prop="id" /> + <el-table-column label="属性值名称" align="center" min-width="150" prop="name" /> + <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['product:property:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['product:property:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ValueForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as PropertyApi from '@/api/mall/product/property' +import ValueForm from './ValueForm.vue' + +defineOptions({ name: 'ProductPropertyValue' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const { params } = useRoute() // 查询参数 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + propertyId: Number(params.propertyId), + name: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const propertyOptions = ref([]) // 属性项的列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PropertyApi.getPropertyValuePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, queryParams.propertyId, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await PropertyApi.deletePropertyValue(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 属性项下拉框数据 + propertyOptions.value.push(await PropertyApi.getProperty(queryParams.propertyId)) +}) +</script> diff --git a/src/views/mall/product/spu/components/SkuList.vue b/src/views/mall/product/spu/components/SkuList.vue new file mode 100644 index 0000000..2b881a4 --- /dev/null +++ b/src/views/mall/product/spu/components/SkuList.vue @@ -0,0 +1,576 @@ +<template> + <!-- 情况一:添加/修改 --> + <el-table + v-if="!isDetail && !isActivityComponent" + :data="isBatch ? skuList : formData!.skus!" + border + class="tabNumWidth" + max-height="500" + size="small" + > + <el-table-column align="center" label="图片" min-width="65"> + <template #default="{ row }"> + <UploadImg v-model="row.picUrl" height="50px" width="50px" /> + </template> + </el-table-column> + <template v-if="formData!.specType && !isBatch"> + <!-- 根据商品属性动态添加 --> + <el-table-column + v-for="(item, index) in tableHeaders" + :key="index" + :label="item.label" + align="center" + min-width="120" + > + <template #default="{ row }"> + <span style="font-weight: bold; color: #40aaff"> + {{ row.properties?.[index]?.valueName }} + </span> + </template> + </el-table-column> + </template> + <el-table-column align="center" label="商品条码" min-width="168"> + <template #default="{ row }"> + <el-input v-model="row.barCode" class="w-100%" /> + </template> + </el-table-column> + <el-table-column align="center" label="销售价" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.price" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + <el-table-column align="center" label="市场价" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.marketPrice" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + <el-table-column align="center" label="成本价" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.costPrice" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + <el-table-column align="center" label="库存" min-width="168"> + <template #default="{ row }"> + <el-input-number v-model="row.stock" :min="0" class="w-100%" controls-position="right" /> + </template> + </el-table-column> + <el-table-column align="center" label="重量(kg)" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.weight" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + <el-table-column align="center" label="体积(m^3)" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.volume" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + <template v-if="formData!.subCommissionType"> + <el-table-column align="center" label="一级返佣(元)" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.firstBrokeragePrice" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + <el-table-column align="center" label="二级返佣(元)" min-width="168"> + <template #default="{ row }"> + <el-input-number + v-model="row.secondBrokeragePrice" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + controls-position="right" + /> + </template> + </el-table-column> + </template> + <el-table-column v-if="formData?.specType" align="center" fixed="right" label="操作" width="80"> + <template #default="{ row }"> + <el-button v-if="isBatch" link size="small" type="primary" @click="batchAdd"> + 批量添加 + </el-button> + <el-button v-else link size="small" type="primary" @click="deleteSku(row)">删除</el-button> + </template> + </el-table-column> + </el-table> + + <!-- 情况二:详情 --> + <el-table + v-if="isDetail" + ref="activitySkuListRef" + :data="formData!.skus!" + border + max-height="500" + size="small" + style="width: 99%" + @selection-change="handleSelectionChange" + > + <el-table-column v-if="isComponent" type="selection" width="45" /> + <el-table-column align="center" label="图片" min-width="80"> + <template #default="{ row }"> + <el-image + v-if="row.picUrl" + :src="row.picUrl" + class="h-50px w-50px" + @click="imagePreview(row.picUrl)" + /> + </template> + </el-table-column> + <template v-if="formData!.specType && !isBatch"> + <!-- 根据商品属性动态添加 --> + <el-table-column + v-for="(item, index) in tableHeaders" + :key="index" + :label="item.label" + align="center" + min-width="80" + > + <template #default="{ row }"> + <span style="font-weight: bold; color: #40aaff"> + {{ row.properties?.[index]?.valueName }} + </span> + </template> + </el-table-column> + </template> + <el-table-column align="center" label="商品条码" min-width="100"> + <template #default="{ row }"> + {{ row.barCode }} + </template> + </el-table-column> + <el-table-column align="center" label="销售价(元)" min-width="80"> + <template #default="{ row }"> + {{ row.price }} + </template> + </el-table-column> + <el-table-column align="center" label="市场价(元)" min-width="80"> + <template #default="{ row }"> + {{ row.marketPrice }} + </template> + </el-table-column> + <el-table-column align="center" label="成本价(元)" min-width="80"> + <template #default="{ row }"> + {{ row.costPrice }} + </template> + </el-table-column> + <el-table-column align="center" label="库存" min-width="80"> + <template #default="{ row }"> + {{ row.stock }} + </template> + </el-table-column> + <el-table-column align="center" label="重量(kg)" min-width="80"> + <template #default="{ row }"> + {{ row.weight }} + </template> + </el-table-column> + <el-table-column align="center" label="体积(m^3)" min-width="80"> + <template #default="{ row }"> + {{ row.volume }} + </template> + </el-table-column> + <template v-if="formData!.subCommissionType"> + <el-table-column align="center" label="一级返佣(元)" min-width="80"> + <template #default="{ row }"> + {{ row.firstBrokeragePrice }} + </template> + </el-table-column> + <el-table-column align="center" label="二级返佣(元)" min-width="80"> + <template #default="{ row }"> + {{ row.secondBrokeragePrice }} + </template> + </el-table-column> + </template> + </el-table> + + <!-- 情况三:作为活动组件 --> + <el-table + v-if="isActivityComponent" + :data="formData!.skus!" + border + max-height="500" + size="small" + style="width: 99%" + > + <el-table-column v-if="isComponent" type="selection" width="45" /> + <el-table-column align="center" label="图片" min-width="80"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="h-60px w-60px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <template v-if="formData!.specType"> + <!-- 根据商品属性动态添加 --> + <el-table-column + v-for="(item, index) in tableHeaders" + :key="index" + :label="item.label" + align="center" + min-width="80" + > + <template #default="{ row }"> + <span style="font-weight: bold; color: #40aaff"> + {{ row.properties?.[index]?.valueName }} + </span> + </template> + </el-table-column> + </template> + <el-table-column align="center" label="商品条码" min-width="100"> + <template #default="{ row }"> + {{ row.barCode }} + </template> + </el-table-column> + <el-table-column align="center" label="销售价(元)" min-width="80"> + <template #default="{ row }"> + {{ row.price }} + </template> + </el-table-column> + <el-table-column align="center" label="市场价(元)" min-width="80"> + <template #default="{ row }"> + {{ row.marketPrice }} + </template> + </el-table-column> + <el-table-column align="center" label="成本价(元)" min-width="80"> + <template #default="{ row }"> + {{ row.costPrice }} + </template> + </el-table-column> + <el-table-column align="center" label="库存" min-width="80"> + <template #default="{ row }"> + {{ row.stock }} + </template> + </el-table-column> + <!-- 方便扩展每个活动配置的属性不一样 --> + <slot name="extension"></slot> + </el-table> +</template> +<script lang="ts" setup> +import { PropType, Ref } from 'vue' +import { copyValueToTarget } from '@/utils' +import { propTypes } from '@/utils/propTypes' +import { UploadImg } from '@/components/UploadFile' +import type { Property, Sku, Spu } from '@/api/mall/product/spu' +import { createImageViewer } from '@/components/ImageViewer' +import { RuleConfig } from '@/views/mall/product/spu/components/index' +import { PropertyAndValues } from './index' +import { ElTable } from 'element-plus' +import { isEmpty } from '@/utils/is' + +defineOptions({ name: 'SkuList' }) +const message = useMessage() // 消息弹窗 + +const props = defineProps({ + propFormData: { + type: Object as PropType<Spu>, + default: () => {} + }, + propertyList: { + type: Array as PropType<PropertyAndValues[]>, + default: () => [] + }, + ruleConfig: { + type: Array as PropType<RuleConfig[]>, + default: () => [] + }, + isBatch: propTypes.bool.def(false), // 是否作为批量操作组件 + isDetail: propTypes.bool.def(false), // 是否作为 sku 详情组件 + isComponent: propTypes.bool.def(false), // 是否作为 sku 选择组件 + isActivityComponent: propTypes.bool.def(false) // 是否作为 sku 活动配置组件 +}) +const formData: Ref<Spu | undefined> = ref<Spu>() // 表单数据 +const skuList = ref<Sku[]>([ + { + price: 0, // 商品价格 + marketPrice: 0, // 市场价 + costPrice: 0, // 成本价 + barCode: '', // 商品条码 + picUrl: '', // 图片地址 + stock: 0, // 库存 + weight: 0, // 商品重量 + volume: 0, // 商品体积 + firstBrokeragePrice: 0, // 一级分销的佣金 + secondBrokeragePrice: 0 // 二级分销的佣金 + } +]) // 批量添加时的临时数据 + +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + zIndex: 9999999, + urlList: [imgUrl] + }) +} + +/** 批量添加 */ +const batchAdd = () => { + validateProperty() + formData.value!.skus!.forEach((item) => { + copyValueToTarget(item, skuList.value[0]) + }) +} +/** 校验商品属性属性值 */ +const validateProperty = () => { + // 校验商品属性属性值是否为空,有一个为空都不给过 + const warningInfo = '存在属性属性值为空,请先检查完善属性值后重试!!!' + for (const item of props.propertyList) { + if (!item.values || isEmpty(item.values)) { + message.warning(warningInfo) + throw new Error(warningInfo) + } + } +} +/** 删除 sku */ +const deleteSku = (row) => { + const index = formData.value!.skus!.findIndex( + // 直接把列表转成字符串比较 + (sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties) + ) + formData.value!.skus!.splice(index, 1) +} +const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表头 +/** + * 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。 + */ +const validateSku = () => { + validateProperty() + let warningInfo = '请检查商品各行相关属性配置,' + let validate = true // 默认通过 + for (const sku of formData.value!.skus!) { + // 作为活动组件的校验 + for (const rule of props?.ruleConfig) { + const arg = getValue(sku, rule.name) + if (!rule.rule(arg)) { + validate = false // 只要有一个不通过则直接不通过 + warningInfo += rule.message + break + } + } + // 只要有一个不通过则结束后续的校验 + if (!validate) { + message.warning(warningInfo) + throw new Error(warningInfo) + } + } +} +const getValue = (obj, arg) => { + const keys = arg.split('.') + let value = obj + for (const key of keys) { + if (value && typeof value === 'object' && key in value) { + value = value[key] + } else { + value = undefined + break + } + } + return value +} + +const emit = defineEmits<{ + (e: 'selectionChange', value: Sku[]): void +}>() +/** + * 选择时触发 + * @param Sku 传递过来的选中的 sku 是一个数组 + */ +const handleSelectionChange = (val: Sku[]) => { + emit('selectionChange', val) +} + +/** + * 将传进来的值赋值给 skuList + */ +watch( + () => props.propFormData, + (data) => { + if (!data) return + formData.value = data + }, + { + deep: true, + immediate: true + } +) + +/** 生成表数据 */ +const generateTableData = (propertyList: any[]) => { + // 构建数据结构 + const propertyValues = propertyList.map((item) => + item.values.map((v: any) => ({ + propertyId: item.id, + propertyName: item.name, + valueId: v.id, + valueName: v.name + })) + ) + const buildSkuList = build(propertyValues) + // 如果回显的 sku 属性和添加的属性不一致则重置 skus 列表 + if (!validateData(propertyList)) { + // 如果不一致则重置表数据,默认添加新的属性重新生成 sku 列表 + formData.value!.skus = [] + } + for (const item of buildSkuList) { + const row = { + properties: Array.isArray(item) ? item : [item], // 如果只有一个属性的话返回的是一个 property 对象 + price: 0, + marketPrice: 0, + costPrice: 0, + barCode: '', + picUrl: '', + stock: 0, + weight: 0, + volume: 0, + firstBrokeragePrice: 0, + secondBrokeragePrice: 0 + } + // 如果存在属性相同的 sku 则不做处理 + const index = formData.value!.skus!.findIndex( + (sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties) + ) + if (index !== -1) { + continue + } + formData.value!.skus!.push(row) + } +} + +/** + * 生成 skus 前置校验 + */ +const validateData = (propertyList: any[]) => { + const skuPropertyIds: number[] = [] + formData.value!.skus!.forEach((sku) => + sku.properties + ?.map((property) => property.propertyId) + ?.forEach((propertyId) => { + if (skuPropertyIds.indexOf(propertyId!) === -1) { + skuPropertyIds.push(propertyId!) + } + }) + ) + const propertyIds = propertyList.map((item) => item.id) + return skuPropertyIds.length === propertyIds.length +} + +/** 构建所有排列组合 */ +const build = (propertyValuesList: Property[][]) => { + if (propertyValuesList.length === 0) { + return [] + } else if (propertyValuesList.length === 1) { + return propertyValuesList[0] + } else { + const result: Property[][] = [] + const rest = build(propertyValuesList.slice(1)) + for (let i = 0; i < propertyValuesList[0].length; i++) { + for (let j = 0; j < rest.length; j++) { + // 第一次不是数组结构,后面的都是数组结构 + if (Array.isArray(rest[j])) { + result.push([propertyValuesList[0][i], ...rest[j]]) + } else { + result.push([propertyValuesList[0][i], rest[j]]) + } + } + } + return result + } +} + +/** 监听属性列表,生成相关参数和表头 */ +watch( + () => props.propertyList, + (propertyList: PropertyAndValues[]) => { + // 如果不是多规格则结束 + if (!formData.value!.specType) { + return + } + // 如果当前组件作为批量添加数据使用,则重置表数据 + if (props.isBatch) { + skuList.value = [ + { + price: 0, + marketPrice: 0, + costPrice: 0, + barCode: '', + picUrl: '', + stock: 0, + weight: 0, + volume: 0, + firstBrokeragePrice: 0, + secondBrokeragePrice: 0 + } + ] + } + + // 判断代理对象是否为空 + if (JSON.stringify(propertyList) === '[]') { + return + } + // 重置表头 + tableHeaders.value = [] + // 生成表头 + propertyList.forEach((item, index) => { + // name加属性项index区分属性值 + tableHeaders.value.push({ prop: `name${index}`, label: item.name }) + }) + // 如果回显的 sku 属性和添加的属性一致则不处理 + if (validateData(propertyList)) { + return + } + // 添加新属性没有属性值也不做处理 + if (propertyList.some((item) => !item.values || isEmpty(item.values))) { + return + } + // 生成 table 数据,即 sku 列表 + generateTableData(propertyList) + }, + { + deep: true, + immediate: true + } +) +const activitySkuListRef = ref<InstanceType<typeof ElTable>>() + +const getSkuTableRef = () => { + return activitySkuListRef.value +} +// 暴露出生成 sku 方法,给添加属性成功时调用 +defineExpose({ generateTableData, validateSku, getSkuTableRef }) +</script> diff --git a/src/views/mall/product/spu/components/SkuTableSelect.vue b/src/views/mall/product/spu/components/SkuTableSelect.vue new file mode 100644 index 0000000..13d6ad1 --- /dev/null +++ b/src/views/mall/product/spu/components/SkuTableSelect.vue @@ -0,0 +1,95 @@ +<template> + <Dialog v-model="dialogVisible" :appendToBody="true" title="选择规格" width="700"> + <el-table v-loading="loading" :data="list" show-overflow-tooltip> + <el-table-column label="#" width="55"> + <template #default="{ row }"> + <el-radio :label="row.id" v-model="selectedSkuId" @change="handleSelected(row)" + > + </el-radio> + </template> + </el-table-column> + <el-table-column label="图片" min-width="80"> + <template #default="{ row }"> + <el-image + :src="row.picUrl" + class="h-30px w-30px" + :preview-src-list="[row.picUrl]" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="规格" align="center" min-width="80"> + <template #default="{ row }"> + {{ row.properties?.map((p) => p.valueName)?.join(' ') }} + </template> + </el-table-column> + <el-table-column align="center" label="销售价(元)" min-width="80"> + <template #default="{ row }"> + {{ fenToYuan(row.price) }} + </template> + </el-table-column> + </el-table> + </Dialog> +</template> + +<script lang="ts" setup> +import { ElTable } from 'element-plus' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { propTypes } from '@/utils/propTypes' +import { fenToYuan } from '@/utils' + +defineOptions({ name: 'SkuTableSelect' }) + +const props = defineProps({ + spuId: propTypes.number.def(null) +}) + +const message = useMessage() // 消息弹窗 +const list = ref<any[]>([]) // 列表的数据 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 + +const selectedSkuId = ref() // 选中的商品 spuId + +/** 选中时触发 */ +const handleSelected = (row: ProductSpuApi.Sku) => { + emits('change', row) + // 关闭弹窗 + dialogVisible.value = false + selectedSkuId.value = undefined +} + +// 确认选择时的触发事件 +const emits = defineEmits<{ + (e: 'change', spu: ProductSpuApi.Sku): void +}>() + +/** 打开弹窗 */ +const open = () => { + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getSpuDetail = async () => { + loading.value = true + try { + const spu = await ProductSpuApi.getSpu(props.spuId) + list.value = spu.skus + } finally { + loading.value = false + } +} + +/** 初始化 **/ +onMounted(async () => {}) +watch( + () => props.spuId, + () => { + if (!props.spuId) { + return + } + getSpuDetail() + } +) +</script> diff --git a/src/views/mall/product/spu/components/SpuShowcase.vue b/src/views/mall/product/spu/components/SpuShowcase.vue new file mode 100644 index 0000000..8bee400 --- /dev/null +++ b/src/views/mall/product/spu/components/SpuShowcase.vue @@ -0,0 +1,142 @@ +<template> + <div class="flex flex-wrap items-center gap-8px"> + <div v-for="(spu, index) in productSpus" :key="spu.id" class="select-box spu-pic"> + <el-tooltip :content="spu.name"> + <div class="relative h-full w-full"> + <el-image :src="spu.picUrl" class="h-full w-full" /> + <Icon + v-show="!disabled" + class="del-icon" + icon="ep:circle-close-filled" + @click="handleRemoveSpu(index)" + /> + </div> + </el-tooltip> + </div> + <el-tooltip content="选择商品" v-if="canAdd"> + <div class="select-box" @click="openSpuTableSelect"> + <Icon icon="ep:plus" /> + </div> + </el-tooltip> + </div> + <!-- 商品选择对话框(表格形式) --> + <SpuTableSelect ref="spuTableSelectRef" :multiple="limit != 1" @change="handleSpuSelected" /> +</template> +<script lang="ts" setup> +import * as ProductSpuApi from '@/api/mall/product/spu' +import SpuTableSelect from '@/views/mall/product/spu/components/SpuTableSelect.vue' +import { propTypes } from '@/utils/propTypes' +import { oneOfType } from 'vue-types' +import { isArray } from '@/utils/is' + +// 商品橱窗,一般用于与商品建立关系时使用 +// 提供功能:展示商品列表、添加商品、移除商品 +defineOptions({ name: 'SpuShowcase' }) + +const props = defineProps({ + modelValue: oneOfType<number | Array<number>>([Number, Array]).isRequired, + // 限制数量:默认不限制 + limit: propTypes.number.def(Number.MAX_VALUE), + disabled: propTypes.bool.def(false) +}) + +// 计算是否可以添加 +const canAdd = computed(() => { + // 情况一:禁用时不可以添加 + if (props.disabled) return false + // 情况二:未指定限制数量时,可以添加 + if (!props.limit) return true + // 情况三:检查已添加数量是否小于限制数量 + return productSpus.value.length < props.limit +}) + +// 商品列表 +const productSpus = ref<ProductSpuApi.Spu[]>([]) + +watch( + () => props.modelValue, + async () => { + const ids = isArray(props.modelValue) + ? // 情况一:多选 + props.modelValue + : // 情况二:单选 + props.modelValue + ? [props.modelValue] + : [] + // 不需要返显 + if (ids.length === 0) { + productSpus.value = [] + return + } + // 只有商品发生变化之后,才去查询商品 + if (productSpus.value.length === 0 || productSpus.value.some((spu) => !ids.includes(spu.id!))) { + productSpus.value = await ProductSpuApi.getSpuDetailList(ids) + } + }, + { immediate: true } +) + +/** 商品表格选择对话框 */ +const spuTableSelectRef = ref() +// 打开对话框 +const openSpuTableSelect = () => { + spuTableSelectRef.value.open(productSpus.value) +} + +/** + * 选择商品后触发 + * @param spus 选中的商品列表 + */ +const handleSpuSelected = (spus: ProductSpuApi.Spu | ProductSpuApi.Spu[]) => { + productSpus.value = isArray(spus) ? spus : [spus] + emitSpuChange() +} + +/** + * 删除商品 + * @param index 商品索引 + */ +const handleRemoveSpu = (index: number) => { + productSpus.value.splice(index, 1) + emitSpuChange() +} +const emit = defineEmits(['update:modelValue', 'change']) +const emitSpuChange = () => { + if (props.limit === 1) { + const spu = productSpus.value.length > 0 ? productSpus.value[0] : null + emit('update:modelValue', spu?.id || 0) + emit('change', spu) + } else { + emit( + 'update:modelValue', + productSpus.value.map((spu) => spu.id) + ) + emit('change', productSpus.value) + } +} +</script> + +<style lang="scss" scoped> +.select-box { + display: flex; + width: 60px; + height: 60px; + border: 1px dashed var(--el-border-color-darker); + border-radius: 8px; + align-items: center; + justify-content: center; +} + +.spu-pic { + position: relative; +} + +.del-icon { + position: absolute; + top: -10px; + right: -10px; + z-index: 1; + width: 20px !important; + height: 20px !important; +} +</style> diff --git a/src/views/mall/product/spu/components/SpuTableSelect.vue b/src/views/mall/product/spu/components/SpuTableSelect.vue new file mode 100644 index 0000000..8028f74 --- /dev/null +++ b/src/views/mall/product/spu/components/SpuTableSelect.vue @@ -0,0 +1,303 @@ +<template> + <Dialog v-model="dialogVisible" :appendToBody="true" title="选择商品" width="70%"> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="商品名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入商品名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="商品分类" prop="categoryId"> + <el-tree-select + v-model="queryParams.categoryId" + :data="categoryTreeList" + :props="defaultProps" + check-strictly + class="!w-240px" + node-key="id" + placeholder="请选择商品分类" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + <el-table v-loading="loading" :data="list" show-overflow-tooltip> + <!-- 1. 多选模式(不能使用type="selection",Element会忽略Header插槽) --> + <el-table-column width="55" v-if="multiple"> + <template #header> + <el-checkbox + v-model="isCheckAll" + :indeterminate="isIndeterminate" + @change="handleCheckAll" + /> + </template> + <template #default="{ row }"> + <el-checkbox + v-model="checkedStatus[row.id]" + @change="(checked: boolean) => handleCheckOne(checked, row, true)" + /> + </template> + </el-table-column> + <!-- 2. 单选模式 --> + <el-table-column label="#" width="55" v-else> + <template #default="{ row }"> + <el-radio :label="row.id" v-model="selectedSpuId" @change="handleSingleSelected(row)"> + <!-- 空格不能省略,是为了让单选框不显示label,如果不指定label不会有选中的效果 --> + + </el-radio> + </template> + </el-table-column> + <el-table-column key="id" align="center" label="商品编号" prop="id" min-width="60" /> + <el-table-column label="商品图" min-width="80"> + <template #default="{ row }"> + <el-image + :src="row.picUrl" + class="h-30px w-30px" + :preview-src-list="[row.picUrl]" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="商品名称" min-width="200" prop="name" /> + <el-table-column label="商品分类" min-width="100" prop="categoryId"> + <template #default="{ row }"> + <span>{{ categoryList?.find((c) => c.id === row.categoryId)?.name }}</span> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer v-if="multiple"> + <el-button type="primary" @click="handleEmitChange">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> + +<script lang="ts" setup> +import { defaultProps, handleTree } from '@/utils/tree' + +import * as ProductCategoryApi from '@/api/mall/product/category' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { propTypes } from '@/utils/propTypes' +import { CHANGE_EVENT } from 'element-plus' + +type Spu = Required<ProductSpuApi.Spu> + +/** + * 商品表格选择对话框 + * 1. 单选模式: + * 1.1 点击表格左侧的单选框时,结束选择,并关闭对话框 + * 1.2 再次打开时,保持选中状态 + * 2. 多选模式: + * 2.1 点击表格左侧的多选框时,记录选中的商品 + * 2.2 切换分页时,保持商品的选中的状态 + * 2.3 点击右下角的确定按钮时,结束选择,关闭对话框 + * 2.4 再次打开时,保持选中状态 + */ +defineOptions({ name: 'SpuTableSelect' }) + +defineProps({ + // 多选模式 + multiple: propTypes.bool.def(false) +}) + +// 列表的总页数 +const total = ref(0) +// 列表的数据 +const list = ref<Spu[]>([]) +// 列表的加载中 +const loading = ref(false) +// 弹窗的是否展示 +const dialogVisible = ref(false) +// 查询参数 +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + // 默认获取上架的商品 + tabType: 0, + name: '', + categoryId: null, + createTime: [] +}) + +/** 打开弹窗 */ +const open = (spuList?: Spu[]) => { + // 重置 + checkedSpus.value = [] + checkedStatus.value = {} + isCheckAll.value = false + isIndeterminate.value = false + + // 处理已选中 + if (spuList && spuList.length > 0) { + checkedSpus.value = [...spuList] + checkedStatus.value = Object.fromEntries(spuList.map((spu) => [spu.id, true])) + } + + dialogVisible.value = true + resetQuery() +} +// 提供 open 方法,用于打开弹窗 +defineExpose({ open }) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductSpuApi.getSpuPage(queryParams.value) + list.value = data.list + total.value = data.total + // checkbox绑定undefined会有问题,需要给一个bool值 + list.value.forEach( + (spu) => (checkedStatus.value[spu.id] = checkedStatus.value[spu.id] || false) + ) + // 计算全选框状态 + calculateIsCheckAll() + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.value.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryParams.value = { + pageNo: 1, + pageSize: 10, + // 默认获取上架的商品 + tabType: 0, + name: '', + categoryId: null, + createTime: [] + } + getList() +} + +// 是否全选 +const isCheckAll = ref(false) +// 全选框是否处于中间状态:不是全部选中 && 任意一个选中 +const isIndeterminate = ref(false) +// 选中的商品 +const checkedSpus = ref<Spu[]>([]) +// 选中状态:key为商品ID,value为是否选中 +const checkedStatus = ref<Record<string, boolean>>({}) + +// 选中的商品 spuId +const selectedSpuId = ref() +/** 单选中时触发 */ +const handleSingleSelected = (spu: Spu) => { + emits(CHANGE_EVENT, spu) + // 关闭弹窗 + dialogVisible.value = false + // 记住上次选择的ID + selectedSpuId.value = spu.id +} + +/** 多选完成 */ +const handleEmitChange = () => { + // 关闭弹窗 + dialogVisible.value = false + emits(CHANGE_EVENT, [...checkedSpus.value]) +} + +/** 确认选择时的触发事件 */ +const emits = defineEmits<{ + change: [spu: Spu | Spu[] | any] +}>() + +/** 全选/全不选 */ +const handleCheckAll = (checked: boolean) => { + isCheckAll.value = checked + isIndeterminate.value = false + + list.value.forEach((spu) => handleCheckOne(checked, spu, false)) +} + +/** + * 选中一行 + * @param checked 是否选中 + * @param spu 商品 + * @param isCalcCheckAll 是否计算全选 + */ +const handleCheckOne = (checked: boolean, spu: Spu, isCalcCheckAll: boolean) => { + if (checked) { + checkedSpus.value.push(spu) + checkedStatus.value[spu.id] = true + } else { + const index = findCheckedIndex(spu) + if (index > -1) { + checkedSpus.value.splice(index, 1) + checkedStatus.value[spu.id] = false + isCheckAll.value = false + } + } + + // 计算全选框状态 + if (isCalcCheckAll) { + calculateIsCheckAll() + } +} + +// 查找商品在已选中商品列表中的索引 +const findCheckedIndex = (spu: Spu) => checkedSpus.value.findIndex((item) => item.id === spu.id) + +// 计算全选框状态 +const calculateIsCheckAll = () => { + isCheckAll.value = list.value.every((spu) => checkedStatus.value[spu.id]) + // 计算中间状态:不是全部选中 && 任意一个选中 + isIndeterminate.value = !isCheckAll.value && list.value.some((spu) => checkedStatus.value[spu.id]) +} + +// 分类列表 +const categoryList = ref() +// 分类树 +const categoryTreeList = ref() +/** 初始化 **/ +onMounted(async () => { + await getList() + // 获得分类树 + categoryList.value = await ProductCategoryApi.getCategoryList({}) + categoryTreeList.value = handleTree(categoryList.value, 'id', 'parentId') +}) +</script> diff --git a/src/views/mall/product/spu/components/index.ts b/src/views/mall/product/spu/components/index.ts new file mode 100644 index 0000000..e2cbe73 --- /dev/null +++ b/src/views/mall/product/spu/components/index.ts @@ -0,0 +1,54 @@ +import SkuList from './SkuList.vue' +import { Spu } from '@/api/mall/product/spu' + +interface PropertyAndValues { + id: number + name: string + values?: PropertyAndValues[] +} + +interface RuleConfig { + // 需要校验的字段 + // 例:name: 'name' 则表示校验 sku.name 的值 + // 例:name: 'productConfig.stock' 则表示校验 sku.productConfig.name 的值,此处 productConfig 表示我在 Sku 上扩展的属性 + name: string + // 校验规格为一个毁掉函数,其中 arg 为需要校验的字段的值。 + // 例:需要校验价格必须大于0.01 + // { + // name:'price', + // rule:(arg: number) => arg > 0.01 + // } + rule: (arg: any) => boolean + // 校验不通过时的消息提示 + message: string +} + +/** + * 获得商品的规格列表 - 商品相关的公共函数 + * + * @param spu + * @return PropertyAndValues 规格列表 + */ +const getPropertyList = (spu: Spu): PropertyAndValues[] => { + // 直接拿返回的 skus 属性逆向生成出 propertyList + const properties: PropertyAndValues[] = [] + // 只有是多规格才处理 + if (spu.specType) { + spu.skus?.forEach((sku) => { + sku.properties?.forEach(({ propertyId, propertyName, valueId, valueName }) => { + // 添加属性 + if (!properties?.some((item) => item.id === propertyId)) { + properties.push({ id: propertyId!, name: propertyName!, values: [] }) + } + // 添加属性值 + const index = properties?.findIndex((item) => item.id === propertyId) + if (!properties[index].values?.some((value) => value.id === valueId)) { + properties[index].values?.push({ id: valueId!, name: valueName! }) + } + }) + }) + } + return properties +} + +export { SkuList, PropertyAndValues, RuleConfig, getPropertyList } diff --git a/src/views/mall/product/spu/form/DeliveryForm.vue b/src/views/mall/product/spu/form/DeliveryForm.vue new file mode 100644 index 0000000..1503122 --- /dev/null +++ b/src/views/mall/product/spu/form/DeliveryForm.vue @@ -0,0 +1,96 @@ +<!-- 商品发布 - 物流设置 --> +<template> + <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" :disabled="isDetail"> + <el-form-item label="配送方式" prop="deliveryTypes"> + <el-checkbox-group v-model="formData.deliveryTypes" class="w-80"> + <el-checkbox + v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_DELIVERY_TYPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-checkbox> + </el-checkbox-group> + </el-form-item> + <el-form-item + label="运费模板" + prop="deliveryTemplateId" + v-if="formData.deliveryTypes?.includes(DeliveryTypeEnum.EXPRESS.type)" + > + <el-select placeholder="请选择运费模板" v-model="formData.deliveryTemplateId" class="w-80"> + <el-option + v-for="item in deliveryTemplateList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-form> +</template> +<script lang="ts" setup> +import { PropType } from 'vue' +import { copyValueToTarget } from '@/utils' +import { propTypes } from '@/utils/propTypes' +import type { Spu } from '@/api/mall/product/spu' +import * as ExpressTemplateApi from '@/api/mall/trade/delivery/expressTemplate' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { DeliveryTypeEnum } from '@/utils/constants' + +defineOptions({ name: 'ProductDeliveryForm' }) + +const message = useMessage() // 消息弹窗 + +const props = defineProps({ + propFormData: { + type: Object as PropType<Spu>, + default: () => {} + }, + isDetail: propTypes.bool.def(false) // 是否作为详情组件 +}) +const formRef = ref() // 表单 Ref +const formData = reactive<Spu>({ + deliveryTypes: [], // 配送方式 + deliveryTemplateId: undefined // 运费模版 +}) +const rules = reactive({ + deliveryTypes: [required], + deliveryTemplateId: [required] +}) + +/** 将传进来的值赋值给 formData */ +watch( + () => props.propFormData, + (data) => { + if (!data) { + return + } + copyValueToTarget(formData, data) + }, + { + immediate: true + } +) + +/** 表单校验 */ +const emit = defineEmits(['update:activeName']) +const validate = async () => { + if (!formRef) return + try { + await unref(formRef)?.validate() + // 校验通过更新数据 + Object.assign(props.propFormData, formData) + } catch (e) { + message.error('【物流设置】不完善,请填写相关信息') + emit('update:activeName', 'delivery') + throw e // 目的截断之后的校验 + } +} +defineExpose({ validate }) + +/** 初始化 */ +const deliveryTemplateList = ref([]) // 运费模版 +onMounted(async () => { + deliveryTemplateList.value = await ExpressTemplateApi.getSimpleTemplateList() +}) +</script> diff --git a/src/views/mall/product/spu/form/DescriptionForm.vue b/src/views/mall/product/spu/form/DescriptionForm.vue new file mode 100644 index 0000000..2980aa4 --- /dev/null +++ b/src/views/mall/product/spu/form/DescriptionForm.vue @@ -0,0 +1,81 @@ +<!-- 商品发布 - 商品详情 --> +<template> + <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" :disabled="isDetail"> + <!--富文本编辑器组件--> + <el-form-item label="商品详情" prop="description"> + <Editor v-model:modelValue="formData.description" /> + </el-form-item> + </el-form> +</template> +<script lang="ts" setup> +import type { Spu } from '@/api/mall/product/spu' +import { Editor } from '@/components/Editor' +import { PropType } from 'vue' +import { propTypes } from '@/utils/propTypes' +import { copyValueToTarget } from '@/utils' + +defineOptions({ name: 'ProductDescriptionForm' }) + +const message = useMessage() // 消息弹窗 + +const props = defineProps({ + propFormData: { + type: Object as PropType<Spu>, + default: () => {} + }, + activeName: propTypes.string.def(''), + isDetail: propTypes.bool.def(false) // 是否作为详情组件 +}) +const formRef = ref() // 表单Ref +const formData = ref<Spu>({ + description: '' // 商品详情 +}) +// 表单规则 +const rules = reactive({ + description: [required] +}) + +/** 富文本编辑器如果输入过再清空会有残留,需再重置一次 */ +watch( + () => formData.value.description, + (newValue) => { + if ('<p><br></p>' === newValue) { + formData.value.description = '' + } + }, + { + deep: true, + immediate: true + } +) + +/** 将传进来的值赋值给 formData */ +watch( + () => props.propFormData, + (data) => { + if (!data) return + // fix:三个表单组件监听赋值必须使用 copyValueToTarget 使用 formData.value = data 会监听非常多次 + copyValueToTarget(formData.value, data) + }, + { + // fix: 去掉深度监听只有对象引用发生改变的时候才执行,解决改一动多的问题 + immediate: true + } +) + +/** 表单校验 */ +const emit = defineEmits(['update:activeName']) +const validate = async () => { + if (!formRef) return + try { + await unref(formRef)?.validate() + // 校验通过更新数据 + Object.assign(props.propFormData, formData.value) + } catch (e) { + message.error('【商品详情】不完善,请填写相关信息') + emit('update:activeName', 'description') + throw e // 目的截断之后的校验 + } +} +defineExpose({ validate }) +</script> diff --git a/src/views/mall/product/spu/form/InfoForm.vue b/src/views/mall/product/spu/form/InfoForm.vue new file mode 100644 index 0000000..76a0970 --- /dev/null +++ b/src/views/mall/product/spu/form/InfoForm.vue @@ -0,0 +1,142 @@ +<!-- 商品发布 - 基础设置 --> +<template> + <el-form ref="formRef" :disabled="isDetail" :model="formData" :rules="rules" label-width="120px"> + <el-form-item label="商品名称" prop="name"> + <el-input + v-model="formData.name" + :autosize="{ minRows: 2, maxRows: 2 }" + :clearable="true" + :show-word-limit="true" + class="w-80!" + maxlength="64" + placeholder="请输入商品名称" + type="textarea" + /> + </el-form-item> + <el-form-item label="商品分类" prop="categoryId"> + <el-cascader + v-model="formData.categoryId" + :options="categoryList" + :props="defaultProps" + class="w-80" + clearable + filterable + placeholder="请选择商品分类" + /> + </el-form-item> + <el-form-item label="商品品牌" prop="brandId"> + <el-select v-model="formData.brandId" class="w-80" placeholder="请选择商品品牌"> + <el-option + v-for="item in brandList" + :key="item.id" + :label="item.name" + :value="item.id as number" + /> + </el-select> + </el-form-item> + <el-form-item label="商品关键字" prop="keyword"> + <el-input v-model="formData.keyword" class="w-80!" placeholder="请输入商品关键字" /> + </el-form-item> + <el-form-item label="商品简介" prop="introduction"> + <el-input + v-model="formData.introduction" + :autosize="{ minRows: 2, maxRows: 2 }" + :clearable="true" + :show-word-limit="true" + class="w-80!" + maxlength="128" + placeholder="请输入商品名称" + type="textarea" + /> + </el-form-item> + <el-form-item label="商品封面图" prop="picUrl"> + <UploadImg v-model="formData.picUrl" :disabled="isDetail" height="80px" /> + </el-form-item> + <el-form-item label="商品轮播图" prop="sliderPicUrls"> + <UploadImgs v-model="formData.sliderPicUrls" :disabled="isDetail" /> + </el-form-item> + </el-form> +</template> +<script lang="ts" setup> +import { PropType } from 'vue' +import { copyValueToTarget } from '@/utils' +import { propTypes } from '@/utils/propTypes' +import { defaultProps, handleTree } from '@/utils/tree' +import type { Spu } from '@/api/mall/product/spu' +import * as ProductCategoryApi from '@/api/mall/product/category' +import { CategoryVO } from '@/api/mall/product/category' +import * as ProductBrandApi from '@/api/mall/product/brand' +import { BrandVO } from '@/api/mall/product/brand' + +defineOptions({ name: 'ProductSpuInfoForm' }) +const props = defineProps({ + propFormData: { + type: Object as PropType<Spu>, + default: () => {} + }, + isDetail: propTypes.bool.def(false) // 是否作为详情组件 +}) + +const message = useMessage() // 消息弹窗 + +const formRef = ref() // 表单 Ref +const formData = reactive<Spu>({ + name: '', // 商品名称 + categoryId: undefined, // 商品分类 + keyword: '', // 关键字 + picUrl: '', // 商品封面图 + sliderPicUrls: [], // 商品轮播图 + introduction: '', // 商品简介 + brandId: undefined // 商品品牌 +}) +const rules = reactive({ + name: [required], + categoryId: [required], + keyword: [required], + introduction: [required], + picUrl: [required], + sliderPicUrls: [required], + brandId: [required] +}) + +/** 将传进来的值赋值给 formData */ +watch( + () => props.propFormData, + (data) => { + if (!data) { + return + } + copyValueToTarget(formData, data) + }, + { + immediate: true + } +) + +/** 表单校验 */ +const emit = defineEmits(['update:activeName']) +const validate = async () => { + if (!formRef) return + try { + await unref(formRef)?.validate() + // 校验通过更新数据 + Object.assign(props.propFormData, formData) + } catch (e) { + message.error('【基础设置】不完善,请填写相关信息') + emit('update:activeName', 'info') + throw e // 目的截断之后的校验 + } +} +defineExpose({ validate }) + +/** 初始化 */ +const brandList = ref<BrandVO[]>([]) // 商品品牌列表 +const categoryList = ref<CategoryVO[]>([]) // 商品分类树 +onMounted(async () => { + // 获得分类树 + const data = await ProductCategoryApi.getCategoryList({}) + categoryList.value = handleTree(data, 'id') + // 获取商品品牌列表 + brandList.value = await ProductBrandApi.getSimpleBrandList() +}) +</script> diff --git a/src/views/mall/product/spu/form/OtherForm.vue b/src/views/mall/product/spu/form/OtherForm.vue new file mode 100644 index 0000000..e7e6358 --- /dev/null +++ b/src/views/mall/product/spu/form/OtherForm.vue @@ -0,0 +1,91 @@ +<!-- 商品发布 - 其它设置 --> +<template> + <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" :disabled="isDetail"> + <el-form-item label="商品排序" prop="sort"> + <el-input-number + v-model="formData.sort" + :min="0" + placeholder="请输入商品排序" + class="w-80!" + /> + </el-form-item> + <el-form-item label="赠送积分" prop="giveIntegral"> + <el-input-number + v-model="formData.giveIntegral" + :min="0" + placeholder="请输入赠送积分" + class="w-80!" + /> + </el-form-item> + <el-form-item label="虚拟销量" prop="virtualSalesCount"> + <el-input-number + v-model="formData.virtualSalesCount" + :min="0" + placeholder="请输入虚拟销量" + class="w-80!" + /> + </el-form-item> + </el-form> +</template> +<script lang="ts" setup> +import type { Spu } from '@/api/mall/product/spu' +import { PropType } from 'vue' +import { propTypes } from '@/utils/propTypes' +import { copyValueToTarget } from '@/utils' + +defineOptions({ name: 'ProductOtherForm' }) + +const message = useMessage() // 消息弹窗 + +const props = defineProps({ + propFormData: { + type: Object as PropType<Spu>, + default: () => {} + }, + isDetail: propTypes.bool.def(false) // 是否作为详情组件 +}) + +const formRef = ref() // 表单Ref +// 表单数据 +const formData = ref<Spu>({ + sort: 0, // 商品排序 + giveIntegral: 0, // 赠送积分 + virtualSalesCount: 0 // 虚拟销量 +}) +// 表单规则 +const rules = reactive({ + sort: [required], + giveIntegral: [required], + virtualSalesCount: [required] +}) + +/** 将传进来的值赋值给 formData */ +watch( + () => props.propFormData, + (data) => { + if (!data) { + return + } + copyValueToTarget(formData.value, data) + }, + { + immediate: true + } +) + +/** 表单校验 */ +const emit = defineEmits(['update:activeName']) +const validate = async () => { + if (!formRef) return + try { + await unref(formRef)?.validate() + // 校验通过更新数据 + Object.assign(props.propFormData, formData.value) + } catch (e) { + message.error('【其它设置】不完善,请填写相关信息') + emit('update:activeName', 'other') + throw e // 目的截断之后的校验 + } +} +defineExpose({ validate }) +</script> diff --git a/src/views/mall/product/spu/form/ProductAttributes.vue b/src/views/mall/product/spu/form/ProductAttributes.vue new file mode 100644 index 0000000..30b1774 --- /dev/null +++ b/src/views/mall/product/spu/form/ProductAttributes.vue @@ -0,0 +1,162 @@ +<!-- 商品发布 - 库存价格 - 属性列表 --> +<template> + <el-col v-for="(item, index) in attributeList" :key="index"> + <div> + <el-text class="mx-1">属性名:</el-text> + <el-tag :closable="!isDetail" class="mx-1" type="success" @close="handleCloseProperty(index)"> + {{ item.name }} + </el-tag> + </div> + <div> + <el-text class="mx-1">属性值:</el-text> + <el-tag + v-for="(value, valueIndex) in item.values" + :key="value.id" + :closable="!isDetail" + class="mx-1" + @close="handleCloseValue(index, valueIndex)" + > + {{ value.name }} + </el-tag> + <el-select + :id="`input${index}`" + :ref="setInputRef" + v-show="inputVisible(index)" + v-model="inputValue" + filterable + allow-create + default-first-option + :reserve-keyword="false" + size="small" + class="!w-30" + @blur="handleInputConfirm(index, item.id)" + @keyup.enter="handleInputConfirm(index, item.id)" + @change="handleInputConfirm(index, item.id)" + > + <el-option + v-for="item2 in attributeOptions" + :key="item2.id" + :label="item2.name" + :value="item2.name" + /> + </el-select> + <el-button + v-show="!inputVisible(index)" + class="button-new-tag ml-1" + size="small" + @click="showInput(index)" + > + + 添加 + </el-button> + </div> + <el-divider class="my-10px" /> + </el-col> +</template> + +<script lang="ts" setup> +import * as PropertyApi from '@/api/mall/product/property' +import { PropertyAndValues } from '@/views/mall/product/spu/components' +import { propTypes } from '@/utils/propTypes' + +defineOptions({ name: 'ProductAttributes' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const inputValue = ref('') // 输入框值 +const attributeIndex = ref<number | null>(null) // 获取焦点时记录当前属性项的index +// 输入框显隐控制 +const inputVisible = computed(() => (index: number) => { + if (attributeIndex.value === null) return false + if (attributeIndex.value === index) return true +}) +const inputRef = ref<any[]>([]) //标签输入框Ref +/** 解决 ref 在 v-for 中的获取问题*/ +const setInputRef = (el: any) => { + if (el === null || typeof el === 'undefined') return + // 如果不存在 id 相同的元素才添加 + if (!inputRef.value.some((item) => item.input?.attributes.id === el.input?.attributes.id)) { + inputRef.value.push(el) + } +} +const attributeList = ref<PropertyAndValues[]>([]) // 商品属性列表 +const attributeOptions = ref([] as PropertyApi.PropertyValueVO[]) // 商品属性名称下拉框 +const props = defineProps({ + propertyList: { + type: Array, + default: () => {} + }, + isDetail: propTypes.bool.def(false) // 是否作为详情组件 +}) + +watch( + () => props.propertyList, + (data) => { + if (!data) return + attributeList.value = data as any + }, + { + deep: true, + immediate: true + } +) + +/** 删除属性值*/ +const handleCloseValue = (index: number, valueIndex: number) => { + attributeList.value[index].values?.splice(valueIndex, 1) +} + +/** 删除属性*/ +const handleCloseProperty = (index: number) => { + attributeList.value?.splice(index, 1) + emit('success', attributeList.value) +} + +/** 显示输入框并获取焦点 */ +const showInput = async (index: number) => { + attributeIndex.value = index + inputRef.value[index].focus() + // 获取属性下拉选项 + await getAttributeOptions(attributeList.value[index].id) +} + +/** 输入框失去焦点或点击回车时触发 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const handleInputConfirm = async (index: number, propertyId: number) => { + if (inputValue.value) { + // 1. 重复添加校验 + if (attributeList.value[index].values.find((item) => item.name === inputValue.value)) { + message.warning('已存在相同属性值,请重试') + attributeIndex.value = null + inputValue.value = '' + return + } + + // 2.1 情况一:属性值已存在,则直接使用并结束 + const existValue = attributeOptions.value.find((item) => item.name === inputValue.value) + if (existValue) { + attributeIndex.value = null + inputValue.value = '' + attributeList.value[index].values.push({ id: existValue.id, name: existValue.name }) + emit('success', attributeList.value) + return + } + + // 2.2 情况二:新属性值,则进行保存 + try { + const id = await PropertyApi.createPropertyValue({ propertyId, name: inputValue.value }) + attributeList.value[index].values.push({ id, name: inputValue.value }) + message.success(t('common.createSuccess')) + emit('success', attributeList.value) + } catch { + message.error('添加失败,请重试') + } + } + attributeIndex.value = null + inputValue.value = '' +} + +/** 获取商品属性下拉选项 */ +const getAttributeOptions = async (propertyId: number) => { + attributeOptions.value = await PropertyApi.getPropertyValueSimpleList(propertyId) +} +</script> diff --git a/src/views/mall/product/spu/form/ProductPropertyAddForm.vue b/src/views/mall/product/spu/form/ProductPropertyAddForm.vue new file mode 100644 index 0000000..455fbbb --- /dev/null +++ b/src/views/mall/product/spu/form/ProductPropertyAddForm.vue @@ -0,0 +1,148 @@ +<!-- 商品发布 - 库存价格 - 添加属性 --> +<template> + <Dialog v-model="dialogVisible" title="添加商品属性"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + @keydown.enter.prevent="submitForm" + > + <el-form-item label="属性名称" prop="name"> + <el-select + v-model="formData.name" + filterable + allow-create + default-first-option + :reserve-keyword="false" + placeholder="请选择属性名称。如果不存在,可手动输入选择" + class="!w-360px" + > + <el-option + v-for="item in attributeOptions" + :key="item.id" + :label="item.name" + :value="item.name" + /> + </el-select> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as PropertyApi from '@/api/mall/product/property' + +defineOptions({ name: 'ProductPropertyForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const formData = ref({ + name: '' +}) +const formRules = reactive({ + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const attributeList = ref([]) // 商品属性列表 +const attributeOptions = ref([] as PropertyApi.PropertyVO[]) // 商品属性名称下拉框 +const props = defineProps({ + propertyList: { + type: Array, + default: () => {} + } +}) + +watch( + () => props.propertyList, // 解决 props 无法直接修改父组件的问题 + (data) => { + if (!data) return + attributeList.value = data + }, + { + deep: true, + immediate: true + } +) + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + resetForm() + // 加载列表 + await getAttributeOptions() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const submitForm = async () => { + // 1.1 重复添加校验 + for (const attrItem of attributeList.value) { + if (attrItem.name === formData.value.name) { + return message.error('该属性已存在,请勿重复添加') + } + } + // 1.2 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + + // 2.1 情况一:属性名已存在,则直接使用并结束 + const existProperty = attributeOptions.value.find((item) => item.name === formData.value.name) + if (existProperty) { + // 添加到属性列表 + attributeList.value.push({ + id: existProperty.id, + ...formData.value, + values: [] + }) + // 关闭弹窗 + dialogVisible.value = false + return + } + + // 2.2 情况二:如果是不存在的属性,则需要执行新增 + // 提交请求 + formLoading.value = true + try { + const data = formData.value as PropertyApi.PropertyVO + const propertyId = await PropertyApi.createProperty(data) + // 添加到属性列表 + attributeList.value.push({ + id: propertyId, + ...formData.value, + values: [] + }) + // 关闭弹窗 + message.success(t('common.createSuccess')) + dialogVisible.value = false + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + name: '' + } + formRef.value?.resetFields() +} + +/** 获取商品属性下拉选项 */ +const getAttributeOptions = async () => { + formLoading.value = true + try { + attributeOptions.value = await PropertyApi.getPropertySimpleList() + } finally { + formLoading.value = false + } +} +</script> diff --git a/src/views/mall/product/spu/form/SkuForm.vue b/src/views/mall/product/spu/form/SkuForm.vue new file mode 100644 index 0000000..73b8cea --- /dev/null +++ b/src/views/mall/product/spu/form/SkuForm.vue @@ -0,0 +1,194 @@ +<!-- 商品发布 - 库存价格 --> +<template> + <el-form + ref="formRef" + :disabled="isDetail" + :model="formData" + :rules="rules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label="分销类型" props="subCommissionType"> + <el-radio-group + v-model="formData.subCommissionType" + class="w-80" + @change="changeSubCommissionType" + > + <el-radio :label="false">默认设置</el-radio> + <el-radio :label="true" class="radio">单独设置</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="商品规格" props="specType"> + <el-radio-group v-model="formData.specType" class="w-80" @change="onChangeSpec"> + <el-radio :label="false" class="radio">单规格</el-radio> + <el-radio :label="true">多规格</el-radio> + </el-radio-group> + </el-form-item> + <!-- 多规格添加--> + <el-form-item v-if="!formData.specType"> + <SkuList + ref="skuListRef" + :prop-form-data="formData" + :property-list="propertyList" + :rule-config="ruleConfig" + /> + </el-form-item> + <el-form-item v-if="formData.specType" label="商品属性"> + <el-button class="mb-10px mr-15px" @click="attributesAddFormRef.open">添加属性</el-button> + <ProductAttributes + :is-detail="isDetail" + :property-list="propertyList" + @success="generateSkus" + /> + </el-form-item> + <template v-if="formData.specType && propertyList.length > 0"> + <el-form-item v-if="!isDetail" label="批量设置"> + <SkuList :is-batch="true" :prop-form-data="formData" :property-list="propertyList" /> + </el-form-item> + <el-form-item label="规格列表"> + <SkuList + ref="skuListRef" + :is-detail="isDetail" + :prop-form-data="formData" + :property-list="propertyList" + :rule-config="ruleConfig" + /> + </el-form-item> + </template> + </el-form> + + <!-- 商品属性添加 Form 表单 --> + <ProductPropertyAddForm ref="attributesAddFormRef" :propertyList="propertyList" /> +</template> +<script lang="ts" setup> +import { PropType } from 'vue' +import { copyValueToTarget } from '@/utils' +import { propTypes } from '@/utils/propTypes' +import { + getPropertyList, + PropertyAndValues, + RuleConfig, + SkuList +} from '@/views/mall/product/spu/components/index' +import ProductAttributes from './ProductAttributes.vue' +import ProductPropertyAddForm from './ProductPropertyAddForm.vue' +import type { Spu } from '@/api/mall/product/spu' + +defineOptions({ name: 'ProductSpuSkuForm' }) + +// sku 相关属性校验规则 +const ruleConfig: RuleConfig[] = [ + { + name: 'stock', + rule: (arg) => arg >= 0, + message: '商品库存必须大于等于 1 !!!' + }, + { + name: 'price', + rule: (arg) => arg >= 0.01, + message: '商品销售价格必须大于等于 0.01 元!!!' + }, + { + name: 'marketPrice', + rule: (arg) => arg >= 0.01, + message: '商品市场价格必须大于等于 0.01 元!!!' + }, + { + name: 'costPrice', + rule: (arg) => arg >= 0.01, + message: '商品成本价格必须大于等于 0.00 元!!!' + } +] + +const message = useMessage() // 消息弹窗 +const formLoading = ref(false) +const props = defineProps({ + propFormData: { + type: Object as PropType<Spu>, + default: () => {} + }, + isDetail: propTypes.bool.def(false) // 是否作为详情组件 +}) +const attributesAddFormRef = ref() // 添加商品属性表单 +const formRef = ref() // 表单 Ref +const propertyList = ref<PropertyAndValues[]>([]) // 商品属性列表 +const skuListRef = ref() // 商品属性列表 Ref +const formData = reactive<Spu>({ + specType: false, // 商品规格 + subCommissionType: false, // 分销类型 + skus: [] +}) +const rules = reactive({ + specType: [required], + subCommissionType: [required] +}) + +/** 将传进来的值赋值给 formData */ +watch( + () => props.propFormData, + (data) => { + if (!data) { + return + } + copyValueToTarget(formData, data) + // 将 SKU 的属性,整理成 PropertyAndValues 数组 + propertyList.value = getPropertyList(data) + }, + { + immediate: true + } +) + +/** 表单校验 */ +const emit = defineEmits(['update:activeName']) +const validate = async () => { + if (!formRef) return + try { + // 校验 sku + skuListRef.value.validateSku() + await unref(formRef).validate() + // 校验通过更新数据 + Object.assign(props.propFormData, formData) + } catch (e) { + message.error('【库存价格】不完善,请填写相关信息') + emit('update:activeName', 'sku') + throw e // 目的截断之后的校验 + } +} +defineExpose({ validate }) + +/** 分销类型 */ +const changeSubCommissionType = () => { + // 默认为零,类型切换后也要重置为零 + for (const item of formData.skus!) { + item.firstBrokeragePrice = 0 + item.secondBrokeragePrice = 0 + } +} + +/** 选择规格 */ +const onChangeSpec = () => { + // 重置商品属性列表 + propertyList.value = [] + // 重置sku列表 + formData.skus = [ + { + price: 0, + marketPrice: 0, + costPrice: 0, + barCode: '', + picUrl: '', + stock: 0, + weight: 0, + volume: 0, + firstBrokeragePrice: 0, + secondBrokeragePrice: 0 + } + ] +} + +/** 调用 SkuList generateTableData 方法*/ +const generateSkus = (propertyList: any[]) => { + skuListRef.value.generateTableData(propertyList) +} +</script> diff --git a/src/views/mall/product/spu/form/index.vue b/src/views/mall/product/spu/form/index.vue new file mode 100644 index 0000000..c4e4b7b --- /dev/null +++ b/src/views/mall/product/spu/form/index.vue @@ -0,0 +1,204 @@ +<template> + <ContentWrap v-loading="formLoading"> + <el-tabs v-model="activeName"> + <el-tab-pane label="基础设置" name="info"> + <InfoForm + ref="infoRef" + v-model:activeName="activeName" + :is-detail="isDetail" + :propFormData="formData" + /> + </el-tab-pane> + <el-tab-pane label="价格库存" name="sku"> + <SkuForm + ref="skuRef" + v-model:activeName="activeName" + :is-detail="isDetail" + :propFormData="formData" + /> + </el-tab-pane> + <el-tab-pane label="物流设置" name="delivery"> + <DeliveryForm + ref="deliveryRef" + v-model:activeName="activeName" + :is-detail="isDetail" + :propFormData="formData" + /> + </el-tab-pane> + <el-tab-pane label="商品详情" name="description"> + <DescriptionForm + ref="descriptionRef" + v-model:activeName="activeName" + :is-detail="isDetail" + :propFormData="formData" + /> + </el-tab-pane> + <el-tab-pane label="其它设置" name="other"> + <OtherForm + ref="otherRef" + v-model:activeName="activeName" + :is-detail="isDetail" + :propFormData="formData" + /> + </el-tab-pane> + </el-tabs> + <el-form> + <el-form-item style="float: right"> + <el-button v-if="!isDetail" :loading="formLoading" type="primary" @click="submitForm"> + 保存 + </el-button> + <el-button @click="close">返回</el-button> + </el-form-item> + </el-form> + </ContentWrap> +</template> +<script lang="ts" setup> +import { cloneDeep } from 'lodash-es' +import { useTagsViewStore } from '@/store/modules/tagsView' +import * as ProductSpuApi from '@/api/mall/product/spu' +import InfoForm from './InfoForm.vue' +import DescriptionForm from './DescriptionForm.vue' +import OtherForm from './OtherForm.vue' +import SkuForm from './SkuForm.vue' +import DeliveryForm from './DeliveryForm.vue' +import { convertToInteger, floatToFixed2, formatToFraction } from '@/utils' + +defineOptions({ name: 'ProductSpuAdd' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const { push, currentRoute } = useRouter() // 路由 +const { params, name } = useRoute() // 查询参数 +const { delView } = useTagsViewStore() // 视图操作 + +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const activeName = ref('info') // Tag 激活的窗口 +const isDetail = ref(false) // 是否查看详情 +const infoRef = ref() // 商品信息 Ref +const skuRef = ref() // 商品规格 Ref +const deliveryRef = ref() // 物流设置 Ref +const descriptionRef = ref() // 商品详情 Ref +const otherRef = ref() // 其他设置 Ref +// SPU 表单数据 +const formData = ref<ProductSpuApi.Spu>({ + name: '', // 商品名称 + categoryId: undefined, // 商品分类 + keyword: '', // 关键字 + picUrl: '', // 商品封面图 + sliderPicUrls: [], // 商品轮播图 + introduction: '', // 商品简介 + deliveryTypes: [], // 配送方式数组 + deliveryTemplateId: undefined, // 运费模版 + brandId: undefined, // 商品品牌 + specType: false, // 商品规格 + subCommissionType: false, // 分销类型 + skus: [ + { + price: 0, // 商品价格 + marketPrice: 0, // 市场价 + costPrice: 0, // 成本价 + barCode: '', // 商品条码 + picUrl: '', // 图片地址 + stock: 0, // 库存 + weight: 0, // 商品重量 + volume: 0, // 商品体积 + firstBrokeragePrice: 0, // 一级分销的佣金 + secondBrokeragePrice: 0 // 二级分销的佣金 + } + ], + description: '', // 商品详情 + sort: 0, // 商品排序 + giveIntegral: 0, // 赠送积分 + virtualSalesCount: 0 // 虚拟销量 +}) + +/** 获得详情 */ +const getDetail = async () => { + if ('ProductSpuDetail' === name) { + isDetail.value = true + } + const id = params.id as unknown as number + if (id) { + formLoading.value = true + try { + const res = (await ProductSpuApi.getSpu(id)) as ProductSpuApi.Spu + res.skus?.forEach((item) => { + if (isDetail.value) { + item.price = floatToFixed2(item.price) + item.marketPrice = floatToFixed2(item.marketPrice) + item.costPrice = floatToFixed2(item.costPrice) + item.firstBrokeragePrice = floatToFixed2(item.firstBrokeragePrice) + item.secondBrokeragePrice = floatToFixed2(item.secondBrokeragePrice) + } else { + // 回显价格分转元 + item.price = formatToFraction(item.price) + item.marketPrice = formatToFraction(item.marketPrice) + item.costPrice = formatToFraction(item.costPrice) + item.firstBrokeragePrice = formatToFraction(item.firstBrokeragePrice) + item.secondBrokeragePrice = formatToFraction(item.secondBrokeragePrice) + } + }) + formData.value = res + } finally { + formLoading.value = false + } + } +} + +/** 提交按钮 */ +const submitForm = async () => { + // 提交请求 + formLoading.value = true + try { + // 校验各表单 + await unref(infoRef)?.validate() + await unref(skuRef)?.validate() + await unref(deliveryRef)?.validate() + await unref(descriptionRef)?.validate() + await unref(otherRef)?.validate() + // 深拷贝一份, 这样最终 server 端不满足,不需要影响原始数据 + const deepCopyFormData = cloneDeep(unref(formData.value)) as ProductSpuApi.Spu + deepCopyFormData.skus!.forEach((item) => { + // 给sku name赋值 + item.name = deepCopyFormData.name + // sku相关价格元转分 + item.price = convertToInteger(item.price) + item.marketPrice = convertToInteger(item.marketPrice) + item.costPrice = convertToInteger(item.costPrice) + item.firstBrokeragePrice = convertToInteger(item.firstBrokeragePrice) + item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice) + }) + // 处理轮播图列表 + const newSliderPicUrls: any[] = [] + deepCopyFormData.sliderPicUrls!.forEach((item: any) => { + // 如果是前端选的图 + typeof item === 'object' ? newSliderPicUrls.push(item.url) : newSliderPicUrls.push(item) + }) + deepCopyFormData.sliderPicUrls = newSliderPicUrls + // 校验都通过后提交表单 + const data = deepCopyFormData as ProductSpuApi.Spu + const id = params.id as unknown as number + if (!id) { + await ProductSpuApi.createSpu(data) + message.success(t('common.createSuccess')) + } else { + await ProductSpuApi.updateSpu(data) + message.success(t('common.updateSuccess')) + } + close() + } finally { + formLoading.value = false + } +} + +/** 关闭按钮 */ +const close = () => { + delView(unref(currentRoute)) + push({ name: 'ProductSpu' }) +} + +/** 初始化 */ +onMounted(async () => { + await getDetail() +}) +</script> diff --git a/src/views/mall/product/spu/index.vue b/src/views/mall/product/spu/index.vue new file mode 100644 index 0000000..0451ef3 --- /dev/null +++ b/src/views/mall/product/spu/index.vue @@ -0,0 +1,457 @@ +<!-- 商品中心 - 商品列表 --> +<template> + <doc-alert title="【商品】商品 SPU 与 SKU" url="https://doc.iocoder.cn/mall/product-spu-sku/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="商品名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入商品名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="商品分类" prop="categoryId"> + <el-cascader + v-model="queryParams.categoryId" + :options="categoryList" + :props="defaultProps" + class="w-1/1" + clearable + filterable + placeholder="请选择商品分类" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['product:spu:create']" + plain + type="primary" + @click="openForm(undefined)" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button + v-hasPermi="['product:spu:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-tabs v-model="queryParams.tabType" @tab-click="handleTabClick"> + <el-tab-pane + v-for="item in tabsData" + :key="item.type" + :label="item.name + '(' + item.count + ')'" + :name="item.type" + /> + </el-tabs> + <el-table v-loading="loading" :data="list"> + <el-table-column type="expand"> + <template #default="{ row }"> + <el-form class="spu-table-expand" label-position="left"> + <el-row> + <el-col :span="24"> + <el-row> + <el-col :span="8"> + <el-form-item label="商品分类:"> + <span>{{ formatCategoryName(row.categoryId) }}</span> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="市场价:"> + <span>{{ fenToYuan(row.marketPrice) }}</span> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="成本价:"> + <span>{{ fenToYuan(row.costPrice) }}</span> + </el-form-item> + </el-col> + </el-row> + </el-col> + </el-row> + <el-row> + <el-col :span="24"> + <el-row> + <el-col :span="8"> + <el-form-item label="浏览量:"> + <span>{{ row.browseCount }}</span> + </el-form-item> + </el-col> + <el-col :span="8"> + <el-form-item label="虚拟销量:"> + <span>{{ row.virtualSalesCount }}</span> + </el-form-item> + </el-col> + </el-row> + </el-col> + </el-row> + </el-form> + </template> + </el-table-column> + <el-table-column label="商品编号" min-width="140" prop="id" /> + <el-table-column label="商品信息" min-width="300"> + <template #default="{ row }"> + <div class="flex"> + <el-image + fit="cover" + :src="row.picUrl" + class="flex-none w-50px h-50px" + @click="imagePreview(row.picUrl)" + /> + <div class="ml-4 overflow-hidden"> + <el-tooltip effect="dark" :content="row.name" placement="top"> + <div> + {{ row.name }} + </div> + </el-tooltip> + </div> + </div> + </template> + </el-table-column> + <el-table-column align="center" label="价格" min-width="160" prop="price"> + <template #default="{ row }"> ¥ {{ fenToYuan(row.price) }}</template> + </el-table-column> + <el-table-column align="center" label="销量" min-width="90" prop="salesCount" /> + <el-table-column align="center" label="库存" min-width="90" prop="stock" /> + <el-table-column align="center" label="排序" min-width="70" prop="sort" /> + <el-table-column align="center" label="销售状态" min-width="80"> + <template #default="{ row }"> + <template v-if="row.status >= 0"> + <el-switch + v-model="row.status" + :active-value="1" + :inactive-value="0" + active-text="上架" + inactive-text="下架" + inline-prompt + @change="handleStatusChange(row)" + /> + </template> + <template v-else> + <el-tag type="info">回收站</el-tag> + </template> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" fixed="right" label="操作" min-width="200"> + <template #default="{ row }"> + <el-button link type="primary" @click="openDetail(row.id)"> 详情 </el-button> + <el-button + v-hasPermi="['product:spu:update']" + link + type="primary" + @click="openForm(row.id)" + > + 修改 + </el-button> + <template v-if="queryParams.tabType === 4"> + <el-button + v-hasPermi="['product:spu:delete']" + link + type="danger" + @click="handleDelete(row.id)" + > + 删除 + </el-button> + <el-button + v-hasPermi="['product:spu:update']" + link + type="primary" + @click="handleStatus02Change(row, ProductSpuStatusEnum.DISABLE.status)" + > + 恢复 + </el-button> + </template> + <template v-else> + <el-button + v-hasPermi="['product:spu:update']" + link + type="danger" + @click="handleStatus02Change(row, ProductSpuStatusEnum.RECYCLE.status)" + > + 回收 + </el-button> + </template> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> +<script lang="ts" setup> +import { TabsPaneContext } from 'element-plus' +import { createImageViewer } from '@/components/ImageViewer' +import { dateFormatter } from '@/utils/formatTime' +import { defaultProps, handleTree, treeToString } from '@/utils/tree' +import { ProductSpuStatusEnum } from '@/utils/constants' +import { fenToYuan } from '@/utils' +import download from '@/utils/download' +import * as ProductSpuApi from '@/api/mall/product/spu' +import * as ProductCategoryApi from '@/api/mall/product/category' + +defineOptions({ name: 'ProductSpu' }) + +const message = useMessage() // 消息弹窗 +const route = useRoute() // 路由 +const { t } = useI18n() // 国际化 +const { push } = useRouter() // 路由跳转 + +const loading = ref(false) // 列表的加载中 +const exportLoading = ref(false) // 导出的加载中 +const total = ref(0) // 列表的总页数 +const list = ref<ProductSpuApi.Spu[]>([]) // 列表的数据 +// tabs 数据 +const tabsData = ref([ + { + name: '出售中', + type: 0, + count: 0 + }, + { + name: '仓库中', + type: 1, + count: 0 + }, + { + name: '已售罄', + type: 2, + count: 0 + }, + { + name: '警戒库存', + type: 3, + count: 0 + }, + { + name: '回收站', + type: 4, + count: 0 + } +]) + +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + tabType: 0, + name: '', + categoryId: undefined, + createTime: undefined +}) // 查询参数 +const queryFormRef = ref() // 搜索的表单Ref + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductSpuApi.getSpuPage(queryParams.value) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 切换 Tab */ +const handleTabClick = (tab: TabsPaneContext) => { + queryParams.value.tabType = tab.paneName as number + getList() +} + +/** 获得每个 Tab 的数量 */ +const getTabsCount = async () => { + const res = await ProductSpuApi.getTabsCount() + for (let objName in res) { + tabsData.value[Number(objName)].count = res[objName] + } +} + +/** 添加到仓库 / 回收站的状态 */ +const handleStatus02Change = async (row: any, newStatus: number) => { + try { + // 二次确认 + const text = newStatus === ProductSpuStatusEnum.RECYCLE.status ? '加入到回收站' : '恢复到仓库' + await message.confirm(`确认要"${row.name}"${text}吗?`) + // 发起修改 + await ProductSpuApi.updateStatus({ id: row.id, status: newStatus }) + message.success(text + '成功') + // 刷新 tabs 数据 + await getTabsCount() + // 刷新列表 + await getList() + } catch {} +} + +/** 更新上架/下架状态 */ +const handleStatusChange = async (row: any) => { + try { + // 二次确认 + const text = row.status ? '上架' : '下架' + await message.confirm(`确认要${text}"${row.name}"吗?`) + // 发起修改 + await ProductSpuApi.updateStatus({ id: row.id, status: row.status }) + message.success(text + '成功') + // 刷新 tabs 数据 + await getTabsCount() + // 刷新列表 + await getList() + } catch { + // 异常时,需要重置回之前的值 + row.status = + row.status === ProductSpuStatusEnum.DISABLE.status + ? ProductSpuStatusEnum.ENABLE.status + : ProductSpuStatusEnum.DISABLE.status + } +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ProductSpuApi.deleteSpu(id) + message.success(t('common.delSuccess')) + // 刷新tabs数据 + await getTabsCount() + // 刷新列表 + await getList() + } catch {} +} + +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 新增或修改 */ +const openForm = (id?: number) => { + // 修改 + if (typeof id === 'number') { + push({ name: 'ProductSpuEdit', params: { id } }) + return + } + // 新增 + push({ name: 'ProductSpuAdd' }) +} + +/** 查看商品详情 */ +const openDetail = (id: number) => { + push({ name: 'ProductSpuDetail', params: { id } }) +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await ProductSpuApi.exportSpu(queryParams) + download.excel(data, '商品列表.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 获取分类的节点的完整结构 */ +const categoryList = ref() // 分类树 +const formatCategoryName = (categoryId: number) => { + return treeToString(categoryList.value, categoryId) +} + +/** 激活时 */ +onActivated(() => { + getList() +}) + +/** 初始化 **/ +onMounted(async () => { + // 解析路由的 categoryId + if (route.query.categoryId) { + queryParams.value.categoryId = Number(route.query.categoryId) + } + // 获得商品信息 + await getTabsCount() + await getList() + // 获得分类树 + const data = await ProductCategoryApi.getCategoryList({}) + categoryList.value = handleTree(data, 'id', 'parentId') +}) +</script> +<style lang="scss" scoped> +.spu-table-expand { + padding-left: 42px; + + :deep(.el-form-item__label) { + width: 82px; + font-weight: bold; + color: #99a9bf; + } +} +</style> diff --git a/src/views/mall/promotion/article/ArticleForm.vue b/src/views/mall/promotion/article/ArticleForm.vue new file mode 100644 index 0000000..1e44fad --- /dev/null +++ b/src/views/mall/promotion/article/ArticleForm.vue @@ -0,0 +1,225 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="70%"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="110px" + > + <el-row> + <el-col :span="12"> + <el-form-item label="文章标题" prop="title"> + <el-input v-model="formData.title" placeholder="请输入文章标题" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="文章分类" prop="categoryId"> + <el-select v-model="formData.categoryId" placeholder="请选择"> + <el-option + v-for="item in categoryList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="文章作者" prop="author"> + <el-input v-model="formData.author" placeholder="请输入文章作者" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="文章简介" prop="introduction"> + <el-input v-model="formData.introduction" placeholder="请输入文章简介" /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="文章封面" prop="picUrl"> + <UploadImg v-model="formData.picUrl" height="80px" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="formData.sort" :min="0" clearable controls-position="right" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="是否热门" prop="recommendHot"> + <el-radio-group v-model="formData.recommendHot"> + <el-radio + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="是否轮播图" prop="recommendBanner"> + <el-radio-group v-model="formData.recommendBanner"> + <el-radio + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="商品关联" prop="spuId"> + <el-tag v-if="formData.spuId" class="mr-10px"> + {{ spuList.find((item) => item.id === formData.spuId)?.name }} + </el-tag> + <el-button @click="spuSelectRef?.open()">选择商品</el-button> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="文章内容"> + <Editor v-model="formData.content" height="150px" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <SpuSelect ref="spuSelectRef" @confirm="selectSpu" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getBoolDictOptions, getIntDictOptions } from '@/utils/dict' +import * as ArticleApi from '@/api/mall/promotion/article' +import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { SpuSelect } from '@/views/mall/promotion/components' + +defineOptions({ name: 'PromotionArticleForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + categoryId: undefined, + title: undefined, + author: undefined, + picUrl: undefined, + introduction: undefined, + sort: 0, + status: 0, + spuId: 0, + recommendHot: false, + recommendBanner: false, + content: undefined +}) +const formRules = reactive({ + categoryId: [{ required: true, message: '分类id不能为空', trigger: 'blur' }], + title: [{ required: true, message: '文章标题不能为空', trigger: 'blur' }], + picUrl: [{ required: true, message: '文章封面图片地址不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }], + spuId: [{ required: true, message: '商品关联id不能为空', trigger: 'blur' }], + recommendHot: [{ required: true, message: '是否热门(小程序)不能为空', trigger: 'blur' }], + recommendBanner: [{ required: true, message: '是否轮播图(小程序)不能为空', trigger: 'blur' }], + content: [{ required: true, message: '文章内容不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const spuSelectRef = ref() // 商品和属性选择 Ref +const selectSpu = (spuId: number) => { + formData.value.spuId = spuId +} +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ArticleApi.getArticle(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ArticleApi.ArticleVO + if (formType.value === 'create') { + await ArticleApi.createArticle(data) + message.success(t('common.createSuccess')) + } else { + await ArticleApi.updateArticle(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + categoryId: undefined, + title: undefined, + author: undefined, + picUrl: undefined, + introduction: undefined, + sort: 0, + status: 0, + spuId: 0, + recommendHot: false, + recommendBanner: false, + content: undefined + } + formRef.value?.resetFields() +} + +const categoryList = ref<ArticleCategoryApi.ArticleCategoryVO[]>([]) +const spuList = ref<ProductSpuApi.Spu[]>([]) +onMounted(async () => { + categoryList.value = + (await ArticleCategoryApi.getSimpleArticleCategoryList()) as ArticleCategoryApi.ArticleCategoryVO[] + spuList.value = (await ProductSpuApi.getSpuSimpleList()) as ProductSpuApi.Spu[] +}) +</script> diff --git a/src/views/mall/promotion/article/category/ArticleCategoryForm.vue b/src/views/mall/promotion/article/category/ArticleCategoryForm.vue new file mode 100644 index 0000000..f8da3bc --- /dev/null +++ b/src/views/mall/promotion/article/category/ArticleCategoryForm.vue @@ -0,0 +1,122 @@ +<template> + <doc-alert title="【营销】内容管理" url="https://doc.iocoder.cn/mall/promotion-content/" /> + + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item label="分类名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入分类名称" /> + </el-form-item> + <el-form-item label="图标地址" prop="picUrl"> + <UploadImg v-model="formData.picUrl" height="80px" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="formData.sort" :min="0" clearable controls-position="right" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory' +import { CommonStatusEnum } from '@/utils/constants' + +defineOptions({ name: 'PromotionArticleCategoryForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + picUrl: undefined, + status: undefined, + sort: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ArticleCategoryApi.getArticleCategory(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ArticleCategoryApi.ArticleCategoryVO + if (formType.value === 'create') { + await ArticleCategoryApi.createArticleCategory(data) + message.success(t('common.createSuccess')) + } else { + await ArticleCategoryApi.updateArticleCategory(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + picUrl: undefined, + status: CommonStatusEnum.ENABLE, + sort: 0 + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/promotion/article/category/index.vue b/src/views/mall/promotion/article/category/index.vue new file mode 100644 index 0000000..73d1420 --- /dev/null +++ b/src/views/mall/promotion/article/category/index.vue @@ -0,0 +1,199 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="分类名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入分类名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['promotion:article-category:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="编号" prop="id" min-width="100" /> + <el-table-column align="center" label="分类名称" prop="name" min-width="240" /> + <el-table-column label="分类图图" min-width="80"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <el-table-column align="center" label="状态" prop="status" min-width="150"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column align="center" label="排序" prop="sort" min-width="150" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['promotion:article-category:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['promotion:article-category:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ArticleCategoryForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory' +import ArticleCategoryForm from './ArticleCategoryForm.vue' +import { createImageViewer } from '@/components/ImageViewer' + +defineOptions({ name: 'PromotionArticleCategory' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + status: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 分类图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ArticleCategoryApi.getArticleCategoryPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ArticleCategoryApi.deleteArticleCategory(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/article/index.vue b/src/views/mall/promotion/article/index.vue new file mode 100644 index 0000000..20ad4ce --- /dev/null +++ b/src/views/mall/promotion/article/index.vue @@ -0,0 +1,229 @@ +<template> + <doc-alert title="【营销】内容管理" url="https://doc.iocoder.cn/mall/promotion-content/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="80px" + > + <el-form-item label="文章分类" prop="categoryId"> + <el-select + v-model="queryParams.categoryId" + class="!w-240px" + placeholder="全部" + @keyup.enter="handleQuery" + > + <el-option + v-for="item in categoryList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="文章标题" prop="title"> + <el-input + v-model="queryParams.title" + class="!w-240px" + clearable + placeholder="请输入文章标题" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['promotion:article:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="封面" min-width="80" prop="picUrl"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <el-table-column align="center" label="标题" min-width="180" prop="title" /> + <el-table-column align="center" label="分类" min-width="180" prop="categoryId"> + <template #default="scope"> + {{ categoryList.find((item) => item.id === scope.row.categoryId)?.name }} + </template> + </el-table-column> + <el-table-column align="center" label="浏览量" min-width="180" prop="browseCount" /> + <el-table-column align="center" label="作者" min-width="180" prop="author" /> + <el-table-column align="center" label="文章简介" min-width="250" prop="introduction" /> + <el-table-column align="center" label="排序" min-width="60" prop="sort" /> + <el-table-column align="center" label="状态" min-width="60" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="发布时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" fixed="right" label="操作" width="120"> + <template #default="scope"> + <el-button + v-hasPermi="['promotion:article:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['promotion:article:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ArticleForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as ArticleApi from '@/api/mall/promotion/article' +import ArticleForm from './ArticleForm.vue' +import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { createImageViewer } from '@/components/ImageViewer' + +defineOptions({ name: 'PromotionArticle' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + categoryId: undefined, + title: null, + status: undefined, + spuId: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +/** 文章封面预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ArticleApi.getArticlePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ArticleApi.deleteArticle(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +const categoryList = ref<ArticleCategoryApi.ArticleCategoryVO[]>([]) +const spuList = ref<ProductSpuApi.Spu[]>([]) +onMounted(async () => { + await getList() + // 加载分类、商品列表 + categoryList.value = + (await ArticleCategoryApi.getSimpleArticleCategoryList()) as ArticleCategoryApi.ArticleCategoryVO[] + spuList.value = (await ProductSpuApi.getSpuSimpleList()) as ProductSpuApi.Spu[] +}) +</script> diff --git a/src/views/mall/promotion/banner/BannerForm.vue b/src/views/mall/promotion/banner/BannerForm.vue new file mode 100644 index 0000000..03bca0f --- /dev/null +++ b/src/views/mall/promotion/banner/BannerForm.vue @@ -0,0 +1,159 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-row> + <el-col :span="24"> + <el-form-item label="标题" prop="title"> + <el-input v-model="formData.title" placeholder="请输入 Banner 标题" /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="图片" prop="picUrl"> + <UploadImg v-model="formData.picUrl" /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="跳转地址" prop="url"> + <el-input v-model="formData.url" placeholder="请输入跳转地址" /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="formData.sort" :min="0" clearable controls-position="right" /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="位置" prop="position"> + <el-radio-group v-model="formData.position"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_BANNER_POSITION)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="描述" prop="memo"> + <el-input v-model="formData.memo" placeholder="请输入描述" type="textarea" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as BannerApi from '@/api/mall/market/banner' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + title: undefined, + picUrl: undefined, + status: 0, + position: 1, + url: undefined, + sort: 0, + memo: undefined +}) +const formRules = reactive({ + title: [{ required: true, message: 'Banner 标题不能为空', trigger: 'blur' }], + picUrl: [{ required: true, message: '图片 URL 不能为空', trigger: 'blur' }], + status: [{ required: true, message: '活动状态不能为空', trigger: 'blur' }], + position: [{ required: true, message: '位置不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }], + url: [{ required: true, message: '跳转地址不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await BannerApi.getBanner(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as BannerApi.BannerVO + if (formType.value === 'create') { + await BannerApi.createBanner(data) + message.success(t('common.createSuccess')) + } else { + await BannerApi.updateBanner(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + title: undefined, + picUrl: undefined, + status: 0, + position: 1, + url: undefined, + sort: 0, + memo: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/promotion/banner/index.vue b/src/views/mall/promotion/banner/index.vue new file mode 100644 index 0000000..e25431a --- /dev/null +++ b/src/views/mall/promotion/banner/index.vue @@ -0,0 +1,206 @@ +<template> + <doc-alert title="【营销】内容管理" url="https://doc.iocoder.cn/mall/promotion-content/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="100px" + > + <el-form-item label="Banner标题" prop="title"> + <el-input + v-model="queryParams.title" + class="!w-240px" + clearable + placeholder="请输入Banner标题" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="活动状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="全部"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['promotion:banner:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="Banner标题" prop="title" /> + <el-table-column align="center" label="图片" min-width="80" prop="picUrl"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <el-table-column align="center" label="状态" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column align="center" label="定位" prop="position"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_BANNER_POSITION" :value="scope.row.position" /> + </template> + </el-table-column> + <el-table-column align="center" label="跳转地址" prop="url" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="排序" prop="sort" /> + <el-table-column align="center" label="描述" prop="memo" /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['promotion:banner:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['promotion:banner:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <BannerForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as BannerApi from '@/api/mall/market/banner' +import BannerForm from './BannerForm.vue' +import { createImageViewer } from '@/components/ImageViewer' + +defineOptions({ name: 'Banner' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + title: null, + status: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 文章封面预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BannerApi.getBannerPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await BannerApi.deleteBanner(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/bargain/activity/BargainActivityForm.vue b/src/views/mall/promotion/bargain/activity/BargainActivityForm.vue new file mode 100644 index 0000000..d8d1463 --- /dev/null +++ b/src/views/mall/promotion/bargain/activity/BargainActivityForm.vue @@ -0,0 +1,233 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%"> + <Form + ref="formRef" + v-loading="formLoading" + :is-col="true" + :rules="rules" + :schema="allSchemas.formSchema" + class="mt-10px" + > + <template #spuId> + <el-button @click="spuSelectRef.open()">选择商品</el-button> + <SpuAndSkuList + ref="spuAndSkuListRef" + :rule-config="ruleConfig" + :spu-list="spuList" + :spu-property-list-p="spuPropertyList" + > + <el-table-column align="center" label="砍价起始价格(元)" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number + v-model="sku.productConfig.bargainFirstPrice" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + /> + </template> + </el-table-column> + <el-table-column align="center" label="砍价底价(元)" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number + v-model="sku.productConfig.bargainMinPrice" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + /> + </template> + </el-table-column> + <el-table-column align="center" label="活动库存" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number v-model="sku.productConfig.stock" class="w-100%" /> + </template> + </el-table-column> + </SpuAndSkuList> + </template> + </Form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <SpuSelect ref="spuSelectRef" :isSelectSku="true" :radio="true" @confirm="selectSpu" /> +</template> +<script lang="ts" setup> +import * as BargainActivityApi from '@/api/mall/promotion/bargain/bargainActivity' +import { BargainProductVO } from '@/api/mall/promotion/bargain/bargainActivity' +import { allSchemas, rules } from './bargainActivity.data' +import { SpuAndSkuList, SpuProperty, SpuSelect } from '@/views/mall/promotion/components' +import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { convertToInteger, formatToFraction } from '@/utils' +import { cloneDeep } from 'lodash-es' + +defineOptions({ name: 'PromotionBargainActivityForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formRef = ref() // 表单 Ref + +// ================= 商品选择相关 ================= + +const spuSelectRef = ref() // 商品和属性选择 Ref +const spuAndSkuListRef = ref() // sku 秒杀配置组件Ref +const spuList = ref<BargainActivityApi.SpuExtension[]>([]) // 选择的 spu +const spuPropertyList = ref<SpuProperty<BargainActivityApi.SpuExtension>[]>([]) +const ruleConfig: RuleConfig[] = [ + { + name: 'productConfig.bargainFirstPrice', + rule: (arg) => arg > 0, + message: '商品砍价起始价格不能小于 0 !!!' + }, + { + name: 'productConfig.bargainMinPrice', + rule: (arg) => arg >= 0, + message: '商品砍价底价不能小于 0 !!!' + }, + { + name: 'productConfig.stock', + rule: (arg) => arg >= 1, + message: '商品活动库存不能小于 1 !!!' + } +] +const selectSpu = (spuId: number, skuIds: number[]) => { + formRef.value.setValues({ spuId }) + getSpuDetails(spuId, skuIds) +} +/** + * 获取 SPU 详情 + */ +const getSpuDetails = async ( + spuId: number, + skuIds: number[] | undefined, + products?: BargainProductVO[] +) => { + const spuProperties: SpuProperty<BargainActivityApi.SpuExtension>[] = [] + const res = (await ProductSpuApi.getSpuDetailList([spuId])) as BargainActivityApi.SpuExtension[] + if (res.length == 0) { + return + } + spuList.value = [] + // 因为只能选择一个 + const spu = res[0] + const selectSkus = + typeof skuIds === 'undefined' ? spu?.skus : spu?.skus?.filter((sku) => skuIds.includes(sku.id!)) + selectSkus?.forEach((sku) => { + let config: BargainProductVO = { + spuId: spu.id!, + skuId: sku.id!, + bargainFirstPrice: 1, + bargainMinPrice: 1, + stock: 1 + } + if (typeof products !== 'undefined') { + const product = products.find((item) => item.skuId === sku.id) + if (product) { + product.bargainFirstPrice = formatToFraction(product.bargainFirstPrice) + product.bargainMinPrice = formatToFraction(product.bargainMinPrice) + } + config = product || config + } + sku.productConfig = config + }) + spu.skus = selectSkus as BargainActivityApi.SkuExtension[] + spuProperties.push({ + spuId: spu.id!, + spuDetail: spu, + propertyList: getPropertyList(spu) + }) + spuList.value.push(spu) + spuPropertyList.value = spuProperties +} + +// ================= end ================= + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + await resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = (await BargainActivityApi.getBargainActivity( + id + )) as BargainActivityApi.BargainActivityVO + // 用户每次砍价金额分转元, 分转元 + data.randomMinPrice = formatToFraction(data.randomMinPrice) + data.randomMaxPrice = formatToFraction(data.randomMaxPrice) + // 对齐活动商品处理结构 + await getSpuDetails( + data.spuId!, + [data.skuId], + [ + { + spuId: data.spuId!, + skuId: data.skuId, + bargainFirstPrice: data.bargainFirstPrice, // 砍价起始价格,单位分 + bargainMinPrice: data.bargainMinPrice, // 砍价底价 + stock: data.stock // 活动库存 + } + ] + ) + formRef.value.setValues(data) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 重置表单 */ +const resetForm = async () => { + spuList.value = [] + spuPropertyList.value = [] + await nextTick() + formRef.value.getElFormRef().resetFields() +} + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.getElFormRef().validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = cloneDeep(formRef.value.formModel) as BargainActivityApi.BargainActivityVO + const products = spuAndSkuListRef.value.getSkuConfigs('productConfig') + products.forEach((item: BargainProductVO) => { + // 砍价价格元转分 + item.bargainFirstPrice = convertToInteger(item.bargainFirstPrice) + item.bargainMinPrice = convertToInteger(item.bargainMinPrice) + }) + // 用户每次砍价金额分转元, 元转分 + data.randomMinPrice = convertToInteger(data.randomMinPrice) + data.randomMaxPrice = convertToInteger(data.randomMaxPrice) + const formData = { ...data, ...products[0] } + if (formType.value === 'create') { + await BargainActivityApi.createBargainActivity(formData) + message.success(t('common.createSuccess')) + } else { + await BargainActivityApi.updateBargainActivity(formData) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} +</script> diff --git a/src/views/mall/promotion/bargain/activity/bargainActivity.data.ts b/src/views/mall/promotion/bargain/activity/bargainActivity.data.ts new file mode 100644 index 0000000..2b124c4 --- /dev/null +++ b/src/views/mall/promotion/bargain/activity/bargainActivity.data.ts @@ -0,0 +1,146 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter2 } from '@/utils/formatTime' + +// 表单校验 +export const rules = reactive({ + name: [required], + startTime: [required], + endTime: [required], + helpMaxCount: [required], + bargainCount: [required], + singleLimitCount: [required] +}) + +// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '砍价活动名称', + field: 'name', + isSearch: true, + isTable: false, + form: { + colProps: { + span: 24 + } + } + }, + { + label: '活动开始时间', + field: 'startTime', + formatter: dateFormatter2, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD', + type: 'daterange' + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 120 + } + }, + { + label: '活动结束时间', + field: 'endTime', + formatter: dateFormatter2, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD', + type: 'daterange' + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 120 + } + }, + { + label: '砍价人数', + field: 'helpMaxCount', + isSearch: false, + form: { + component: 'InputNumber', + labelMessage: '参与人数不能少于两人', + value: 2 + } + }, + { + label: '最大帮砍次数', + field: 'bargainCount', + isSearch: false, + form: { + component: 'InputNumber', + labelMessage: '参与人数不能少于两人', + value: 2 + } + }, + { + label: '总限购数量', + field: 'totalLimitCount', + isSearch: false, + form: { + component: 'InputNumber', + labelMessage: '用户最大能发起砍价的次数', + value: 0 + } + }, + { + label: '砍价的最小金额', + field: 'randomMinPrice', + isSearch: false, + isTable: false, + form: { + component: 'InputNumber', + componentProps: { + min: 0, + precision: 2, + step: 0.1 + }, + labelMessage: '用户每次砍价的最小金额', + value: 0 + } + }, + { + label: '砍价的最大金额', + field: 'randomMaxPrice', + isSearch: false, + isTable: false, + form: { + component: 'InputNumber', + componentProps: { + min: 0, + precision: 2, + step: 0.1 + }, + labelMessage: '用户每次砍价的最大金额', + value: 0 + } + }, + { + label: '砍价商品', + field: 'spuId', + isSearch: false, + form: { + colProps: { + span: 24 + } + } + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) diff --git a/src/views/mall/promotion/bargain/activity/index.vue b/src/views/mall/promotion/bargain/activity/index.vue new file mode 100644 index 0000000..40449fe --- /dev/null +++ b/src/views/mall/promotion/bargain/activity/index.vue @@ -0,0 +1,234 @@ +<template> + <doc-alert title="【营销】砍价活动" url="https://doc.iocoder.cn/mall/promotion-bargain/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="活动名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入活动名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="活动状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择活动状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:bargain-activity:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="活动编号" prop="id" min-width="80" /> + <el-table-column label="活动名称" prop="name" min-width="140" /> + <el-table-column label="活动时间" min-width="210"> + <template #default="scope"> + {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }} + ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }} + </template> + </el-table-column> + <el-table-column label="商品图片" prop="spuName" min-width="80"> + <template #default="scope"> + <el-image + :src="scope.row.picUrl" + class="h-40px w-40px" + :preview-src-list="[scope.row.picUrl]" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="商品标题" prop="spuName" min-width="300" /> + <el-table-column + label="起始价格" + prop="bargainFirstPrice" + min-width="100" + :formatter="fenToYuanFormat" + /> + <el-table-column + label="砍价底价" + prop="bargainMinPrice" + min-width="100" + :formatter="fenToYuanFormat" + /> + <el-table-column label="总砍价人数" prop="recordUserCount" min-width="100" /> + <el-table-column label="成功砍价人数" prop="recordSuccessUserCount" min-width="110" /> + <el-table-column label="助力人数" prop="helpUserCount" min-width="100" /> + <el-table-column label="活动状态" align="center" prop="status" min-width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="库存" align="center" prop="stock" min-width="80" /> + <el-table-column label="总库存" align="center" prop="totalStock" min-width="80" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="150px" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['promotion:bargain-activity:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleClose(scope.row.id)" + v-if="scope.row.status === 0" + v-hasPermi="['promotion:bargain-activity:close']" + > + 关闭 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-else + v-hasPermi="['promotion:bargain-activity:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <BargainActivityForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as BargainActivityApi from '@/api/mall/promotion/bargain/bargainActivity' +import BargainActivityForm from './BargainActivityForm.vue' +import { formatDate } from '@/utils/formatTime' +import { fenToYuanFormat } from '@/utils/formatter' +import { closeBargainActivity } from '@/api/mall/promotion/bargain/bargainActivity' + +defineOptions({ name: 'PromotionBargainActivity' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + status: null +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BargainActivityApi.getBargainActivityPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +// TODO 芋艿:这里要改下 +/** 关闭按钮操作 */ +const handleClose = async (id: number) => { + try { + // 关闭的二次确认 + await message.confirm('确认关闭该砍价活动吗?') + // 发起关闭 + await BargainActivityApi.closeBargainActivity(id) + message.success('关闭成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await BargainActivityApi.closeBargainActivity(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + await getList() +}) +</script> diff --git a/src/views/mall/promotion/bargain/record/BargainRecordListDialog.vue b/src/views/mall/promotion/bargain/record/BargainRecordListDialog.vue new file mode 100644 index 0000000..9637ac8 --- /dev/null +++ b/src/views/mall/promotion/bargain/record/BargainRecordListDialog.vue @@ -0,0 +1,90 @@ +<template> + <Dialog v-model="dialogVisible" title="助力列表"> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="用户编号" prop="userId" min-width="80px" /> + <el-table-column label="用户头像" prop="avatar" min-width="80px"> + <template #default="scope"> + <el-avatar :src="scope.row.avatar" /> + </template> + </el-table-column> + <el-table-column label="用户昵称" prop="nickname" min-width="100px" /> + <el-table-column + label="砍价金额" + prop="reducePrice" + min-width="100px" + :formatter="fenToYuanFormat" + /> + <el-table-column + label="助力时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + </Dialog> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as BargainHelpApi from '@/api/mall/promotion/bargain/bargainHelp' +import { fenToYuanFormat } from '@/utils/formatter' + +/** 助力列表 */ +defineOptions({ name: 'BargainRecordListDialog' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + recordId: undefined +}) +const queryFormRef = ref() // 搜索的表单 + +/** 打开弹窗 */ +const dialogVisible = ref(false) // 弹窗的是否展示 +const open = async (recordId: any) => { + dialogVisible.value = true + queryParams.recordId = recordId + resetQuery() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BargainHelpApi.getBargainHelpPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + handleQuery() +} +</script> diff --git a/src/views/mall/promotion/bargain/record/index.vue b/src/views/mall/promotion/bargain/record/index.vue new file mode 100644 index 0000000..306d8ea --- /dev/null +++ b/src/views/mall/promotion/bargain/record/index.vue @@ -0,0 +1,197 @@ +<template> + <doc-alert title="【营销】砍价活动" url="https://doc.iocoder.cn/mall/promotion-bargain/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="砍价状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择砍价状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_BARGAIN_RECORD_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:bargain-record:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['promotion:bargain-record:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" min-width="50" prop="id" /> + <el-table-column label="发起用户" min-width="120"> + <template #default="scope"> + <el-image + :src="scope.row.avatar" + class="h-20px w-20px" + :preview-src-list="[scope.row.avatar]" + preview-teleported + /> + {{ scope.row.nickname }} + </template> + </el-table-column> + <el-table-column + label="发起时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="砍价活动" min-width="150" prop="activity.name" /> + <el-table-column + label="最低价" + min-width="100" + prop="activity.bargainMinPrice" + :formatter="fenToYuanFormat" + /> + <el-table-column + label="当前价" + min-width="100" + prop="bargainPrice" + :formatter="fenToYuanFormat" + /> + <el-table-column label="总砍价次数" min-width="100" prop="activity.helpMaxCount" /> + <el-table-column label="剩余砍价次数" min-width="100" prop="helpCount" /> + <el-table-column label="砍价状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_BARGAIN_RECORD_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="结束时间" + align="center" + prop="endTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="订单编号" align="center" prop="orderId" /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openRecordListDialog(scope.row.id)" + v-hasPermi="['promotion:bargain-help:query']" + > + 助力 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗 --> + <BargainRecordListDialog ref="recordListDialogRef" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as BargainRecordApi from '@/api/mall/promotion/bargain/bargainRecord' +import { fenToYuanFormat } from '@/utils/formatter' +import BargainRecordListDialog from './BargainRecordListDialog.vue' + +defineOptions({ name: 'PromotionBargainRecord' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + status: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BargainRecordApi.getBargainRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 打开[助力]弹窗 */ +const recordListDialogRef = ref() +const openRecordListDialog = (id?: number) => { + recordListDialogRef.value.open(id) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/combination/activity/CombinationActivityForm.vue b/src/views/mall/promotion/combination/activity/CombinationActivityForm.vue new file mode 100644 index 0000000..5b6e582 --- /dev/null +++ b/src/views/mall/promotion/combination/activity/CombinationActivityForm.vue @@ -0,0 +1,187 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%"> + <Form + ref="formRef" + v-loading="formLoading" + :is-col="true" + :rules="rules" + :schema="allSchemas.formSchema" + class="mt-10px" + > + <template #spuId> + <el-button @click="spuSelectRef.open()">选择商品</el-button> + <SpuAndSkuList + ref="spuAndSkuListRef" + :rule-config="ruleConfig" + :spu-list="spuList" + :spu-property-list-p="spuPropertyList" + > + <el-table-column align="center" label="拼团价格(元)" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number + v-model="sku.productConfig.combinationPrice" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + /> + </template> + </el-table-column> + </SpuAndSkuList> + </template> + </Form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <SpuSelect ref="spuSelectRef" :isSelectSku="true" @confirm="selectSpu" /> +</template> +<script lang="ts" setup> +import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity' +import { CombinationProductVO } from '@/api/mall/promotion/combination/combinationActivity' +import { allSchemas, rules } from './combinationActivity.data' +import { SpuAndSkuList, SpuProperty, SpuSelect } from '@/views/mall/promotion/components' +import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { convertToInteger, formatToFraction } from '@/utils' +import { cloneDeep } from 'lodash-es' + +defineOptions({ name: 'PromotionCombinationActivityForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formRef = ref() // 表单 Ref + +// ================= 商品选择相关 ================= + +const spuSelectRef = ref() // 商品和属性选择 Ref +const spuAndSkuListRef = ref() // sku 秒杀配置组件Ref +const spuList = ref<CombinationActivityApi.SpuExtension[]>([]) // 选择的 spu +const spuPropertyList = ref<SpuProperty<CombinationActivityApi.SpuExtension>[]>([]) +const ruleConfig: RuleConfig[] = [ + { + name: 'productConfig.combinationPrice', + rule: (arg) => arg >= 0.01, + message: '商品拼团价格不能小于0.01 !!!' + } +] +const selectSpu = (spuId: number, skuIds: number[]) => { + formRef.value.setValues({ spuId }) + getSpuDetails(spuId, skuIds) +} +/** + * 获取 SPU 详情 + */ +const getSpuDetails = async ( + spuId: number, + skuIds: number[] | undefined, + products?: CombinationProductVO[] +) => { + const spuProperties: SpuProperty<CombinationActivityApi.SpuExtension>[] = [] + const res = (await ProductSpuApi.getSpuDetailList([ + spuId + ])) as CombinationActivityApi.SpuExtension[] + if (res.length == 0) { + return + } + spuList.value = [] + // 因为只能选择一个 + const spu = res[0] + const selectSkus = + typeof skuIds === 'undefined' ? spu?.skus : spu?.skus?.filter((sku) => skuIds.includes(sku.id!)) + selectSkus?.forEach((sku) => { + let config: CombinationProductVO = { + spuId: spu.id!, + skuId: sku.id!, + combinationPrice: 0 + } + if (typeof products !== 'undefined') { + const product = products.find((item) => item.skuId === sku.id) + if (product) { + product.combinationPrice = formatToFraction(product.combinationPrice) + } + config = product || config + } + sku.productConfig = config + }) + spu.skus = selectSkus as CombinationActivityApi.SkuExtension[] + spuProperties.push({ + spuId: spu.id!, + spuDetail: spu, + propertyList: getPropertyList(spu) + }) + spuList.value.push(spu) + spuPropertyList.value = spuProperties +} + +// ================= end ================= + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + await resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = (await CombinationActivityApi.getCombinationActivity( + id + )) as CombinationActivityApi.CombinationActivityVO + await getSpuDetails(data.spuId!, data.products?.map((sku) => sku.skuId), data.products) + formRef.value.setValues(data) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 重置表单 */ +const resetForm = async () => { + spuList.value = [] + spuPropertyList.value = [] + await nextTick() + formRef.value.getElFormRef().resetFields() +} + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.getElFormRef().validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + // 获得拼团商品配置 + const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig')) + products.forEach((item: CombinationActivityApi.CombinationProductVO) => { + item.combinationPrice = convertToInteger(item.combinationPrice) + }) + const data = cloneDeep(formRef.value.formModel) as CombinationActivityApi.CombinationActivityVO + data.products = products + // 真正提交 + if (formType.value === 'create') { + await CombinationActivityApi.createCombinationActivity(data) + message.success(t('common.createSuccess')) + } else { + await CombinationActivityApi.updateCombinationActivity(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} +</script> diff --git a/src/views/mall/promotion/combination/activity/combinationActivity.data.ts b/src/views/mall/promotion/combination/activity/combinationActivity.data.ts new file mode 100644 index 0000000..dd3e48f --- /dev/null +++ b/src/views/mall/promotion/combination/activity/combinationActivity.data.ts @@ -0,0 +1,140 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter2 } from '@/utils/formatTime' + +// 表单校验 +export const rules = reactive({ + name: [required], + totalLimitCount: [required], + singleLimitCount: [required], + startTime: [required], + endTime: [required], + userSize: [required], + limitDuration: [required], + virtualGroup: [required] +}) + +// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '拼团名称', + field: 'name', + isSearch: true, + isTable: false, + form: { + colProps: { + span: 24 + } + } + }, + { + label: '活动开始时间', + field: 'startTime', + formatter: dateFormatter2, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD', + type: 'daterange' + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 120 + } + }, + { + label: '活动结束时间', + field: 'endTime', + formatter: dateFormatter2, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD', + type: 'daterange' + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 120 + } + }, + { + label: '参与人数', + field: 'userSize', + isSearch: false, + form: { + component: 'InputNumber', + labelMessage: '参与人数不能少于两人', + value: 2 + } + }, + { + label: '限制时长', + field: 'limitDuration', + isSearch: false, + isTable: false, + form: { + component: 'InputNumber', + labelMessage: '限制时长(小时)', + componentProps: { + placeholder: '请输入限制时长(小时)' + } + } + }, + { + label: '总限购数量', + field: 'totalLimitCount', + isSearch: false, + isTable: false, + form: { + component: 'InputNumber', + value: 0 + } + }, + { + label: '单次限购数量', + field: 'singleLimitCount', + isSearch: false, + isTable: false, + form: { + component: 'InputNumber', + value: 0 + } + }, + { + label: '虚拟成团', + field: 'virtualGroup', + dictType: DICT_TYPE.INFRA_BOOLEAN_STRING, + dictClass: 'boolean', + isSearch: true, + form: { + component: 'Radio', + value: false + } + }, + { + label: '拼团商品', + field: 'spuId', + isSearch: false, + form: { + colProps: { + span: 24 + } + } + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) diff --git a/src/views/mall/promotion/combination/activity/index.vue b/src/views/mall/promotion/combination/activity/index.vue new file mode 100644 index 0000000..02c7de2 --- /dev/null +++ b/src/views/mall/promotion/combination/activity/index.vue @@ -0,0 +1,236 @@ +<template> + <doc-alert title="【营销】拼团活动" url="https://doc.iocoder.cn/mall/promotion-combination/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="活动名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入活动名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="活动状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择活动状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:combination-activity:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="活动编号" prop="id" min-width="80" /> + <el-table-column label="活动名称" prop="name" min-width="140" /> + <el-table-column label="活动时间" min-width="210"> + <template #default="scope"> + {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }} + ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }} + </template> + </el-table-column> + <el-table-column label="商品图片" prop="spuName" min-width="80"> + <template #default="scope"> + <el-image + :src="scope.row.picUrl" + class="h-40px w-40px" + :preview-src-list="[scope.row.picUrl]" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="商品标题" prop="spuName" min-width="300" /> + <el-table-column + label="原价" + prop="marketPrice" + min-width="100" + :formatter="fenToYuanFormat" + /> + <el-table-column label="拼团价" prop="seckillPrice" min-width="100"> + <template #default="scope"> + {{ formatCombinationPrice(scope.row.products) }} + </template> + </el-table-column> + <el-table-column label="开团组数" prop="groupCount" min-width="100" /> + <el-table-column label="成团组数" prop="groupSuccessCount" min-width="100" /> + <el-table-column label="购买次数" prop="recordCount" min-width="100" /> + <el-table-column label="活动状态" align="center" prop="status" min-width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="150px" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['promotion:combination-activity:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleClose(scope.row.id)" + v-if="scope.row.status === 0" + v-hasPermi="['promotion:combination-activity:close']" + > + 关闭 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-else + v-hasPermi="['promotion:combination-activity:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <CombinationActivityForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity' +import CombinationActivityForm from './CombinationActivityForm.vue' +import { formatDate } from '@/utils/formatTime' +import { fenToYuanFormat } from '@/utils/formatter' +import { fenToYuan } from '@/utils' + +defineOptions({ name: 'PromotionBargainActivity' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + status: null +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CombinationActivityApi.getCombinationActivityPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +// TODO 芋艿:这里要改下 +/** 关闭按钮操作 */ +const handleClose = async (id: number) => { + try { + // 关闭的二次确认 + await message.confirm('确认关闭该秒杀活动吗?') + // 发起关闭 + await CombinationActivityApi.closeCombinationActivity(id) + message.success('关闭成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await CombinationActivityApi.deleteCombinationActivity(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +const formatCombinationPrice = (products) => { + const combinationPrice = Math.min(...products.map((item) => item.combinationPrice)) + return `¥${fenToYuan(combinationPrice)}` +} + +/** 初始化 **/ +onMounted(async () => { + await getList() +}) +</script> diff --git a/src/views/mall/promotion/combination/record/CombinationRecordListDialog.vue b/src/views/mall/promotion/combination/record/CombinationRecordListDialog.vue new file mode 100644 index 0000000..13e04a1 --- /dev/null +++ b/src/views/mall/promotion/combination/record/CombinationRecordListDialog.vue @@ -0,0 +1,89 @@ +<template> + <Dialog v-model="dialogVisible" title="拼团列表" width="950"> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="编号" prop="id" min-width="50" /> + <el-table-column align="center" label="头像" prop="avatar" min-width="80"> + <template #default="scope"> + <el-avatar :src="scope.row.avatar" /> + </template> + </el-table-column> + <el-table-column align="center" label="昵称" prop="nickname" min-width="100" /> + <el-table-column align="center" label="开团团长" prop="headId" min-width="100"> + <template #default="{ row }: { row: CombinationRecordApi.CombinationRecordVO }"> + <el-tag> {{ row.headId === 0 ? '团长' : '团员' }} </el-tag> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="参团时间" + prop="createTime" + width="180" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="结束时间" + prop="endTime" + width="180" + /> + <el-table-column align="center" label="拼团状态" prop="status" min-width="150"> + <template #default="scope"> + <dict-tag + :type="DICT_TYPE.PROMOTION_COMBINATION_RECORD_STATUS" + :value="scope.row.status" + /> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + </Dialog> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as CombinationRecordApi from '@/api/mall/promotion/combination/combinationRecord' +import { DICT_TYPE } from '@/utils/dict' + +/** 助力列表 */ +defineOptions({ name: 'CombinationRecordListDialog' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + headId: undefined +}) + +/** 打开弹窗 */ +const dialogVisible = ref(false) // 弹窗的是否展示 +const open = async (headId: any) => { + dialogVisible.value = true + queryParams.headId = headId + await getList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CombinationRecordApi.getCombinationRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} +</script> diff --git a/src/views/mall/promotion/combination/record/index.vue b/src/views/mall/promotion/combination/record/index.vue new file mode 100644 index 0000000..223a723 --- /dev/null +++ b/src/views/mall/promotion/combination/record/index.vue @@ -0,0 +1,276 @@ +<template> + <doc-alert title="【营销】拼团活动" url="https://doc.iocoder.cn/mall/promotion-combination/" /> + + <!-- 统计信息展示 --> + <el-row :gutter="12"> + <el-col :span="6"> + <ContentWrap class="h-[110px] pb-0!"> + <div class="flex items-center"> + <div + class="h-[50px] w-[50px] flex items-center justify-center" + style="color: rgb(24 144 255); background-color: rgb(24 144 255 / 10%)" + > + <Icon :size="23" icon="fa:user-times" /> + </div> + <div class="ml-[20px]"> + <div class="mb-8px text-14px text-gray-400">参与人数(个)</div> + <CountTo + :duration="2600" + :end-val="recordSummary.userCount" + :start-val="0" + class="text-20px" + /> + </div> + </div> + </ContentWrap> + </el-col> + <el-col :span="6"> + <ContentWrap class="h-[110px]"> + <div class="flex items-center"> + <div + class="h-[50px] w-[50px] flex items-center justify-center" + style="color: rgb(162 119 255); background-color: rgb(162 119 255 / 10%)" + > + <Icon :size="23" icon="fa:user-plus" /> + </div> + <div class="ml-[20px]"> + <div class="mb-8px text-14px text-gray-400">成团数量(个)</div> + <CountTo + :duration="2600" + :end-val="recordSummary.successCount" + :start-val="0" + class="text-20px" + /> + </div> + </div> + </ContentWrap> + </el-col> + <el-col :span="6"> + <ContentWrap class="h-[110px]"> + <div class="flex items-center"> + <div + class="h-[50px] w-[50px] flex items-center justify-center" + style="color: rgb(162 119 255); background-color: rgb(162 119 255 / 10%)" + > + <Icon :size="23" icon="fa:user-plus" /> + </div> + <div class="ml-[20px]"> + <div class="mb-8px text-14px text-gray-400">虚拟成团(个)</div> + <CountTo + :duration="2600" + :end-val="recordSummary.virtualGroupCount" + :start-val="0" + class="text-20px" + /> + </div> + </div> + </ContentWrap> + </el-col> + </el-row> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :shortcuts="defaultShortcuts" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item label="拼团状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="全部"> + <el-option + v-for="(dict, index) in getIntDictOptions( + DICT_TYPE.PROMOTION_COMBINATION_RECORD_STATUS + )" + :key="index" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 分页列表数据展示 --> + <ContentWrap> + <el-table v-loading="loading" :data="pageList"> + <el-table-column align="center" label="编号" prop="id" min-width="50" /> + <el-table-column align="center" label="头像" prop="avatar" min-width="80"> + <template #default="scope"> + <el-avatar :src="scope.row.avatar" /> + </template> + </el-table-column> + <el-table-column align="center" label="昵称" prop="nickname" min-width="100" /> + <el-table-column align="center" label="开团团长" prop="headId" min-width="100"> + <template #default="{ row }: { row: CombinationRecordApi.CombinationRecordVO }"> + {{ + row.headId ? pageList.find((item) => item.id === row.headId)?.nickname : row.nickname + }} + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="开团时间" + prop="startTime" + width="180" + /> + <el-table-column + align="center" + label="拼团商品" + prop="type" + show-overflow-tooltip + min-width="300" + > + <template #defaul="{ row }"> + <el-image + :src="row.picUrl" + class="mr-5px h-30px w-30px align-middle" + @click="imagePreview(row.picUrl)" + /> + <span class="align-middle">{{ row.spuName }}</span> + </template> + </el-table-column> + <el-table-column align="center" label="几人团" prop="userSize" min-width="100" /> + <el-table-column align="center" label="参与人数" prop="userCount" min-width="100" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="参团时间" + prop="createTime" + width="180" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="结束时间" + prop="endTime" + width="180" + /> + <el-table-column align="center" label="拼团状态" prop="status" min-width="150"> + <template #default="scope"> + <dict-tag + :type="DICT_TYPE.PROMOTION_COMBINATION_RECORD_STATUS" + :value="scope.row.status" + /> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['promotion:combination-record:query']" + link + type="primary" + @click="openRecordListDialog(scope.row)" + > + 查看拼团 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗 --> + <CombinationRecordListDialog ref="combinationRecordListRef" /> +</template> +<script lang="ts" setup> +import CombinationRecordListDialog from './CombinationRecordListDialog.vue' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter, defaultShortcuts } from '@/utils/formatTime' +import { createImageViewer } from '@/components/ImageViewer' +import * as CombinationRecordApi from '@/api/mall/promotion/combination/combinationRecord' + +defineOptions({ name: 'PromotionCombinationRecord' }) + +const queryParams = ref({ + status: undefined, // 拼团状态 + createTime: undefined, // 创建时间 + pageSize: 10, + pageNo: 1 +}) +const queryFormRef = ref() // 搜索的表单 +const combinationRecordListRef = ref() // 查询表单 Ref +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 总记录数 +const pageList = ref<CombinationRecordApi.CombinationRecordVO[]>([]) // 分页数据 +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CombinationRecordApi.getCombinationRecordPage(queryParams.value) + pageList.value = data.list as CombinationRecordApi.CombinationRecordVO[] + total.value = data.total + } finally { + loading.value = false + } +} +// 拼团统计数据 +const recordSummary = ref({ + successCount: 0, + userCount: 0, + virtualGroupCount: 0 +}) +/** 获得拼团记录统计信息 */ +const getSummary = async () => { + recordSummary.value = await CombinationRecordApi.getCombinationRecordSummary() +} + +/** 查看拼团详情 */ +const openRecordListDialog = (row: CombinationRecordApi.CombinationRecordVO) => { + combinationRecordListRef.value?.open(row.headId || row.id) // 多表达式的原因,团长的 headId 为空,就是自身的情况 +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.value.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} + +/** 初始化 **/ +onMounted(async () => { + await getSummary() + await getList() +}) +</script> diff --git a/src/views/mall/promotion/components/SpuAndSkuList.vue b/src/views/mall/promotion/components/SpuAndSkuList.vue new file mode 100644 index 0000000..facc6cf --- /dev/null +++ b/src/views/mall/promotion/components/SpuAndSkuList.vue @@ -0,0 +1,112 @@ +<template> + <el-table :data="spuData" :expand-row-keys="expandRowKeys" row-key="id"> + <el-table-column type="expand" width="30"> + <template #default="{ row }"> + <SkuList + ref="skuListRef" + :is-activity-component="true" + :prop-form-data="spuPropertyList.find((item) => item.spuId === row.id)?.spuDetail" + :property-list="spuPropertyList.find((item) => item.spuId === row.id)?.propertyList" + :rule-config="ruleConfig" + > + <template #extension> + <slot></slot> + </template> + </SkuList> + </template> + </el-table-column> + <el-table-column key="id" align="center" label="商品编号" prop="id" /> + <el-table-column label="商品图" min-width="80"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" /> + <el-table-column align="center" label="商品售价" min-width="90" prop="price"> + <template #default="{ row }"> + {{ formatToFraction(row.price) }} + </template> + </el-table-column> + <el-table-column align="center" label="销量" min-width="90" prop="salesCount" /> + <el-table-column align="center" label="库存" min-width="90" prop="stock" /> + </el-table> +</template> +<script generic="T extends Spu" lang="ts" setup> +import { formatToFraction } from '@/utils' +import { createImageViewer } from '@/components/ImageViewer' +import { Spu } from '@/api/mall/product/spu' +import { RuleConfig, SkuList } from '@/views/mall/product/spu/components' +import { SpuProperty } from '@/views/mall/promotion/components/index' + +defineOptions({ name: 'PromotionSpuAndSkuList' }) + +const props = defineProps<{ + spuList: T[] + ruleConfig: RuleConfig[] + spuPropertyListP: SpuProperty<T>[] +}>() + +const spuData = ref<Spu[]>([]) // spu 详情数据列表 +const skuListRef = ref() // 商品属性列表Ref +const spuPropertyList = ref<SpuProperty<T>[]>([]) // spuId 对应的 sku 的属性列表 +const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。 + +/** + * 获取所有 sku 活动配置 + * + * @param extendedAttribute 在 sku 上扩展的属性,例:秒杀活动 sku 扩展属性 productConfig 请参考 seckillActivity.ts + */ +const getSkuConfigs = (extendedAttribute: string) => { + skuListRef.value.validateSku() + const seckillProducts = [] + spuPropertyList.value.forEach((item) => { + item.spuDetail.skus.forEach((sku) => { + seckillProducts.push(sku[extendedAttribute]) + }) + }) + return seckillProducts +} +// 暴露出给表单提交时使用 +defineExpose({ getSkuConfigs }) + +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + zIndex: 99999999, + urlList: [imgUrl] + }) +} + +/** + * 将传进来的值赋值给 skuList + */ +watch( + () => props.spuList, + (data) => { + if (!data) return + spuData.value = data as Spu[] + }, + { + deep: true, + immediate: true + } +) +/** + * 将传进来的值赋值给 skuList + */ +watch( + () => props.spuPropertyListP, + (data) => { + if (!data) return + spuPropertyList.value = data as SpuProperty<T>[] + // 解决如果之前选择的是单规格 spu 的话后面选择多规格 sku 多规格属性信息不展示的问题。解决方法:让 SkuList 组件重新渲染(行折叠会干掉包含的组件展开时会重新加载) + setTimeout(() => { + expandRowKeys.value = data.map((item) => item.spuId) + }, 200) + }, + { + deep: true, + immediate: true + } +) +</script> diff --git a/src/views/mall/promotion/components/SpuSelect.vue b/src/views/mall/promotion/components/SpuSelect.vue new file mode 100644 index 0000000..fd7dffe --- /dev/null +++ b/src/views/mall/promotion/components/SpuSelect.vue @@ -0,0 +1,317 @@ +<template> + <Dialog v-model="dialogVisible" :appendToBody="true" :title="dialogTitle" width="70%"> + <ContentWrap> + <el-row :gutter="20" class="mb-10px"> + <el-col :span="6"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入商品名称" + @keyup.enter="handleQuery" + /> + </el-col> + <el-col :span="6"> + <el-tree-select + v-model="queryParams.categoryId" + :data="categoryList" + :props="defaultProps" + check-strictly + class="w-1/1" + node-key="id" + placeholder="请选择商品分类" + /> + </el-col> + <el-col :span="6"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-col> + <el-col :span="6"> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-col> + </el-row> + <el-table + ref="spuListRef" + v-loading="loading" + :data="list" + :expand-row-keys="expandRowKeys" + row-key="id" + @expand-change="expandChange" + @selection-change="selectSpu" + > + <el-table-column v-if="isSelectSku" type="expand" width="30"> + <template #default> + <SkuList + v-if="isExpand" + ref="skuListRef" + :isComponent="true" + :isDetail="true" + :prop-form-data="spuData" + :property-list="propertyList" + @selection-change="selectSku" + /> + </template> + </el-table-column> + <el-table-column type="selection" width="55" /> + <el-table-column key="id" align="center" label="商品编号" prop="id" /> + <el-table-column label="商品图" min-width="80"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <el-table-column + :show-overflow-tooltip="true" + label="商品名称" + min-width="300" + prop="name" + /> + <el-table-column align="center" label="商品售价" min-width="90" prop="price"> + <template #default="{ row }"> + {{ formatToFraction(row.price) }} + </template> + </el-table-column> + <el-table-column align="center" label="销量" min-width="90" prop="salesCount" /> + <el-table-column align="center" label="库存" min-width="90" prop="stock" /> + <el-table-column align="center" label="排序" min-width="70" prop="sort" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button type="primary" @click="confirm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> + +<script lang="ts" setup> +import { getPropertyList, PropertyAndValues, SkuList } from '@/views/mall/product/spu/components' +import { ElTable } from 'element-plus' +import { dateFormatter } from '@/utils/formatTime' +import { createImageViewer } from '@/components/ImageViewer' +import { formatToFraction } from '@/utils' +import { defaultProps, handleTree } from '@/utils/tree' + +import * as ProductCategoryApi from '@/api/mall/product/category' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { propTypes } from '@/utils/propTypes' + +defineOptions({ name: 'PromotionSpuSelect' }) + +const props = defineProps({ + // 默认不需要(不需要的情况下只返回 spu,需要的情况下返回 选中的 spu 和 sku 列表) + // 其它活动需要选择商品和商品属性导入此组件即可,需添加组件属性 :isSelectSku='true' + isSelectSku: propTypes.bool.def(false), // 是否需要选择 sku 属性 + radio: propTypes.bool.def(false) // 是否单选 sku +}) + +const message = useMessage() // 消息弹窗 +const total = ref(0) // 列表的总页数 +const list = ref<any[]>([]) // 列表的数据 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + tabType: 0, // 默认获取上架的商品 + name: '', + categoryId: null, + createTime: [] +}) // 查询参数 +const propertyList = ref<PropertyAndValues[]>([]) // 商品属性列表 +const spuListRef = ref<InstanceType<typeof ElTable>>() +const skuListRef = ref<InstanceType<typeof SkuList>>() // 商品属性选择 Ref +const spuData = ref<ProductSpuApi.Spu>() // 商品详情 +const isExpand = ref(false) // 控制 SKU 列表显示 +const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。 + +//============ 商品选择相关 ============ +const selectedSpuId = ref<number>(0) // 选中的商品 spuId +const selectedSkuIds = ref<number[]>([]) // 选中的商品 skuIds +const selectSku = (val: ProductSpuApi.Sku[]) => { + const skuTable = skuListRef.value?.getSkuTableRef() + if (selectedSpuId.value === 0) { + message.warning('请先选择商品再选择相应的规格!!!') + skuTable?.clearSelection() + return + } + if (val.length === 0) { + selectedSkuIds.value = [] + return + } + if (props.radio) { + // 只选择一个 + selectedSkuIds.value = [val.map((sku) => sku.id!)[0]] + // 如果大于1个 + if (val.length > 1) { + // 清空选择 + skuTable?.clearSelection() + // 变更为最后一次选择的 + skuTable?.toggleRowSelection(val.pop(), true) + return + } + } else { + selectedSkuIds.value = val.map((sku) => sku.id!) + } +} +const selectSpu = (val: ProductSpuApi.Spu[]) => { + if (val.length === 0) { + selectedSpuId.value = 0 + return + } + // 只选择一个 + selectedSpuId.value = val.map((spu) => spu.id!)[0] + // 切换选择 spu 如果有选择的 sku 则清空,确保选择的 sku 是对应的 spu 下面的 + if (selectedSkuIds.value.length > 0) { + selectedSkuIds.value = [] + } + // 如果大于1个 + if (val.length > 1) { + // 清空选择 + spuListRef.value?.clearSelection() + // 变更为最后一次选择的 + spuListRef.value?.toggleRowSelection(val.pop(), true) + return + } + expandChange(val[0], val) +} + +// 计算商品属性 +const expandChange = async (row: ProductSpuApi.Spu, expandedRows?: ProductSpuApi.Spu[]) => { + // 判断需要展开的 spuId === 选择的 spuId。如果选择了 A 就展开 A 的 skuList。如果选择了 A 手动展开 B 则阻断 + // 目的防止误选 sku + if (selectedSpuId.value !== 0) { + if (row.id !== selectedSpuId.value) { + message.warning('你已选择商品请先取消') + expandRowKeys.value = [selectedSpuId.value] + return + } + // 如果已展开 skuList 则选择此对应的 spu 不需要重新获取渲染 skuList + if (isExpand.value && spuData.value?.id === row.id) { + return + } + } + spuData.value = {} + propertyList.value = [] + isExpand.value = false + if (expandedRows?.length === 0) { + // 如果展开个数为 0 + expandRowKeys.value = [] + return + } + // 获取 SPU 详情 + const res = (await ProductSpuApi.getSpu(row.id as number)) as ProductSpuApi.Spu + propertyList.value = getPropertyList(res) + spuData.value = res + isExpand.value = true + expandRowKeys.value = [row.id!] +} + +// 确认选择时的触发事件 +const emits = defineEmits<{ + (e: 'confirm', spuId: number, skuIds?: number[]): void +}>() +/** + * 确认选择返回选中的 spu 和 sku (如果需要选择sku的话) + */ +const confirm = () => { + if (selectedSpuId.value === 0) { + message.warning('没有选择任何商品') + return + } + if (props.isSelectSku && selectedSkuIds.value.length === 0) { + message.warning('没有选择任何商品属性') + return + } + // 返回各自 id 列表 + props.isSelectSku + ? emits('confirm', selectedSpuId.value, selectedSkuIds.value) + : emits('confirm', selectedSpuId.value) + // 关闭弹窗 + dialogVisible.value = false + selectedSpuId.value = 0 + selectedSkuIds.value = [] +} + +/** 打开弹窗 */ +const open = () => { + dialogTitle.value = '商品选择' + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductSpuApi.getSpuPage(queryParams.value) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryParams.value = { + pageNo: 1, + pageSize: 10, + tabType: 0, // 默认获取上架的商品 + name: '', + categoryId: null, + createTime: [] + } + getList() +} + +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + zIndex: 99999999, + urlList: [imgUrl] + }) +} + +const categoryList = ref() // 分类树 + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 获得分类树 + const data = await ProductCategoryApi.getCategoryList({}) + categoryList.value = handleTree(data, 'id', 'parentId') +}) +</script> diff --git a/src/views/mall/promotion/components/index.ts b/src/views/mall/promotion/components/index.ts new file mode 100644 index 0000000..b42c8ce --- /dev/null +++ b/src/views/mall/promotion/components/index.ts @@ -0,0 +1,14 @@ +import SpuSelect from './SpuSelect.vue' +import SpuAndSkuList from './SpuAndSkuList.vue' +import { PropertyAndValues } from '@/views/mall/product/spu/components' + +type SpuProperty<T> = { + spuId: number + spuDetail: T + propertyList: PropertyAndValues[] +} + +/** + * 提供商品活动商品选择通用组件 + */ +export { SpuSelect, SpuAndSkuList, SpuProperty } diff --git a/src/views/mall/promotion/coupon/components/CouponSelect.vue b/src/views/mall/promotion/coupon/components/CouponSelect.vue new file mode 100644 index 0000000..715dcb7 --- /dev/null +++ b/src/views/mall/promotion/coupon/components/CouponSelect.vue @@ -0,0 +1,219 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%"> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="82px" + > + <el-form-item label="优惠券名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入优惠劵名" + @keyup="handleQuery" + /> + </el-form-item> + <el-form-item label="优惠类型" prop="discountType"> + <el-select + v-model="queryParams.discountType" + class="!w-240px" + clearable + placeholder="请选择优惠券类型" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="优惠券状态" prop="status"> + <el-select + v-model="queryParams.status" + class="!w-240px" + clearable + placeholder="请选择优惠券状态" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" @selection-change="handleSelectionChange"> + <el-table-column type="selection" width="55" /> + <el-table-column label="优惠券名称" min-width="140" prop="name" /> + <el-table-column label="类型" min-width="80" prop="productScope"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_PRODUCT_SCOPE" :value="scope.row.productScope" /> + </template> + </el-table-column> + <el-table-column label="优惠" min-width="100" prop="discount"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE" :value="scope.row.discountType" /> + {{ discountFormat(scope.row) }} + </template> + </el-table-column> + <el-table-column label="领取方式" min-width="100" prop="takeType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_TAKE_TYPE" :value="scope.row.takeType" /> + </template> + </el-table-column> + <el-table-column + :formatter="validityTypeFormat" + align="center" + label="使用时间" + prop="validityType" + width="185" + /> + <el-table-column align="center" label="发放数量" prop="totalCount" /> + <el-table-column + :formatter="remainedCountFormat" + align="center" + label="剩余数量" + prop="totalCount" + /> + <el-table-column + :formatter="takeLimitCountFormat" + align="center" + label="领取上限" + prop="takeLimitCount" + /> + <el-table-column align="center" label="状态" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { + discountFormat, + remainedCountFormat, + takeLimitCountFormat, + validityTypeFormat +} from '@/views/mall/promotion/coupon/formatter' +import { dateFormatter } from '@/utils/formatTime' +import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate' + +defineOptions({ name: 'CouponSelect' }) + +defineProps<{ + multipleSelection: CouponTemplateApi.CouponTemplateVO[] +}>() +const emit = defineEmits<{ + (e: 'update:multipleSelection', v: CouponTemplateApi.CouponTemplateVO[]) +}>() +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('选择优惠卷') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 字典表格数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + status: null, + discountType: null, + type: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + // 执行查询 + const data = await CouponTemplateApi.getCouponTemplatePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef?.value?.resetFields() + handleQuery() +} + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + resetQuery() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +const handleSelectionChange = (val: CouponTemplateApi.CouponTemplateVO[]) => { + emit('update:multipleSelection', val) +} + +const submitForm = () => { + dialogVisible.value = false +} +// TODO @puhui999:提前 todo,先不用改;未来单独成组件,其它模块可以服用;例如说,满减送,可以选择优惠劵; +</script> diff --git a/src/views/mall/promotion/coupon/components/CouponSendForm.vue b/src/views/mall/promotion/coupon/components/CouponSendForm.vue new file mode 100644 index 0000000..be0223a --- /dev/null +++ b/src/views/mall/promotion/coupon/components/CouponSendForm.vue @@ -0,0 +1,162 @@ +<template> + <Dialog v-model="dialogVisible" :appendToBody="true" title="发送优惠券" width="70%"> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="82px" + > + <el-form-item label="优惠券名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + placeholder="请输入优惠劵名" + clearable + @keyup="handleQuery" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + + <!-- 列表 --> + <el-table v-loading="loading" :data="list" show-overflow-tooltip> + <el-table-column align="center" label="优惠券名称" prop="name" min-width="60" /> + <el-table-column + label="优惠金额 / 折扣" + align="center" + prop="discount" + :formatter="discountFormat" + min-width="60" + /> + <el-table-column + align="center" + label="最低消费" + prop="usePrice" + min-width="60" + :formatter="usePriceFormat" + /> + <el-table-column + align="center" + label="有效期限" + prop="validityType" + min-width="140" + :formatter="validityTypeFormat" + /> + <el-table-column + align="center" + label="剩余数量" + min-width="60" + :formatter="remainedCountFormat" + /> + <el-table-column label="操作" align="center" min-width="60px" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + :disabled="sendLoading" + :loading="sendLoading" + @click="handleSendCoupon(scope.row.id)" + v-hasPermi="['member:level:update']" + > + 发送 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + <div class="clear-both"></div> + </Dialog> +</template> +<script lang="ts" setup> +import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate' +import * as CouponApi from '@/api/mall/promotion/coupon/coupon' +import { + discountFormat, + remainedCountFormat, + usePriceFormat, + validityTypeFormat +} from '@/views/mall/promotion/coupon/formatter' +import { CouponTemplateTakeTypeEnum } from '@/utils/constants' + +defineOptions({ name: 'PromotionCouponSendForm' }) + +const message = useMessage() // 消息弹窗 +const total = ref(0) // 列表的总页数 +const list = ref<any[]>([]) // 列表的数据 +const loading = ref(false) // 列表的加载中 +const sendLoading = ref(false) // 发送按钮的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + name: null, + canTakeTypes: [CouponTemplateTakeTypeEnum.ADMIN.type] +}) // 查询参数 +const queryFormRef = ref() // 搜索的表单 +// 领取人的编号列表 +let userIds: number[] = [] + +/** 打开弹窗 */ +const open = (ids: number[]) => { + userIds = ids + // 打开时重置查询,防止发送列表剩余数量未更新的问题 + resetQuery() + + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await CouponTemplateApi.getCouponTemplatePage(queryParams.value) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.value.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef?.value?.resetFields() + handleQuery() +} + +/** 发送操作 **/ +const handleSendCoupon = async (templateId: number) => { + try { + sendLoading.value = true + await CouponApi.sendCoupon({ templateId, userIds }) + // 提示 + message.success('发送成功') + dialogVisible.value = false + } finally { + sendLoading.value = false + } +} +</script> diff --git a/src/views/mall/promotion/coupon/components/index.ts b/src/views/mall/promotion/coupon/components/index.ts new file mode 100644 index 0000000..6a0e56f --- /dev/null +++ b/src/views/mall/promotion/coupon/components/index.ts @@ -0,0 +1,4 @@ +import CouponSendForm from './CouponSendForm.vue' +import CouponSelect from './CouponSelect.vue' + +export { CouponSendForm, CouponSelect } diff --git a/src/views/mall/promotion/coupon/formatter.ts b/src/views/mall/promotion/coupon/formatter.ts new file mode 100644 index 0000000..f00138a --- /dev/null +++ b/src/views/mall/promotion/coupon/formatter.ts @@ -0,0 +1,44 @@ +import { CouponTemplateValidityTypeEnum, PromotionDiscountTypeEnum } from '@/utils/constants' +import { formatDate } from '@/utils/formatTime' +import { CouponTemplateVO } from '@/api/mall/promotion/coupon/couponTemplate' +import { floatToFixed2 } from '@/utils' + +// 格式化【优惠金额/折扣】 +export const discountFormat = (row: CouponTemplateVO) => { + if (row.discountType === PromotionDiscountTypeEnum.PRICE.type) { + return `¥${floatToFixed2(row.discountPrice)}` + } + if (row.discountType === PromotionDiscountTypeEnum.PERCENT.type) { + return `${row.discountPercent}%` + } + return '未知【' + row.discountType + '】' +} + +// 格式化【领取上限】 +export const takeLimitCountFormat = (row: CouponTemplateVO) => { + if (row.takeLimitCount === -1) { + return '无领取限制' + } + return `${row.takeLimitCount} 张/人` +} + +// 格式化【有效期限】 +export const validityTypeFormat = (row: CouponTemplateVO) => { + if (row.validityType === CouponTemplateValidityTypeEnum.DATE.type) { + return `${formatDate(row.validStartTime)} 至 ${formatDate(row.validEndTime)}` + } + if (row.validityType === CouponTemplateValidityTypeEnum.TERM.type) { + return `领取后第 ${row.fixedStartTerm} - ${row.fixedEndTerm} 天内可用` + } + return '未知【' + row.validityType + '】' +} + +// 格式化【剩余数量】 +export const remainedCountFormat = (row: CouponTemplateVO) => { + return row.totalCount - row.takeCount +} + +// 格式化【最低消费】 +export const usePriceFormat = (row: CouponTemplateVO) => { + return `¥${floatToFixed2(row.usePrice)}` +} diff --git a/src/views/mall/promotion/coupon/index.vue b/src/views/mall/promotion/coupon/index.vue new file mode 100755 index 0000000..25d2e94 --- /dev/null +++ b/src/views/mall/promotion/coupon/index.vue @@ -0,0 +1,201 @@ +<template> + <doc-alert title="【营销】优惠劵" url="https://doc.iocoder.cn/mall/promotion-coupon/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="会员昵称" prop="nickname"> + <el-input + v-model="queryParams.nickname" + class="!w-240px" + placeholder="请输入会员昵称" + clearable + @keyup="handleQuery" + /> + </el-form-item> + <el-form-item label="领取时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> <Icon icon="ep:search" class="mr-5px" />搜索 </el-button> + <el-button @click="resetQuery"> <Icon icon="ep:refresh" class="mr-5px" />重置 </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <!-- Tab 选项:真正的内容在 Lab --> + <el-tabs v-model="activeTab" type="card" @tab-change="onTabChange"> + <el-tab-pane + v-for="tab in statusTabs" + :key="tab.value" + :label="tab.label" + :name="tab.value" + /> + </el-tabs> + + <!-- 列表 --> + <el-table v-loading="loading" :data="list"> + <el-table-column label="会员昵称" align="center" min-width="100" prop="nickname" /> + <el-table-column label="优惠券名称" align="center" min-width="140" prop="name" /> + <el-table-column label="类型" align="center" prop="discountType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_PRODUCT_SCOPE" :value="scope.row.productScope" /> + </template> + </el-table-column> + <el-table-column label="优惠" min-width="100" prop="discount"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE" :value="scope.row.discountType" /> + {{ discountFormat(scope.row) }} + </template> + </el-table-column> + <el-table-column label="领取方式" align="center" prop="takeType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_TAKE_TYPE" :value="scope.row.takeType" /> + </template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="领取时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column + label="使用时间" + align="center" + prop="useTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> + <template #default="scope"> + <el-button + v-hasPermi="['promotion:coupon:delete']" + type="danger" + link + @click="handleDelete(scope.row.id)" + > + 回收 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts" name="PromotionCoupon"> +import { deleteCoupon, getCouponPage } from '@/api/mall/promotion/coupon/coupon' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { discountFormat } from '@/views/mall/promotion/coupon/formatter' + +defineOptions({ name: 'PromotionCoupon' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 字典表格数据 +// 查询参数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + createTime: [], + status: undefined, + nickname: undefined +}) +const queryFormRef = ref() // 搜索的表单 + +const activeTab = ref('all') // Tab 筛选 +const statusTabs = reactive([ + { + label: '全部', + value: 'all' + } +]) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + // 执行查询 + try { + const data = await getCouponPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + handleQuery() +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 二次确认 + await message.confirm( + '回收将会收回会员领取的待使用的优惠券,已使用的将无法回收,确定要回收所选优惠券吗?' + ) + // 发起删除 + await deleteCoupon(id) + message.notifySuccess('回收成功') + // 重新加载列表 + await getList() + } catch {} +} + +/** tab 切换 */ +const onTabChange = (tabName) => { + queryParams.status = tabName === 'all' ? undefined : tabName + getList() +} + +/** 初始化 **/ +onMounted(() => { + getList() + // 设置 statuses 过滤 + for (const dict of getIntDictOptions(DICT_TYPE.PROMOTION_COUPON_STATUS)) { + statusTabs.push({ + label: dict.label, + value: dict.value as string + }) + } +}) +</script> diff --git a/src/views/mall/promotion/coupon/template/CouponTemplateForm.vue b/src/views/mall/promotion/coupon/template/CouponTemplateForm.vue new file mode 100644 index 0000000..408f381 --- /dev/null +++ b/src/views/mall/promotion/coupon/template/CouponTemplateForm.vue @@ -0,0 +1,388 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="140px" + > + <el-form-item label="优惠券名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入优惠券名称" /> + </el-form-item> + <el-form-item label="优惠劵类型" prop="productScope"> + <el-radio-group v-model="formData.productScope"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item + v-if="formData.productScope === PromotionProductScopeEnum.SPU.scope" + label="商品" + prop="productSpuIds" + > + <SpuShowcase v-model="formData.productSpuIds" /> + </el-form-item> + <el-form-item + v-if="formData.productScope === PromotionProductScopeEnum.CATEGORY.scope" + label="分类" + prop="productCategoryIds" + > + <ProductCategorySelect v-model="formData.productCategoryIds" /> + </el-form-item> + <el-form-item label="优惠类型" prop="discountType"> + <el-radio-group v-model="formData.discountType"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item + v-if="formData.discountType === PromotionDiscountTypeEnum.PRICE.type" + label="优惠券面额" + prop="discountPrice" + > + <el-input-number + v-model="formData.discountPrice" + :min="0" + :precision="2" + class="mr-2 !w-400px" + placeholder="请输入优惠金额,单位:元" + /> + 元 + </el-form-item> + <el-form-item + v-if="formData.discountType === PromotionDiscountTypeEnum.PERCENT.type" + label="优惠券折扣" + prop="discountPercent" + > + <el-input-number + v-model="formData.discountPercent" + :max="9.9" + :min="1" + :precision="1" + class="mr-2 !w-400px" + placeholder="优惠券折扣不能小于 1 折,且不可大于 9.9 折" + /> + 折 + </el-form-item> + <el-form-item + v-if="formData.discountType === PromotionDiscountTypeEnum.PERCENT.type" + label="最多优惠" + prop="discountLimitPrice" + > + <el-input-number + v-model="formData.discountLimitPrice" + :min="0" + :precision="2" + class="mr-2 !w-400px" + placeholder="请输入最多优惠" + /> + 元 + </el-form-item> + <el-form-item label="满多少元可以使用" prop="usePrice"> + <el-input-number + v-model="formData.usePrice" + :min="0" + :precision="2" + class="mr-2 !w-400px" + placeholder="无门槛请设为 0" + /> + 元 + </el-form-item> + <el-form-item label="领取方式" prop="takeType"> + <el-radio-group v-model="formData.takeType"> + <el-radio :key="1" :label="1">直接领取</el-radio> + <el-radio :key="2" :label="2">指定发放</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item v-if="formData.takeType === 1" label="发放数量" prop="totalCount"> + <el-input-number + v-model="formData.totalCount" + :min="-1" + :precision="0" + class="mr-2 !w-400px" + placeholder="发放数量,没有之后不能领取或发放,-1 为不限制" + /> + 张 + </el-form-item> + <el-form-item v-if="formData.takeType === 1" label="每人限领个数" prop="takeLimitCount"> + <el-input-number + v-model="formData.takeLimitCount" + :min="-1" + :precision="0" + class="mr-2 !w-400px" + placeholder="设置为 -1 时,可无限领取" + /> + 张 + </el-form-item> + <el-form-item label="有效期类型" prop="validityType"> + <el-radio-group v-model="formData.validityType"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item + v-if="formData.validityType === CouponTemplateValidityTypeEnum.DATE.type" + label="固定日期" + prop="validTimes" + > + <el-date-picker + v-model="formData.validTimes" + :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)]" + style="width: 240px" + type="datetimerange" + value-format="x" + /> + </el-form-item> + <el-form-item + v-if="formData.validityType === CouponTemplateValidityTypeEnum.TERM.type" + label="领取日期" + prop="fixedStartTerm" + > + 第 + <el-input-number + v-model="formData.fixedStartTerm" + :min="0" + :precision="0" + class="mx-2" + placeholder="0 为今天生效" + /> + 至 + <el-input-number + v-model="formData.fixedEndTerm" + :min="0" + :precision="0" + class="mx-2" + placeholder="请输入结束天数" + /> + 天有效 + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate' +import { + CouponTemplateValidityTypeEnum, + PromotionDiscountTypeEnum, + PromotionProductScopeEnum +} from '@/utils/constants' +import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue' +import ProductCategorySelect from '@/views/mall/product/category/components/ProductCategorySelect.vue' +import { convertToInteger, formatToFraction } from '@/utils' + +defineOptions({ name: 'CouponTemplateForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + discountType: PromotionDiscountTypeEnum.PRICE.type, + discountPrice: undefined, + discountPercent: undefined, + discountLimitPrice: undefined, + usePrice: undefined, + takeType: 1, + totalCount: undefined, + takeLimitCount: undefined, + validityType: CouponTemplateValidityTypeEnum.DATE.type, + validTimes: [], + validStartTime: undefined, + validEndTime: undefined, + fixedStartTerm: undefined, + fixedEndTerm: undefined, + productScope: PromotionProductScopeEnum.ALL.scope, + productScopeValues: [], // 商品范围:值为 品类编号列表 或 商品编号列表 ,用于提交 + productCategoryIds: [], // 仅用于表单,不提交 + productSpuIds: [] // 仅用于表单,不提交 +}) +const formRules = reactive({ + name: [{ required: true, message: '优惠券名称不能为空', trigger: 'blur' }], + discountType: [{ required: true, message: '优惠券类型不能为空', trigger: 'change' }], + discountPrice: [{ required: true, message: '优惠券面额不能为空', trigger: 'blur' }], + discountPercent: [{ required: true, message: '优惠券折扣不能为空', trigger: 'blur' }], + discountLimitPrice: [{ required: true, message: '最多优惠不能为空', trigger: 'blur' }], + usePrice: [{ required: true, message: '满多少元可以使用不能为空', trigger: 'blur' }], + takeType: [{ required: true, message: '领取方式不能为空', trigger: 'change' }], + totalCount: [{ required: true, message: '发放数量不能为空', trigger: 'blur' }], + takeLimitCount: [{ required: true, message: '每人限领个数不能为空', trigger: 'blur' }], + validityType: [{ required: true, message: '有效期类型不能为空', trigger: 'change' }], + validTimes: [{ required: true, message: '固定日期不能为空', trigger: 'change' }], + fixedStartTerm: [{ required: true, message: '开始领取天数不能为空', trigger: 'blur' }], + fixedEndTerm: [{ required: true, message: '开始领取天数不能为空', trigger: 'blur' }], + productScope: [{ required: true, message: '商品范围不能为空', trigger: 'blur' }], + productSpuIds: [{ required: true, message: '商品不能为空', trigger: 'blur' }], + productCategoryIds: [{ required: true, message: '分类不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = await CouponTemplateApi.getCouponTemplate(id) + formData.value = { + ...data, + discountPrice: formatToFraction(data.discountPrice), + discountPercent: + data.discountPercent !== undefined ? data.discountPercent / 10.0 : undefined, + discountLimitPrice: formatToFraction(data.discountLimitPrice), + usePrice: formatToFraction(data.usePrice), + validTimes: [data.validStartTime, data.validEndTime] + } + // 获得商品范围 + await getProductScope() + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = { + ...formData.value, + discountPrice: convertToInteger(formData.value.discountPrice), + discountPercent: + formData.value.discountPercent !== undefined + ? formData.value.discountPercent * 10 + : undefined, + discountLimitPrice: convertToInteger(formData.value.discountLimitPrice), + usePrice: convertToInteger(formData.value.usePrice), + validStartTime: + formData.value.validTimes && formData.value.validTimes.length === 2 + ? formData.value.validTimes[0] + : undefined, + validEndTime: + formData.value.validTimes && formData.value.validTimes.length === 2 + ? formData.value.validTimes[1] + : undefined + } as unknown as CouponTemplateApi.CouponTemplateVO + + // 设置商品范围 + setProductScopeValues(data) + + if (formType.value === 'create') { + await CouponTemplateApi.createCouponTemplate(data) + message.success(t('common.createSuccess')) + } else { + await CouponTemplateApi.updateCouponTemplate(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + discountType: PromotionDiscountTypeEnum.PRICE.type, + discountPrice: undefined, + discountPercent: undefined, + discountLimitPrice: undefined, + usePrice: undefined, + takeType: 1, + totalCount: undefined, + takeLimitCount: undefined, + validityType: CouponTemplateValidityTypeEnum.DATE.type, + validTimes: [], + validStartTime: undefined, + validEndTime: undefined, + fixedStartTerm: undefined, + fixedEndTerm: undefined, + productScope: PromotionProductScopeEnum.ALL.scope, + productScopeValues: [], + productSpuIds: [], + productCategoryIds: [] + } + formRef.value?.resetFields() +} + +/** 获得商品范围 */ +const getProductScope = async () => { + switch (formData.value.productScope) { + case PromotionProductScopeEnum.SPU.scope: + // 设置商品编号 + formData.value.productSpuIds = formData.value.productScopeValues + break + case PromotionProductScopeEnum.CATEGORY.scope: + await nextTick(() => { + let productCategoryIds = formData.value.productScopeValues + if (Array.isArray(productCategoryIds) && productCategoryIds.length > 0) { + // 单选时使用数组不能反显 + productCategoryIds = productCategoryIds[0] + } + // 设置品类编号 + formData.value.productCategoryIds = productCategoryIds + }) + break + default: + break + } +} + +/** 设置商品范围 */ +function setProductScopeValues(data: CouponTemplateApi.CouponTemplateVO) { + switch (formData.value.productScope) { + case PromotionProductScopeEnum.SPU.scope: + data.productScopeValues = formData.value.productSpuIds + break + case PromotionProductScopeEnum.CATEGORY.scope: + data.productScopeValues = Array.isArray(formData.value.productCategoryIds) + ? formData.value.productCategoryIds + : [formData.value.productCategoryIds] + break + default: + break + } +} +</script> + +<style lang="scss" scoped></style> diff --git a/src/views/mall/promotion/coupon/template/index.vue b/src/views/mall/promotion/coupon/template/index.vue new file mode 100755 index 0000000..657cead --- /dev/null +++ b/src/views/mall/promotion/coupon/template/index.vue @@ -0,0 +1,278 @@ +<template> + <doc-alert title="【营销】优惠劵" url="https://doc.iocoder.cn/mall/promotion-coupon/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="82px" + > + <el-form-item label="优惠券名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入优惠劵名" + @keyup="handleQuery" + /> + </el-form-item> + <el-form-item label="优惠类型" prop="discountType"> + <el-select + v-model="queryParams.discountType" + class="!w-240px" + clearable + placeholder="请选择优惠券类型" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="优惠券状态" prop="status"> + <el-select + v-model="queryParams.status" + class="!w-240px" + clearable + placeholder="请选择优惠券状态" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['promotion:coupon-template:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="优惠券名称" min-width="140" prop="name" /> + <el-table-column label="类型" min-width="130" prop="productScope"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_PRODUCT_SCOPE" :value="scope.row.productScope" /> + </template> + </el-table-column> + <el-table-column label="优惠" min-width="110" prop="discount"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE" :value="scope.row.discountType" /> + <div>{{ discountFormat(scope.row) }}</div> + </template> + </el-table-column> + <el-table-column label="领取方式" min-width="100" prop="takeType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_TAKE_TYPE" :value="scope.row.takeType" /> + </template> + </el-table-column> + <el-table-column + :formatter="validityTypeFormat" + align="center" + label="使用时间" + prop="validityType" + width="185" + /> + <el-table-column align="center" label="发放数量" prop="totalCount" /> + <el-table-column + :formatter="remainedCountFormat" + align="center" + label="剩余数量" + prop="totalCount" + /> + <el-table-column + :formatter="takeLimitCountFormat" + align="center" + label="领取上限" + prop="takeLimitCount" + /> + <el-table-column align="center" label="状态" prop="status"> + <template #default="scope"> + <el-switch + v-model="scope.row.status" + :active-value="0" + :inactive-value="1" + @change="handleStatusChange(scope.row)" + /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + <el-table-column + align="center" + class-name="small-padding fixed-width" + fixed="right" + label="操作" + width="120" + > + <template #default="scope"> + <el-button + v-hasPermi="['promotion:coupon-template:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 修改 + </el-button> + <el-button + v-hasPermi="['promotion:coupon-template:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <CouponTemplateForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate' +import { CommonStatusEnum } from '@/utils/constants' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import CouponTemplateForm from './CouponTemplateForm.vue' +import { + discountFormat, + remainedCountFormat, + takeLimitCountFormat, + validityTypeFormat +} from '@/views/mall/promotion/coupon/formatter' + +defineOptions({ name: 'PromotionCouponTemplate' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 字典表格数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + status: null, + discountType: null, + type: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + // 执行查询 + const data = await CouponTemplateApi.getCouponTemplatePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef?.value?.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 优惠劵模板状态修改 */ +const handleStatusChange = async (row: any) => { + // 此时,row 已经变成目标状态了,所以可以直接提交请求和提示 + let text = row.status === CommonStatusEnum.ENABLE ? '启用' : '停用' + + try { + await message.confirm('确认要"' + text + '""' + row.name + '"优惠劵吗?') + await CouponTemplateApi.updateCouponTemplateStatus(row.id, row.status) + message.success(text + '成功') + } catch { + // 异常时,需要将 row.status 状态重置回之前的 + row.status = + row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE + } +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.confirm('是否确认删除优惠劵编号为"' + id + '"的数据项?') + // 发起删除 + await CouponTemplateApi.deleteCouponTemplate(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/discountActivity/DiscountActivityForm.vue b/src/views/mall/promotion/discountActivity/DiscountActivityForm.vue new file mode 100644 index 0000000..d7a9806 --- /dev/null +++ b/src/views/mall/promotion/discountActivity/DiscountActivityForm.vue @@ -0,0 +1,179 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%"> + <Form + ref="formRef" + v-loading="formLoading" + :isCol="true" + :rules="rules" + :schema="allSchemas.formSchema" + > + <!-- 先选择 --> + <!-- TODO @zhangshuai:商品允许选择多个 --> + <!-- TODO @zhangshuai:选择后的 SKU,需要后面加个【删除】按钮 --> + <!-- TODO @zhangshuai:展示的金额,貌似不对,大了 100 倍,需要看下 --> + <!-- TODO @zhangshuai:“优惠类型”,是每个 SKU 可以自定义已设置哈。因为每个商品 SKU 的折扣和减少价格,可能不同。具体交互,可以注册一个 youzan.com 看看;它的交互方式是,如果设置了“优惠金额”,则算“减价”;如果再次设置了“折扣百分比”,就算“打折”;这样形成一个互斥的优惠类型 --> + <template #spuId> + <el-button @click="spuSelectRef.open()">选择商品</el-button> + <SpuAndSkuList + ref="spuAndSkuListRef" + :rule-config="ruleConfig" + :spu-list="spuList" + :spu-property-list-p="spuPropertyList" + > + <el-table-column align="center" label="优惠金额" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number v-model="sku.productConfig.discountPrice" :min="0" class="w-100%" /> + </template> + </el-table-column> + <el-table-column align="center" label="折扣百分比(%)" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number v-model="sku.productConfig.discountPercent" class="w-100%" /> + </template> + </el-table-column> + </SpuAndSkuList> + </template> + </Form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <SpuSelect ref="spuSelectRef" :isSelectSku="true" @confirm="selectSpu" /> +</template> +<script lang="ts" setup> +import { SpuAndSkuList, SpuProperty, SpuSelect } from '../components' +import { allSchemas, rules } from './discountActivity.data' +import { cloneDeep } from 'lodash-es' +import * as DiscountActivityApi from '@/api/mall/promotion/discount/discountActivity' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components' + +defineOptions({ name: 'PromotionDiscountActivityForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formRef = ref() // 表单 Ref +// ================= 商品选择相关 ================= + +const spuSelectRef = ref() // 商品和属性选择 Ref +const spuAndSkuListRef = ref() // sku 限时折扣 配置组件Ref +const ruleConfig: RuleConfig[] = [] +const spuList = ref<DiscountActivityApi.SpuExtension[]>([]) // 选择的 spu +const spuPropertyList = ref<SpuProperty<DiscountActivityApi.SpuExtension>[]>([]) +const selectSpu = (spuId: number, skuIds: number[]) => { + formRef.value.setValues({ spuId }) + getSpuDetails(spuId, skuIds) +} +/** + * 获取 SPU 详情 + */ +const getSpuDetails = async ( + spuId: number, + skuIds: number[] | undefined, + products?: DiscountActivityApi.DiscountProductVO[] +) => { + const spuProperties: SpuProperty<DiscountActivityApi.SpuExtension>[] = [] + const res = (await ProductSpuApi.getSpuDetailList([spuId])) as DiscountActivityApi.SpuExtension[] + if (res.length == 0) { + return + } + spuList.value = [] + // 因为只能选择一个 + const spu = res[0] + const selectSkus = + typeof skuIds === 'undefined' ? spu?.skus : spu?.skus?.filter((sku) => skuIds.includes(sku.id!)) + selectSkus?.forEach((sku) => { + let config: DiscountActivityApi.DiscountProductVO = { + skuId: sku.id!, + spuId: spu.id, + discountType: 1, + discountPercent: 0, + discountPrice: 0 + } + if (typeof products !== 'undefined') { + const product = products.find((item) => item.skuId === sku.id) + config = product || config + } + sku.productConfig = config + }) + spu.skus = selectSkus as DiscountActivityApi.SkuExtension[] + spuProperties.push({ + spuId: spu.id!, + spuDetail: spu, + propertyList: getPropertyList(spu) + }) + spuList.value.push(spu) + spuPropertyList.value = spuProperties +} + +// ================= end ================= + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + await resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = (await DiscountActivityApi.getDiscountActivity( + id + )) as DiscountActivityApi.DiscountActivityVO + const supId = data.products[0].spuId + await getSpuDetails(supId!, data.products?.map((sku) => sku.skuId), data.products) + formRef.value.setValues(data) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.getElFormRef().validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formRef.value.formModel as DiscountActivityApi.DiscountActivityVO + // 获取 折扣商品配置 + const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig')) + products.forEach((item: DiscountActivityApi.DiscountProductVO) => { + item.discountType = data['discountType'] + }) + data.products = products + // 真正提交 + if (formType.value === 'create') { + await DiscountActivityApi.createDiscountActivity(data) + message.success(t('common.createSuccess')) + } else { + await DiscountActivityApi.updateDiscountActivity(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = async () => { + spuList.value = [] + spuPropertyList.value = [] + await nextTick() + formRef.value.getElFormRef().resetFields() +} +</script> diff --git a/src/views/mall/promotion/discountActivity/discountActivity.data.ts b/src/views/mall/promotion/discountActivity/discountActivity.data.ts new file mode 100644 index 0000000..d79dcab --- /dev/null +++ b/src/views/mall/promotion/discountActivity/discountActivity.data.ts @@ -0,0 +1,119 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter2 } from '@/utils/formatTime' + +// TODO @zhangshai: +// 表单校验 +export const rules = reactive({ + spuId: [required], + name: [required], + startTime: [required], + endTime: [required], + discountType: [required] +}) + +// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '活动名称', + field: 'name', + isSearch: true, + form: { + colProps: { + span: 24 + } + }, + table: { + width: 120 + } + }, + { + label: '活动开始时间', + field: 'startTime', + formatter: dateFormatter2, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD', + type: 'daterange' + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 120 + } + }, + { + label: '活动结束时间', + field: 'endTime', + formatter: dateFormatter2, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD', + type: 'daterange' + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 120 + } + }, + { + label: '优惠类型', + field: 'discountType', + dictType: DICT_TYPE.PROMOTION_DISCOUNT_TYPE, + dictClass: 'number', + isSearch: true, + form: { + component: 'Radio', + value: 1 + } + }, + { + label: '活动商品', + field: 'spuId', + isTable: true, + isSearch: false, + form: { + colProps: { + span: 24 + } + }, + table: { + width: 300 + } + }, + { + label: '备注', + field: 'remark', + isSearch: false, + form: { + component: 'Input', + componentProps: { + type: 'textarea', + rows: 4 + }, + colProps: { + span: 24 + } + }, + table: { + width: 300 + } + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) diff --git a/src/views/mall/promotion/discountActivity/index.vue b/src/views/mall/promotion/discountActivity/index.vue new file mode 100644 index 0000000..7d73b51 --- /dev/null +++ b/src/views/mall/promotion/discountActivity/index.vue @@ -0,0 +1,239 @@ +<template> + <doc-alert title="【营销】限时折扣" url="https://doc.iocoder.cn/mall/promotion-discount/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="活动名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入活动名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="活动状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择活动状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="活动时间" prop="activeTime"> + <el-date-picker + v-model="queryParams.activeTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:discount-activity:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增活动 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="活动编号" prop="id" min-width="80" /> + <el-table-column label="活动名称" prop="name" min-width="140" /> + <el-table-column label="活动时间" min-width="210"> + <template #default="scope"> + {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }} + ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }} + </template> + </el-table-column> + <el-table-column label="商品图片" prop="spuName" min-width="80"> + <template #default="scope"> + <el-image + :src="scope.row.picUrl" + class="h-40px w-40px" + :preview-src-list="[scope.row.picUrl]" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="商品标题" prop="spuName" min-width="300" /> + <el-table-column label="活动状态" align="center" prop="status" min-width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="150px" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['promotion:discount-activity:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleClose(scope.row.id)" + v-if="scope.row.status === 0" + v-hasPermi="['promotion:discount-activity:close']" + > + 关闭 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-else + v-hasPermi="['promotion:discount-activity:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + <!-- 表单弹窗:添加/修改 --> + <DiscountActivityForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as DiscountActivity from '@/api/mall/promotion/discount/discountActivity' +import DiscountActivityForm from './DiscountActivityForm.vue' +import { formatDate } from '@/utils/formatTime' +import { fenToYuanFormat } from '@/utils/formatter' +import { fenToYuan } from '@/utils' + +defineOptions({ name: 'DiscountActivity' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + activeTime: null, + name: null, + status: null +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DiscountActivity.getDiscountActivityPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 关闭按钮操作 */ +const handleClose = async (id: number) => { + try { + // 关闭的二次确认 + await message.confirm('确认关闭该限时折扣活动吗?') + // 发起关闭 + await DiscountActivity.closeDiscountActivity(id) + message.success('关闭成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DiscountActivity.deleteDiscountActivity(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +const configList = ref([]) // 时段配置精简列表 +// const formatConfigNames = (configId) => { +// const config = configList.value.find((item) => item.id === configId) +// return config != null ? `${config.name}[${config.startTime} ~ ${config.endTime}]` : '' +// } + +const formatSeckillPrice = (products) => { + // const seckillPrice = Math.min(...products.map((item) => item.seckillPrice)) + console.log(products) + const seckillPrice = 200 + return `¥${fenToYuan(seckillPrice)}` +} + +/** 初始化 **/ +onMounted(async () => { + await getList() +}) +</script> diff --git a/src/views/mall/promotion/diy/page/DiyPageForm.vue b/src/views/mall/promotion/diy/page/DiyPageForm.vue new file mode 100644 index 0000000..4c47187 --- /dev/null +++ b/src/views/mall/promotion/diy/page/DiyPageForm.vue @@ -0,0 +1,104 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item label="页面名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入页面名称" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + <el-form-item label="预览图" prop="previewPicUrls"> + <UploadImgs v-model="formData.previewPicUrls" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as DiyPageApi from '@/api/mall/promotion/diy/page' + +/** 装修页面表单 */ +defineOptions({ name: 'DiyPageForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + remark: undefined, + previewPicUrls: [] +}) +const formRules = reactive({ + name: [{ required: true, message: '页面名称不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await DiyPageApi.getDiyPage(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as DiyPageApi.DiyPageVO + if (formType.value === 'create') { + await DiyPageApi.createDiyPage(data) + message.success(t('common.createSuccess')) + } else { + await DiyPageApi.updateDiyPage(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + remark: undefined, + previewPicUrls: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/promotion/diy/page/decorate.vue b/src/views/mall/promotion/diy/page/decorate.vue new file mode 100644 index 0000000..fa20c3e --- /dev/null +++ b/src/views/mall/promotion/diy/page/decorate.vue @@ -0,0 +1,74 @@ +<template> + <DiyEditor + v-if="formData && !formLoading" + v-model="formData.property" + :title="formData.name" + :libs="PAGE_LIBS" + @save="submitForm" + /> +</template> +<script setup lang="ts"> +import * as DiyPageApi from '@/api/mall/promotion/diy/page' +import { useTagsViewStore } from '@/store/modules/tagsView' +import { PAGE_LIBS } from '@/components/DiyEditor/util' + +/** 装修页面表单 */ +defineOptions({ name: 'DiyPageDecorate' }) + +const message = useMessage() // 消息弹窗 + +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref<DiyPageApi.DiyPageVO>() +const formRef = ref() // 表单 Ref + +// 获取详情 +const getPageDetail = async (id: any) => { + formLoading.value = true + try { + formData.value = await DiyPageApi.getDiyPageProperty(id) + } finally { + formLoading.value = false + } +} + +// 提交表单 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + // 提交请求 + formLoading.value = true + try { + await DiyPageApi.updateDiyPageProperty(unref(formData)!) + message.success('保存成功') + } finally { + formLoading.value = false + } +} + +// 重置表单 +const resetForm = () => { + formData.value = { + id: undefined, + templateId: undefined, + name: '', + remark: '', + previewPicUrls: [], + property: '' + } as DiyPageApi.DiyPageVO + formRef.value?.resetFields() +} + +/** 初始化 **/ +const { currentRoute } = useRouter() // 路由 +const { delView } = useTagsViewStore() // 视图操作 +const route = useRoute() +onMounted(() => { + resetForm() + if (!route.params.id) { + message.warning('参数错误,页面编号不能为空!') + delView(unref(currentRoute)) + return + } + getPageDetail(route.params.id) +}) +</script> diff --git a/src/views/mall/promotion/diy/page/index.vue b/src/views/mall/promotion/diy/page/index.vue new file mode 100644 index 0000000..f225332 --- /dev/null +++ b/src/views/mall/promotion/diy/page/index.vue @@ -0,0 +1,191 @@ +<template> + <doc-alert title="【营销】商城装修" url="https://doc.iocoder.cn/mall/diy/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="页面名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入页面名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:diy-page:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="预览图" align="center" prop="previewPicUrls"> + <template #default="scope"> + <el-image + class="h-40px max-w-40px" + v-for="(url, index) in scope.row.previewPicUrls" + :key="index" + :src="url" + :preview-src-list="scope.row.previewPicUrls" + :initial-index="index" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="页面名称" align="center" prop="name" /> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="handleDecorate(scope.row.id)" + v-hasPermi="['promotion:diy-page:update']" + > + 装修 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['promotion:diy-page:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['promotion:diy-page:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <DiyPageForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as DiyPageApi from '@/api/mall/promotion/diy/page' +import DiyPageForm from './DiyPageForm.vue' + +/** 装修页面 */ +defineOptions({ name: 'DiyPage' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DiyPageApi.getDiyPagePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DiyPageApi.deleteDiyPage(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 打开装修页面 */ +const { push } = useRouter() +const handleDecorate = (id: number) => { + push({ name: 'DiyPageDecorate', params: { id } }) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/diy/template/DiyTemplateForm.vue b/src/views/mall/promotion/diy/template/DiyTemplateForm.vue new file mode 100644 index 0000000..f430d35 --- /dev/null +++ b/src/views/mall/promotion/diy/template/DiyTemplateForm.vue @@ -0,0 +1,104 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item label="模板名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入模板名称" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" /> + </el-form-item> + <el-form-item label="预览图" prop="previewPicUrls"> + <UploadImgs v-model="formData.previewPicUrls" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as DiyTemplateApi from '@/api/mall/promotion/diy/template' + +/** 装修模板表单 */ +defineOptions({ name: 'DiyTemplateForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + remark: undefined, + previewPicUrls: [] +}) +const formRules = reactive({ + name: [{ required: true, message: '模板名称不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await DiyTemplateApi.getDiyTemplate(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as DiyTemplateApi.DiyTemplateVO + if (formType.value === 'create') { + await DiyTemplateApi.createDiyTemplate(data) + message.success(t('common.createSuccess')) + } else { + await DiyTemplateApi.updateDiyTemplate(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + remark: undefined, + previewPicUrls: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/promotion/diy/template/decorate.vue b/src/views/mall/promotion/diy/template/decorate.vue new file mode 100644 index 0000000..e7838f2 --- /dev/null +++ b/src/views/mall/promotion/diy/template/decorate.vue @@ -0,0 +1,167 @@ +<template> + <DiyEditor + v-if="formData && !formLoading" + v-model="currentFormData!.property" + :title="templateItems[selectedTemplateItem].name" + :libs="libs" + :show-page-config="selectedTemplateItem !== 0" + :show-tab-bar="selectedTemplateItem === 0" + :show-navigation-bar="selectedTemplateItem !== 0" + :preview-url="previewUrl" + @save="submitForm" + @reset="handleEditorReset" + > + <template #toolBarLeft> + <el-radio-group + v-model="selectedTemplateItem" + class="h-full!" + @change="handleTemplateItemChange" + > + <el-tooltip v-for="(item, index) in templateItems" :key="index" :content="item.name"> + <el-radio-button :label="index"> + <Icon :icon="item.icon" :size="24" /> + </el-radio-button> + </el-tooltip> + </el-radio-group> + </template> + </DiyEditor> +</template> +<script setup lang="ts"> +// TODO @疯狂:要不要建个 decorate 目录,然后挪进去,改成 index.vue,这样可以更明确看到是个独立界面哈,更好找 +import * as DiyTemplateApi from '@/api/mall/promotion/diy/template' +import * as DiyPageApi from '@/api/mall/promotion/diy/page' +import { useTagsViewStore } from '@/store/modules/tagsView' +import { DiyComponentLibrary, PAGE_LIBS } from '@/components/DiyEditor/util' // 商城的 DIY 组件,在 DiyEditor 目录下 +import { toNumber } from 'lodash-es' + +/** 装修模板表单 */ +defineOptions({ name: 'DiyTemplateDecorate' }) + +// 左上角工具栏操作按钮 +const selectedTemplateItem = ref(0) +const templateItems = reactive([ + { name: '基础设置', icon: 'ep:iphone' }, + { name: '首页', icon: 'ep:home-filled' }, + { name: '我的', icon: 'ep:user-filled' } +]) + +const message = useMessage() // 消息弹窗 + +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref<DiyTemplateApi.DiyTemplatePropertyVO>() +const formRef = ref() // 表单 Ref +// 当前编辑的属性 +const currentFormData = ref<DiyTemplateApi.DiyTemplatePropertyVO | DiyPageApi.DiyPageVO>() +// 商城 H5 预览地址 +const previewUrl = ref('') + +// 获取详情 +const getPageDetail = async (id: any) => { + formLoading.value = true + try { + formData.value = await DiyTemplateApi.getDiyTemplateProperty(id) + currentFormData.value = formData.value + + // 拼接手机预览链接 + const domain = import.meta.env.VITE_MALL_H5_DOMAIN + previewUrl.value = `${domain}/#/pages/index/index?templateId=${formData.value.id}` + } finally { + formLoading.value = false + } +} + +// 模板组件库 +const templateLibs = [] as DiyComponentLibrary[] +// 当前组件库 +const libs = ref<DiyComponentLibrary[]>(templateLibs) +// 模板选项切换 +const handleTemplateItemChange = () => { + // 编辑模板 + if (selectedTemplateItem.value === 0) { + libs.value = templateLibs + currentFormData.value = formData.value + return + } + + // 编辑页面 + libs.value = PAGE_LIBS + currentFormData.value = formData.value!.pages.find( + (page: DiyPageApi.DiyPageVO) => page.name === templateItems[selectedTemplateItem.value].name + ) +} + +// 提交表单 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + // 提交请求 + formLoading.value = true + try { + if (selectedTemplateItem.value === 0) { + // 提交模板属性 + await DiyTemplateApi.updateDiyTemplateProperty(unref(formData)!) + } else { + // 提交页面属性 + await DiyPageApi.updateDiyPageProperty(unref(currentFormData)!) + } + message.success('保存成功') + } finally { + formLoading.value = false + } +} + +// 重置表单 +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + used: false, + usedTime: undefined, + remark: '', + previewPicUrls: [], + property: '', + pages: [] + } as DiyTemplateApi.DiyTemplatePropertyVO + formRef.value?.resetFields() +} + +// 重置时记录当前编辑的页面 +const handleEditorReset = () => storePageIndex() + +//#region 无感刷新 +// 记录标识 +const DIY_PAGE_INDEX_KEY = 'diy_page_index' +// 1. 记录 +const storePageIndex = () => + sessionStorage.setItem(DIY_PAGE_INDEX_KEY, `${selectedTemplateItem.value}`) +// 2. 恢复 +const recoverPageIndex = () => { + // 恢复重置前的页面,默认是第一个页面 + const pageIndex = toNumber(sessionStorage.getItem(DIY_PAGE_INDEX_KEY)) || 0 + // 移除标记 + sessionStorage.removeItem(DIY_PAGE_INDEX_KEY) + // 切换页面 + if (pageIndex !== selectedTemplateItem.value) { + selectedTemplateItem.value = pageIndex + handleTemplateItemChange() + } +} +//#endregion + +/** 初始化 **/ +const { currentRoute } = useRouter() // 路由 +const { delView } = useTagsViewStore() // 视图操作 +onMounted(async () => { + resetForm() + if (!currentRoute.value.params.id) { + message.warning('参数错误,页面编号不能为空!') + delView(unref(currentRoute)) + return + } + + // 查询详情 + await getPageDetail(currentRoute.value.params.id) + // 恢复重置前的页面 + recoverPageIndex() +}) +</script> diff --git a/src/views/mall/promotion/diy/template/index.vue b/src/views/mall/promotion/diy/template/index.vue new file mode 100644 index 0000000..50c5d29 --- /dev/null +++ b/src/views/mall/promotion/diy/template/index.vue @@ -0,0 +1,227 @@ +<template> + <doc-alert title="【营销】商城装修" url="https://doc.iocoder.cn/mall/diy/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="模板名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入模板名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:diy-template:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="预览图" align="center" prop="previewPicUrls"> + <template #default="scope"> + <el-image + class="h-40px max-w-40px" + v-for="(url, index) in scope.row.previewPicUrls" + :key="index" + :src="url" + :preview-src-list="scope.row.previewPicUrls" + :initial-index="index" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="模板名称" align="center" prop="name" /> + <el-table-column label="是否使用" align="center" prop="used"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.used" /> + </template> + </el-table-column> + <el-table-column + label="使用时间" + align="center" + prop="usedTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="200"> + <template #default="scope"> + <el-button + link + type="primary" + @click="handleDecorate(scope.row.id)" + v-hasPermi="['promotion:diy-template:update']" + > + 装修 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['promotion:diy-template:update']" + > + 编辑 + </el-button> + <template v-if="!scope.row.used"> + <el-button + link + type="primary" + @click="handleUse(scope.row)" + v-hasPermi="['promotion:diy-template:use']" + > + 使用 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['promotion:diy-template:delete']" + > + 删除 + </el-button> + </template> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <DiyTemplateForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as DiyTemplateApi from '@/api/mall/promotion/diy/template' +import DiyTemplateForm from './DiyTemplateForm.vue' +import { DICT_TYPE } from '@/utils/dict' + +/** 装修模板 */ +defineOptions({ name: 'DiyTemplate' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DiyTemplateApi.getDiyTemplatePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DiyTemplateApi.deleteDiyTemplate(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 使用模板 */ +const handleUse = async (row: DiyTemplateApi.DiyTemplateVO) => { + try { + // 使用模板的二次确认 + await message.confirm(`是否使用模板“${row.name}”?`) + // 发起删除 + await DiyTemplateApi.useDiyTemplate(row.id!) + message.success('使用成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 打开装修页面 */ +const { push } = useRouter() +const handleDecorate = (id: number) => { + push({ name: 'DiyTemplateDecorate', params: { id } }) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/kefu/components/KeFuConversationList.vue b/src/views/mall/promotion/kefu/components/KeFuConversationList.vue new file mode 100644 index 0000000..60188f3 --- /dev/null +++ b/src/views/mall/promotion/kefu/components/KeFuConversationList.vue @@ -0,0 +1,236 @@ +<template> + <div class="kefu"> + <div + v-for="item in conversationList" + :key="item.id" + :class="{ active: item.id === activeConversationId, pinned: item.adminPinned }" + class="kefu-conversation flex items-center" + @click="openRightMessage(item)" + @contextmenu.prevent="rightClick($event as PointerEvent, item)" + > + <div class="flex justify-center items-center w-100%"> + <div class="flex justify-center items-center w-50px h-50px"> + <!-- 头像 + 未读 --> + <el-badge + :hidden="item.adminUnreadMessageCount === 0" + :max="99" + :value="item.adminUnreadMessageCount" + > + <el-avatar :src="item.userAvatar" alt="avatar" /> + </el-badge> + </div> + <div class="ml-10px w-100%"> + <div class="flex justify-between items-center w-100%"> + <span class="username">{{ item.userNickname }}</span> + <span class="color-[#989EA6]"> + {{ formatPast(item.lastMessageTime, 'YYYY-mm-dd') }} + </span> + </div> + <!-- 最后聊天内容 --> + <div + v-dompurify-html=" + getConversationDisplayText(item.lastMessageContentType, item.lastMessageContent) + " + class="last-message flex items-center color-[#989EA6]" + ></div> + </div> + </div> + </div> + + <!-- 右键,进行操作(类似微信) --> + <ul v-show="showRightMenu" :style="rightMenuStyle" class="right-menu-ul"> + <li + v-show="!rightClickConversation.adminPinned" + class="flex items-center" + @click.stop="updateConversationPinned(true)" + > + <Icon class="mr-5px" icon="ep:top" /> + 置顶会话 + </li> + <li + v-show="rightClickConversation.adminPinned" + class="flex items-center" + @click.stop="updateConversationPinned(false)" + > + <Icon class="mr-5px" icon="ep:bottom" /> + 取消置顶 + </li> + <li class="flex items-center" @click.stop="deleteConversation"> + <Icon class="mr-5px" color="red" icon="ep:delete" /> + 删除会话 + </li> + <li class="flex items-center" @click.stop="closeRightMenu"> + <Icon class="mr-5px" color="red" icon="ep:close" /> + 取消 + </li> + </ul> + </div> +</template> + +<script lang="ts" setup> +import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation' +import { useEmoji } from './tools/emoji' +import { formatPast } from '@/utils/formatTime' +import { KeFuMessageContentTypeEnum } from './tools/constants' +import { useAppStore } from '@/store/modules/app' + +defineOptions({ name: 'KeFuConversationList' }) + +const message = useMessage() // 消息弹窗 +const appStore = useAppStore() +const { replaceEmoji } = useEmoji() +const conversationList = ref<KeFuConversationRespVO[]>([]) // 会话列表 +const activeConversationId = ref(-1) // 选中的会话 +const collapse = computed(() => appStore.getCollapse) // 折叠菜单 + +/** 加载会话列表 */ +const getConversationList = async () => { + const list = await KeFuConversationApi.getConversationList() + list.sort((a: KeFuConversationRespVO, _) => (a.adminPinned ? -1 : 1)) + conversationList.value = list +} +defineExpose({ getConversationList }) + +/** 打开右侧的消息列表 */ +const emits = defineEmits<{ + (e: 'change', v: KeFuConversationRespVO): void +}>() +const openRightMessage = (item: KeFuConversationRespVO) => { + activeConversationId.value = item.id + emits('change', item) +} + +/** 获得消息类型 */ +const getConversationDisplayText = computed( + () => (lastMessageContentType: number, lastMessageContent: string) => { + switch (lastMessageContentType) { + case KeFuMessageContentTypeEnum.SYSTEM: + return '[系统消息]' + case KeFuMessageContentTypeEnum.VIDEO: + return '[视频消息]' + case KeFuMessageContentTypeEnum.IMAGE: + return '[图片消息]' + case KeFuMessageContentTypeEnum.PRODUCT: + return '[商品消息]' + case KeFuMessageContentTypeEnum.ORDER: + return '[订单消息]' + case KeFuMessageContentTypeEnum.VOICE: + return '[语音消息]' + case KeFuMessageContentTypeEnum.TEXT: + return replaceEmoji(lastMessageContent) + default: + return '' + } + } +) + +//======================= 右键菜单 ======================= +const showRightMenu = ref(false) // 显示右键菜单 +const rightMenuStyle = ref<any>({}) // 右键菜单 Style +const rightClickConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 右键选中的会话对象 + +/** 打开右键菜单 */ +const rightClick = (mouseEvent: PointerEvent, item: KeFuConversationRespVO) => { + rightClickConversation.value = item + // 显示右键菜单 + showRightMenu.value = true + rightMenuStyle.value = { + top: mouseEvent.clientY - 110 + 'px', + left: collapse.value ? mouseEvent.clientX - 80 + 'px' : mouseEvent.clientX - 210 + 'px' + } +} +/** 关闭右键菜单 */ +const closeRightMenu = () => { + showRightMenu.value = false +} + +/** 置顶会话 */ +const updateConversationPinned = async (adminPinned: boolean) => { + // 1. 会话置顶/取消置顶 + await KeFuConversationApi.updateConversationPinned({ + id: rightClickConversation.value.id, + adminPinned + }) + message.notifySuccess(adminPinned ? '置顶成功' : '取消置顶成功') + // 2. 关闭右键菜单,更新会话列表 + closeRightMenu() + await getConversationList() +} + +/** 删除会话 */ +const deleteConversation = async () => { + // 1. 删除会话 + await message.confirm('您确定要删除该会话吗?') + await KeFuConversationApi.deleteConversation(rightClickConversation.value.id) + // 2. 关闭右键菜单,更新会话列表 + closeRightMenu() + await getConversationList() +} + +/** 监听右键菜单的显示状态,添加点击事件监听器 */ +watch(showRightMenu, (val) => { + if (val) { + document.body.addEventListener('click', closeRightMenu) + } else { + document.body.removeEventListener('click', closeRightMenu) + } +}) +</script> + +<style lang="scss" scoped> +.kefu { + &-conversation { + height: 60px; + padding: 10px; + background-color: #fff; + transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */ + + .username { + min-width: 0; + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + } + + .last-message { + width: 200px; + overflow: hidden; // 隐藏超出的文本 + white-space: nowrap; // 禁止换行 + text-overflow: ellipsis; // 添加省略号 + } + } + + .active { + border-left: 5px #3271ff solid; + background-color: #eff0f1; + } + + .pinned { + background-color: #eff0f1; + } + + .right-menu-ul { + position: absolute; + background-color: #fff; + padding: 10px; + margin: 0; + list-style-type: none; /* 移除默认的项目符号 */ + border-radius: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */ + width: 130px; + + li { + padding: 8px 16px; + cursor: pointer; + border-radius: 12px; + transition: background-color 0.3s; /* 平滑过渡 */ + &:hover { + background-color: #e0e0e0; /* 悬停时的背景颜色 */ + } + } + } +} +</style> diff --git a/src/views/mall/promotion/kefu/components/KeFuMessageList.vue b/src/views/mall/promotion/kefu/components/KeFuMessageList.vue new file mode 100644 index 0000000..d3632e6 --- /dev/null +++ b/src/views/mall/promotion/kefu/components/KeFuMessageList.vue @@ -0,0 +1,465 @@ +<template> + <el-container v-if="showKeFuMessageList" class="kefu"> + <el-header> + <div class="kefu-title">{{ conversation.userNickname }}</div> + </el-header> + <el-main class="kefu-content overflow-visible"> + <el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)" @scroll="handleScroll"> + <div v-if="refreshContent" ref="innerRef" class="w-[100%] pb-3px"> + <!-- 消息列表 --> + <div v-for="(item, index) in getMessageList0" :key="item.id" class="w-[100%]"> + <div class="flex justify-center items-center mb-20px"> + <!-- 日期 --> + <div + v-if=" + item.contentType !== KeFuMessageContentTypeEnum.SYSTEM && showTime(item, index) + " + class="date-message" + > + {{ formatDate(item.createTime) }} + </div> + <!-- 系统消息 --> + <div + v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM" + class="system-message" + > + {{ item.content }} + </div> + </div> + <div + :class="[ + item.senderType === UserTypeEnum.MEMBER + ? `ss-row-left` + : item.senderType === UserTypeEnum.ADMIN + ? `ss-row-right` + : '' + ]" + class="flex mb-20px w-[100%]" + > + <el-avatar + v-if="item.senderType === UserTypeEnum.MEMBER" + :src="conversation.userAvatar" + alt="avatar" + class="w-60px h-60px" + /> + <div + :class="{ 'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType }" + class="p-10px" + > + <!-- 文本消息 --> + <MessageItem :message="item"> + <template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType"> + <div + v-dompurify-html="replaceEmoji(item.content)" + class="flex items-center" + ></div> + </template> + </MessageItem> + <!-- 图片消息 --> + <MessageItem :message="item"> + <el-image + v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType" + :initial-index="0" + :preview-src-list="[item.content]" + :src="item.content" + class="w-200px" + fit="contain" + preview-teleported + /> + </MessageItem> + <!-- 商品消息 --> + <MessageItem :message="item"> + <ProductItem + v-if="KeFuMessageContentTypeEnum.PRODUCT === item.contentType" + :picUrl="getMessageContent(item).picUrl" + :price="getMessageContent(item).price" + :skuText="getMessageContent(item).introduction" + :title="getMessageContent(item).spuName" + :titleWidth="400" + class="max-w-70%" + priceColor="#FF3000" + /> + </MessageItem> + <!-- 订单消息 --> + <MessageItem :message="item"> + <OrderItem + v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType" + :message="item" + class="max-w-70%" + /> + </MessageItem> + </div> + <el-avatar + v-if="item.senderType === UserTypeEnum.ADMIN" + :src="item.senderAvatar" + alt="avatar" + /> + </div> + </div> + </div> + </el-scrollbar> + <div + v-show="showNewMessageTip" + class="newMessageTip flex items-center cursor-pointer" + @click="handleToNewMessage" + > + <span>有新消息</span> + <Icon class="ml-5px" icon="ep:bottom" /> + </div> + </el-main> + <el-footer height="230px"> + <div class="h-[100%]"> + <div class="chat-tools flex items-center"> + <EmojiSelectPopover @select-emoji="handleEmojiSelect" /> + <PictureSelectUpload + class="ml-15px mt-3px cursor-pointer" + @send-picture="handleSendPicture" + /> + </div> + <el-input v-model="message" :rows="6" style="border-style: none" type="textarea" /> + <div class="h-45px flex justify-end"> + <el-button class="mt-10px" type="primary" @click="handleSendMessage">发送</el-button> + </div> + </div> + </el-footer> + </el-container> + <el-empty v-else description="请选择左侧的一个会话后开始" /> +</template> + +<script lang="ts" setup> +import { ElScrollbar as ElScrollbarType } from 'element-plus' +import { KeFuMessageApi, KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message' +import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation' +import EmojiSelectPopover from './tools/EmojiSelectPopover.vue' +import PictureSelectUpload from './tools/PictureSelectUpload.vue' +import ProductItem from './message/ProductItem.vue' +import OrderItem from './message/OrderItem.vue' +import { Emoji, useEmoji } from './tools/emoji' +import { KeFuMessageContentTypeEnum } from './tools/constants' +import { isEmpty } from '@/utils/is' +import { UserTypeEnum } from '@/utils/constants' +import { formatDate } from '@/utils/formatTime' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { debounce } from 'lodash-es' +import { jsonParse } from '@/utils' + +dayjs.extend(relativeTime) + +defineOptions({ name: 'KeFuMessageList' }) + +const message = ref('') // 消息弹窗 +const { replaceEmoji } = useEmoji() +const messageTool = useMessage() +const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表 +const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话 +const showNewMessageTip = ref(false) // 显示有新消息提示 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + conversationId: 0 +}) +const total = ref(0) // 消息总条数 +const refreshContent = ref(false) // 内容刷新,主要解决会话消息页面高度不一致导致的滚动功能精度失效 + +/** 获悉消息内容 */ +const getMessageContent = computed(() => (item: any) => jsonParse(item.content)) +/** 获得消息列表 */ +const getMessageList = async () => { + const res = await KeFuMessageApi.getKeFuMessagePage(queryParams) + total.value = res.total + // 情况一:加载最新消息 + if (queryParams.pageNo === 1) { + messageList.value = res.list + } else { + // 情况二:加载历史消息 + for (const item of res.list) { + pushMessage(item) + } + } + refreshContent.value = true +} + +/** 添加消息 */ +const pushMessage = (message: any) => { + if (messageList.value.some((val) => val.id === message.id)) { + return + } + messageList.value.push(message) +} + +/** 按照时间倒序,获取消息列表 */ +const getMessageList0 = computed(() => { + messageList.value.sort((a: any, b: any) => a.createTime - b.createTime) + return messageList.value +}) + +/** 刷新消息列表 */ +const refreshMessageList = async (message?: any) => { + if (!conversation.value) { + return + } + + if (typeof message !== 'undefined') { + // 当前查询会话与消息所属会话不一致则不做处理 + if (message.conversationId !== conversation.value.id) { + return + } + pushMessage(message) + } else { + // TODO @puhui999:不基于 page 做。而是流式分页;通过 createTime 排序查询; + queryParams.pageNo = 1 + await getMessageList() + } + + if (loadHistory.value) { + // 右下角显示有新消息提示 + showNewMessageTip.value = true + } else { + // 滚动到最新消息处 + await handleToNewMessage() + } +} + +/** 获得新会话的消息列表 */ +// TODO @puhui999:可优化:可以考虑本地做每个会话的消息 list 缓存;然后点击切换时,读取缓存;然后异步获取新消息,merge 下; +const getNewMessageList = async (val: KeFuConversationRespVO) => { + // 会话切换,重置相关参数 + queryParams.pageNo = 1 + messageList.value = [] + total.value = 0 + loadHistory.value = false + refreshContent.value = false + // 设置会话相关属性 + conversation.value = val + queryParams.conversationId = val.id + // 获取消息 + await refreshMessageList() +} +defineExpose({ getNewMessageList, refreshMessageList }) + +const showKeFuMessageList = computed(() => !isEmpty(conversation.value)) // 是否显示聊天区域 +const skipGetMessageList = computed(() => { + // 已加载到最后一页的话则不触发新的消息获取 + return total.value > 0 && Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo +}) // 跳过消息获取 + +/** 处理表情选择 */ +const handleEmojiSelect = (item: Emoji) => { + message.value += item.name +} + +/** 处理图片发送 */ +const handleSendPicture = async (picUrl: string) => { + // 组织发送消息 + const msg = { + conversationId: conversation.value.id, + contentType: KeFuMessageContentTypeEnum.IMAGE, + content: picUrl + } + await sendMessage(msg) +} + +/** 发送文本消息 */ +const handleSendMessage = async () => { + // 1. 校验消息是否为空 + if (isEmpty(unref(message.value))) { + messageTool.notifyWarning('请输入消息后再发送哦!') + return + } + // 2. 组织发送消息 + const msg = { + conversationId: conversation.value.id, + contentType: KeFuMessageContentTypeEnum.TEXT, + content: message.value + } + await sendMessage(msg) +} + +/** 真正发送消息 【共用】*/ +const sendMessage = async (msg: any) => { + // 发送消息 + await KeFuMessageApi.sendKeFuMessage(msg) + message.value = '' + // 加载消息列表 + await refreshMessageList() +} + +/** 滚动到底部 */ +const innerRef = ref<HTMLDivElement>() +const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>() +const scrollToBottom = async () => { + // 1. 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动 + if (loadHistory.value) { + return + } + // 2.1 滚动到最新消息,关闭新消息提示 + await nextTick() + scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight) + showNewMessageTip.value = false + // 2.2 消息已读 + await KeFuMessageApi.updateKeFuMessageReadStatus(conversation.value.id) +} + +/** 查看新消息 */ +const handleToNewMessage = async () => { + loadHistory.value = false + await scrollToBottom() +} + +const loadHistory = ref(false) // 加载历史消息 +/** 处理消息列表滚动事件(debounce 限流) */ +const handleScroll = debounce(({ scrollTop }) => { + if (skipGetMessageList.value) { + return + } + // 触顶自动加载下一页数据 + if (Math.floor(scrollTop) === 0) { + handleOldMessage() + } + const wrap = scrollbarRef.value?.wrapRef + // 触底重置 + if (Math.abs(wrap!.scrollHeight - wrap!.clientHeight - wrap!.scrollTop) < 1) { + loadHistory.value = false + refreshMessageList() + } +}, 200) +/** 加载历史消息 */ +const handleOldMessage = async () => { + // 记录已有页面高度 + const oldPageHeight = innerRef.value?.clientHeight + if (!oldPageHeight) { + return + } + loadHistory.value = true + // 加载消息列表 + queryParams.pageNo += 1 + await getMessageList() + // 等页面加载完后,获得上一页最后一条消息的位置,控制滚动到它所在位置 + scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight - oldPageHeight) +} + +/** + * 是否显示时间 + * + * @param {*} item - 数据 + * @param {*} index - 索引 + */ +const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => { + if (unref(messageList.value)[index + 1]) { + let dateString = dayjs(unref(messageList.value)[index + 1].createTime).fromNow() + return dateString !== dayjs(unref(item).createTime).fromNow() + } + return false +}) +</script> + +<style lang="scss" scoped> +.kefu { + &-title { + border-bottom: #e4e0e0 solid 1px; + height: 60px; + line-height: 60px; + } + + &-content { + position: relative; + + .newMessageTip { + position: absolute; + bottom: 35px; + right: 35px; + background-color: #fff; + padding: 10px; + border-radius: 30px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */ + } + + .ss-row-left { + justify-content: flex-start; + + .kefu-message { + margin-left: 20px; + position: relative; + + &::before { + content: ''; + width: 10px; + height: 10px; + left: -19px; + top: calc(50% - 10px); + position: absolute; + border-left: 5px solid transparent; + border-bottom: 5px solid transparent; + border-top: 5px solid transparent; + border-right: 5px solid #ffffff; + } + } + } + + .ss-row-right { + justify-content: flex-end; + + .kefu-message { + margin-right: 20px; + position: relative; + + &::after { + content: ''; + width: 10px; + height: 10px; + right: -19px; + top: calc(50% - 10px); + position: absolute; + border-left: 5px solid #ffffff; + border-bottom: 5px solid transparent; + border-top: 5px solid transparent; + border-right: 5px solid transparent; + } + } + } + + // 消息气泡 + .kefu-message { + color: #333; + border-radius: 5px; + box-shadow: 3px 5px 15px rgba(0, 0, 0, 0.2); + padding: 5px 10px; + width: auto; + max-width: 50%; + text-align: left; + display: inline-block !important; + position: relative; + word-break: break-all; + background-color: #ffffff; + transition: all 0.2s; + + &:hover { + transform: scale(1.03); + } + } + + .date-message, + .system-message { + width: fit-content; + border-radius: 12rpx; + padding: 8rpx 16rpx; + margin-bottom: 16rpx; + background-color: #e8e8e8; + color: #999; + font-size: 24rpx; + } + } + + .chat-tools { + width: 100%; + border: #e4e0e0 solid 1px; + border-radius: 10px; + height: 44px; + } + + ::v-deep(textarea) { + resize: none; + } +} +</style> diff --git a/src/views/mall/promotion/kefu/components/asserts/a.png b/src/views/mall/promotion/kefu/components/asserts/a.png new file mode 100644 index 0000000..3293900 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/a.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/aini.png b/src/views/mall/promotion/kefu/components/asserts/aini.png new file mode 100644 index 0000000..02cf5c4 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/aini.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/aixin.png b/src/views/mall/promotion/kefu/components/asserts/aixin.png new file mode 100644 index 0000000..25e6422 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/aixin.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/baiyan.png b/src/views/mall/promotion/kefu/components/asserts/baiyan.png new file mode 100644 index 0000000..d16260a Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/baiyan.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/bizui.png b/src/views/mall/promotion/kefu/components/asserts/bizui.png new file mode 100644 index 0000000..a3b1800 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/bizui.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/buhaoyisi.png b/src/views/mall/promotion/kefu/components/asserts/buhaoyisi.png new file mode 100644 index 0000000..54c4b3f Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/buhaoyisi.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/bukesiyi.png b/src/views/mall/promotion/kefu/components/asserts/bukesiyi.png new file mode 100644 index 0000000..5f272e3 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/bukesiyi.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/dajing.png b/src/views/mall/promotion/kefu/components/asserts/dajing.png new file mode 100644 index 0000000..8649727 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/dajing.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/danao.png b/src/views/mall/promotion/kefu/components/asserts/danao.png new file mode 100644 index 0000000..aa85a29 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/danao.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/daxiao.png b/src/views/mall/promotion/kefu/components/asserts/daxiao.png new file mode 100644 index 0000000..26206bc Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/daxiao.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/dianzan.png b/src/views/mall/promotion/kefu/components/asserts/dianzan.png new file mode 100644 index 0000000..2e7f00e Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/dianzan.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/emo.png b/src/views/mall/promotion/kefu/components/asserts/emo.png new file mode 100644 index 0000000..9c84551 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/emo.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/esi.png b/src/views/mall/promotion/kefu/components/asserts/esi.png new file mode 100644 index 0000000..84e9726 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/esi.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/fadai.png b/src/views/mall/promotion/kefu/components/asserts/fadai.png new file mode 100644 index 0000000..0772de2 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/fadai.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/fankun.png b/src/views/mall/promotion/kefu/components/asserts/fankun.png new file mode 100644 index 0000000..6e18dac Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/fankun.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/feiwen.png b/src/views/mall/promotion/kefu/components/asserts/feiwen.png new file mode 100644 index 0000000..be97616 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/feiwen.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/fennu.png b/src/views/mall/promotion/kefu/components/asserts/fennu.png new file mode 100644 index 0000000..20c5733 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/fennu.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/ganga.png b/src/views/mall/promotion/kefu/components/asserts/ganga.png new file mode 100644 index 0000000..30ec329 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/ganga.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/ganmao.png b/src/views/mall/promotion/kefu/components/asserts/ganmao.png new file mode 100644 index 0000000..35bbb89 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/ganmao.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/hanyan.png b/src/views/mall/promotion/kefu/components/asserts/hanyan.png new file mode 100644 index 0000000..a0bc838 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/hanyan.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/haochi.png b/src/views/mall/promotion/kefu/components/asserts/haochi.png new file mode 100644 index 0000000..2e52b6b Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/haochi.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/hongxin.png b/src/views/mall/promotion/kefu/components/asserts/hongxin.png new file mode 100644 index 0000000..65b5de8 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/hongxin.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/huaixiao.png b/src/views/mall/promotion/kefu/components/asserts/huaixiao.png new file mode 100644 index 0000000..bc0e76c Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/huaixiao.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/jingkong.png b/src/views/mall/promotion/kefu/components/asserts/jingkong.png new file mode 100644 index 0000000..7aa6584 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/jingkong.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/jingshu.png b/src/views/mall/promotion/kefu/components/asserts/jingshu.png new file mode 100644 index 0000000..0e984d6 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/jingshu.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/jingya.png b/src/views/mall/promotion/kefu/components/asserts/jingya.png new file mode 100644 index 0000000..9ba6bab Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/jingya.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/kaixin.png b/src/views/mall/promotion/kefu/components/asserts/kaixin.png new file mode 100644 index 0000000..29c9f5d Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/kaixin.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/keai.png b/src/views/mall/promotion/kefu/components/asserts/keai.png new file mode 100644 index 0000000..d3b582c Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/keai.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/keshui.png b/src/views/mall/promotion/kefu/components/asserts/keshui.png new file mode 100644 index 0000000..cef489e Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/keshui.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/kun.png b/src/views/mall/promotion/kefu/components/asserts/kun.png new file mode 100644 index 0000000..1ddc388 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/kun.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/lengku.png b/src/views/mall/promotion/kefu/components/asserts/lengku.png new file mode 100644 index 0000000..c5c6fee Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/lengku.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/liuhan.png b/src/views/mall/promotion/kefu/components/asserts/liuhan.png new file mode 100644 index 0000000..e6ddc6f Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/liuhan.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/liukoushui.png b/src/views/mall/promotion/kefu/components/asserts/liukoushui.png new file mode 100644 index 0000000..3e2fba6 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/liukoushui.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/liulei.png b/src/views/mall/promotion/kefu/components/asserts/liulei.png new file mode 100644 index 0000000..dbf8204 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/liulei.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/mengbi.png b/src/views/mall/promotion/kefu/components/asserts/mengbi.png new file mode 100644 index 0000000..a4206ee Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/mengbi.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/mianwubiaoqing.png b/src/views/mall/promotion/kefu/components/asserts/mianwubiaoqing.png new file mode 100644 index 0000000..6f315b9 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/mianwubiaoqing.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/nanguo.png b/src/views/mall/promotion/kefu/components/asserts/nanguo.png new file mode 100644 index 0000000..19b9fb9 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/nanguo.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/outu.png b/src/views/mall/promotion/kefu/components/asserts/outu.png new file mode 100644 index 0000000..2f9a06d Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/outu.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/picture.svg b/src/views/mall/promotion/kefu/components/asserts/picture.svg new file mode 100644 index 0000000..8811d49 --- /dev/null +++ b/src/views/mall/promotion/kefu/components/asserts/picture.svg @@ -0,0 +1,10 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" + "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg t="1720063872285" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6895" + width="200" height="200"> + <path d="M782.16 880.98c-179.31 23.91-361 23.91-540.32 0C138.89 867.25 62 779.43 62 675.57V348.43c0-103.86 76.89-191.69 179.84-205.41 179.31-23.91 361-23.91 540.31 0C885.11 156.75 962 244.57 962 348.43v327.13c0 103.87-76.89 191.69-179.84 205.42z" + fill="#FF554D" p-id="6896"></path> + <path d="M226.11 596.86c-9.74 47.83 17.26 95.6 63.48 111.3C333.49 723.08 394.55 737 469.53 737c59.25 0 105.46-8.69 140.23-19.7 51.59-16.34 79.94-71.16 63.37-122.68-24.47-76.11-65.57-180.7-106.68-180.7-64.62 0-64.62 96.92-64.62 96.92S437.22 317 372.61 317c-82.11 0-117.85 139.12-146.5 279.86z" + fill="#FFFFFF" p-id="6897"></path> + <path d="M782 347m-60 0a60 60 0 1 0 120 0 60 60 0 1 0-120 0Z" fill="#FFBC55" p-id="6898"></path> +</svg> diff --git a/src/views/mall/promotion/kefu/components/asserts/shengqi.png b/src/views/mall/promotion/kefu/components/asserts/shengqi.png new file mode 100644 index 0000000..7dce41d Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/shengqi.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/shuizhuo.png b/src/views/mall/promotion/kefu/components/asserts/shuizhuo.png new file mode 100644 index 0000000..97d0f0a Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/shuizhuo.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/tianshi.png b/src/views/mall/promotion/kefu/components/asserts/tianshi.png new file mode 100644 index 0000000..eb922dd Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/tianshi.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/xiaodiaoya.png b/src/views/mall/promotion/kefu/components/asserts/xiaodiaoya.png new file mode 100644 index 0000000..29fbc0e Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/xiaodiaoya.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/xiaoku.png b/src/views/mall/promotion/kefu/components/asserts/xiaoku.png new file mode 100644 index 0000000..88a169d Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/xiaoku.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/xinsui.png b/src/views/mall/promotion/kefu/components/asserts/xinsui.png new file mode 100644 index 0000000..a0f572a Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/xinsui.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/xiong.png b/src/views/mall/promotion/kefu/components/asserts/xiong.png new file mode 100644 index 0000000..43dfd70 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/xiong.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/yiwen.png b/src/views/mall/promotion/kefu/components/asserts/yiwen.png new file mode 100644 index 0000000..4c0da70 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/yiwen.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/yun.png b/src/views/mall/promotion/kefu/components/asserts/yun.png new file mode 100644 index 0000000..56e5d02 Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/yun.png differ diff --git a/src/views/mall/promotion/kefu/components/asserts/ziya.png b/src/views/mall/promotion/kefu/components/asserts/ziya.png new file mode 100644 index 0000000..593ef5e Binary files /dev/null and b/src/views/mall/promotion/kefu/components/asserts/ziya.png differ diff --git a/src/views/mall/promotion/kefu/components/history/MemberBrowsingHistory.vue b/src/views/mall/promotion/kefu/components/history/MemberBrowsingHistory.vue new file mode 100644 index 0000000..8b6891a --- /dev/null +++ b/src/views/mall/promotion/kefu/components/history/MemberBrowsingHistory.vue @@ -0,0 +1,97 @@ +<!-- 目录是不是叫 member 好点。然后这个组件是 MemberInfo,里面有浏览足迹 --> +<template> + <div v-show="!isEmpty(conversation)" class="kefu"> + <div class="header-title h-60px flex justify-center items-center">他的足迹</div> + <el-tabs v-model="activeName" class="demo-tabs" @tab-click="handleClick"> + <el-tab-pane label="最近浏览" name="a" /> + <el-tab-pane label="订单列表" name="b" /> + </el-tabs> + <div> + <el-scrollbar ref="scrollbarRef" always height="calc(100vh - 400px)" @scroll="handleScroll"> + <!-- 最近浏览 --> + <ProductBrowsingHistory v-if="activeName === 'a'" ref="productBrowsingHistoryRef" /> + <!-- 订单列表 --> + <OrderBrowsingHistory v-if="activeName === 'b'" ref="orderBrowsingHistoryRef" /> + </el-scrollbar> + </div> + </div> + <el-empty v-show="isEmpty(conversation)" description="请选择左侧的一个会话后开始" /> +</template> + +<script lang="ts" setup> +import type { TabsPaneContext } from 'element-plus' +import ProductBrowsingHistory from './ProductBrowsingHistory.vue' +import OrderBrowsingHistory from './OrderBrowsingHistory.vue' +import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation' +import { isEmpty } from '@/utils/is' +import { debounce } from 'lodash-es' +import { ElScrollbar as ElScrollbarType } from 'element-plus/es/components/scrollbar/index' + +defineOptions({ name: 'MemberBrowsingHistory' }) + +const activeName = ref('a') + +/** tab 切换 */ +const productBrowsingHistoryRef = ref<InstanceType<typeof ProductBrowsingHistory>>() +const orderBrowsingHistoryRef = ref<InstanceType<typeof OrderBrowsingHistory>>() +const handleClick = async (tab: TabsPaneContext) => { + activeName.value = tab.paneName as string + await nextTick() + await getHistoryList() +} + +/** 获得历史数据 */ +// TODO @puhui:不要用 a、b 哈。就订单列表、浏览列表这种噶 +const getHistoryList = async () => { + switch (activeName.value) { + case 'a': + await productBrowsingHistoryRef.value?.getHistoryList(conversation.value) + break + case 'b': + await orderBrowsingHistoryRef.value?.getHistoryList(conversation.value) + break + default: + break + } +} + +/** 加载下一页数据 */ +const loadMore = async () => { + switch (activeName.value) { + case 'a': + await productBrowsingHistoryRef.value?.loadMore() + break + case 'b': + await orderBrowsingHistoryRef.value?.loadMore() + break + default: + break + } +} + +/** 浏览历史初始化 */ +const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话 +const initHistory = async (val: KeFuConversationRespVO) => { + activeName.value = 'a' + conversation.value = val + await nextTick() + await getHistoryList() +} +defineExpose({ initHistory }) + +/** 处理消息列表滚动事件(debounce 限流) */ +const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>() +const handleScroll = debounce(() => { + const wrap = scrollbarRef.value?.wrapRef + // 触底重置 + if (Math.abs(wrap!.scrollHeight - wrap!.clientHeight - wrap!.scrollTop) < 1) { + loadMore() + } +}, 200) +</script> + +<style lang="scss" scoped> +.header-title { + border-bottom: #e4e0e0 solid 1px; +} +</style> diff --git a/src/views/mall/promotion/kefu/components/history/OrderBrowsingHistory.vue b/src/views/mall/promotion/kefu/components/history/OrderBrowsingHistory.vue new file mode 100644 index 0000000..8fb8891 --- /dev/null +++ b/src/views/mall/promotion/kefu/components/history/OrderBrowsingHistory.vue @@ -0,0 +1,44 @@ +<template> + <OrderItem v-for="item in list" :key="item.id" :order="item" class="mb-10px" /> +</template> + +<script lang="ts" setup> +import OrderItem from '@/views/mall/promotion/kefu/components/message/OrderItem.vue' +import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation' +import { getOrderPage } from '@/api/mall/trade/order' +import { concat } from 'lodash-es' + +defineOptions({ name: 'OrderBrowsingHistory' }) + +const list = ref<any>([]) // 列表 +const total = ref(0) // 总数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: 0 +}) +const skipGetMessageList = computed(() => { + // 已加载到最后一页的话则不触发新的消息获取 + return total.value > 0 && Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo +}) // 跳过消息获取 + +/** 获得浏览记录 */ +const getHistoryList = async (val: KeFuConversationRespVO) => { + queryParams.userId = val.userId + const res = await getOrderPage(queryParams) + total.value = res.total + list.value = res.list +} + +/** 加载下一页数据 */ +const loadMore = async () => { + if (skipGetMessageList.value) { + return + } + queryParams.pageNo += 1 + const res = await getOrderPage(queryParams) + total.value = res.total + concat(list.value, res.list) +} +defineExpose({ getHistoryList, loadMore }) +</script> diff --git a/src/views/mall/promotion/kefu/components/history/ProductBrowsingHistory.vue b/src/views/mall/promotion/kefu/components/history/ProductBrowsingHistory.vue new file mode 100644 index 0000000..c9bff18 --- /dev/null +++ b/src/views/mall/promotion/kefu/components/history/ProductBrowsingHistory.vue @@ -0,0 +1,57 @@ +<template> + <ProductItem + v-for="item in list" + :key="item.id" + :picUrl="item.picUrl" + :price="item.price" + :skuText="item.introduction" + :title="item.spuName" + :titleWidth="400" + class="mb-10px" + priceColor="#FF3000" + /> +</template> + +<script lang="ts" setup> +import { getBrowseHistoryPage } from '@/api/mall/product/history' +import ProductItem from '@/views/mall/promotion/kefu/components/message/ProductItem.vue' +import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation' +import { concat } from 'lodash-es' + +defineOptions({ name: 'ProductBrowsingHistory' }) + +const list = ref<any>([]) // 列表 +const total = ref(0) // 总数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: 0, + userDeleted: false +}) +const skipGetMessageList = computed(() => { + // 已加载到最后一页的话则不触发新的消息获取 + return total.value > 0 && Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo +}) // 跳过消息获取 + +/** 获得浏览记录 */ +const getHistoryList = async (val: KeFuConversationRespVO) => { + queryParams.userId = val.userId + const res = await getBrowseHistoryPage(queryParams) + total.value = res.total + list.value = res.list +} + +/** 加载下一页数据 */ +const loadMore = async () => { + if (skipGetMessageList.value) { + return + } + queryParams.pageNo += 1 + const res = await getBrowseHistoryPage(queryParams) + total.value = res.total + concat(list.value, res.list) +} +defineExpose({ getHistoryList, loadMore }) +</script> + +<style lang="scss" scoped></style> diff --git a/src/views/mall/promotion/kefu/components/index.ts b/src/views/mall/promotion/kefu/components/index.ts new file mode 100644 index 0000000..585d0da --- /dev/null +++ b/src/views/mall/promotion/kefu/components/index.ts @@ -0,0 +1,5 @@ +import KeFuConversationList from './KeFuConversationList.vue' +import KeFuMessageList from './KeFuMessageList.vue' +import MemberBrowsingHistory from './history/MemberBrowsingHistory.vue' + +export { KeFuConversationList, KeFuMessageList, MemberBrowsingHistory } diff --git a/src/views/mall/promotion/kefu/components/message/MessageItem.vue b/src/views/mall/promotion/kefu/components/message/MessageItem.vue new file mode 100644 index 0000000..325a363 --- /dev/null +++ b/src/views/mall/promotion/kefu/components/message/MessageItem.vue @@ -0,0 +1,24 @@ +<template> + <!-- 消息组件 --> + <div + :class="[ + message.senderType === UserTypeEnum.MEMBER + ? `ml-10px` + : message.senderType === UserTypeEnum.ADMIN + ? `mr-10px` + : '' + ]" + > + <slot></slot> + </div> +</template> + +<script lang="ts" setup> +import { UserTypeEnum } from '@/utils/constants' +import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message' + +defineOptions({ name: 'MessageItem' }) +defineProps<{ + message: KeFuMessageRespVO +}>() +</script> diff --git a/src/views/mall/promotion/kefu/components/message/OrderItem.vue b/src/views/mall/promotion/kefu/components/message/OrderItem.vue new file mode 100644 index 0000000..5ee21f2 --- /dev/null +++ b/src/views/mall/promotion/kefu/components/message/OrderItem.vue @@ -0,0 +1,146 @@ +<template> + <div v-if="isObject(getMessageContent)"> + <div :key="getMessageContent.id" class="order-list-card-box mt-14px"> + <div class="order-card-header flex items-center justify-between p-x-20px"> + <div class="order-no">订单号:{{ getMessageContent.no }}</div> + <div :class="formatOrderColor(getMessageContent)" class="order-state font-16"> + {{ formatOrderStatus(getMessageContent) }} + </div> + </div> + <div v-for="item in getMessageContent.items" :key="item.id" class="border-bottom"> + <ProductItem + :num="item.count" + :picUrl="item.picUrl" + :price="item.price" + :skuText="item.properties.map((property: any) => property.valueName).join(' ')" + :title="item.spuName" + /> + </div> + <div class="pay-box flex justify-end pr-20px"> + <div class="flex items-center"> + <div class="discounts-title pay-color" + >共 {{ getMessageContent?.productCount }} 件商品,总金额: + </div> + <div class="discounts-money pay-color"> + ¥{{ fenToYuan(getMessageContent?.payPrice) }} + </div> + </div> + </div> + </div> + </div> +</template> + +<script lang="ts" setup> +import { fenToYuan, jsonParse } from '@/utils' +import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message' +import { isObject } from '@/utils/is' +import ProductItem from '@/views/mall/promotion/kefu/components/message/ProductItem.vue' + +defineOptions({ name: 'OrderItem' }) +const props = defineProps<{ + message?: KeFuMessageRespVO + order?: any +}>() + +const getMessageContent = computed(() => + typeof props.message !== 'undefined' ? jsonParse(props!.message!.content) : props.order +) + +/** + * 格式化订单状态的颜色 + * + * @param order 订单 + * @return {string} 颜色的 class 名称 + */ +function formatOrderColor(order: any) { + if (order.status === 0) { + return 'info-color' + } + if (order.status === 10 || order.status === 20 || (order.status === 30 && !order.commentStatus)) { + return 'warning-color' + } + if (order.status === 30 && order.commentStatus) { + return 'success-color' + } + return 'danger-color' +} + +/** + * 格式化订单状态 + * + * @param order 订单 + */ +function formatOrderStatus(order: any) { + if (order.status === 0) { + return '待付款' + } + if (order.status === 10 && order.deliveryType === 1) { + return '待发货' + } + if (order.status === 10 && order.deliveryType === 2) { + return '待核销' + } + if (order.status === 20) { + return '待收货' + } + if (order.status === 30 && !order.commentStatus) { + return '待评价' + } + if (order.status === 30 && order.commentStatus) { + return '已完成' + } + return '已关闭' +} +</script> + +<style lang="scss" scoped> +.order-list-card-box { + border-radius: 10px; + padding: 10px; + background-color: #e2e2e2; + + .order-card-header { + height: 28px; + + .order-no { + font-size: 16px; + font-weight: 500; + } + } + + .pay-box { + .discounts-title { + font-size: 16px; + line-height: normal; + color: #999999; + } + + .discounts-money { + font-size: 16px; + line-height: normal; + color: #999; + font-family: OPPOSANS; + } + + .pay-color { + color: #333; + } + } +} + +.warning-color { + color: #faad14; +} + +.danger-color { + color: #ff3000; +} + +.success-color { + color: #52c41a; +} + +.info-color { + color: #999999; +} +</style> diff --git a/src/views/mall/promotion/kefu/components/message/ProductItem.vue b/src/views/mall/promotion/kefu/components/message/ProductItem.vue new file mode 100644 index 0000000..ba2a2a9 --- /dev/null +++ b/src/views/mall/promotion/kefu/components/message/ProductItem.vue @@ -0,0 +1,189 @@ +<template> + <div> + <div> + <slot name="top"></slot> + </div> + <div + :style="[{ borderRadius: radius + 'px', marginBottom: marginBottom + 'px' }]" + class="ss-order-card-warp flex items-stretch justify-between bg-white" + > + <div class="img-box mr-24px"> + <el-image + :initial-index="0" + :preview-src-list="[picUrl]" + :src="picUrl" + class="order-img" + fit="contain" + preview-teleported + /> + </div> + <div + :style="[{ width: titleWidth ? titleWidth + 'px' : '' }]" + class="box-right flex flex-col justify-between" + > + <div v-if="title" class="title-text ss-line-2">{{ title }}</div> + <div v-if="skuString" class="spec-text mt-8px mb-12px">{{ skuString }}</div> + <div class="groupon-box"> + <slot name="groupon"></slot> + </div> + <div class="flex"> + <div class="flex items-center"> + <div + v-if="price && Number(price) > 0" + :style="[{ color: priceColor }]" + class="price-text flex items-center" + > + ¥{{ fenToYuan(price) }} + </div> + <div v-if="num" class="total-text flex items-center">x {{ num }}</div> + <slot name="priceSuffix"></slot> + </div> + </div> + <div class="tool-box"> + <slot name="tool"></slot> + </div> + <div> + <slot name="rightBottom"></slot> + </div> + </div> + </div> + </div> +</template> + +<script lang="ts" setup> +import { fenToYuan } from '@/utils' + +defineOptions({ name: 'ProductItem' }) +const props = defineProps({ + picUrl: { + type: String, + default: 'https://img1.baidu.com/it/u=1601695551,235775011&fm=26&fmt=auto' + }, + title: { + type: String, + default: '' + }, + titleWidth: { + type: Number, + default: 0 + }, + skuText: { + type: [String, Array], + default: '' + }, + price: { + type: [String, Number], + default: '' + }, + priceColor: { + type: [String], + default: '' + }, + num: { + type: [String, Number], + default: 0 + }, + score: { + type: [String, Number], + default: '' + }, + radius: { + type: [String], + default: '' + }, + marginBottom: { + type: [String], + default: '' + } +}) + +/** SKU 展示字符串 */ +const skuString = computed(() => { + if (!props.skuText) { + return '' + } + if (typeof props.skuText === 'object') { + return props.skuText.join(',') + } + return props.skuText +}) +</script> + +<style lang="scss" scoped> +.ss-order-card-warp { + padding: 20px; + border-radius: 10px; + background-color: #e2e2e2; + + .img-box { + width: 80px; + height: 80px; + border-radius: 10px; + overflow: hidden; + + .order-img { + width: 80px; + height: 80px; + } + } + + .box-right { + flex: 1; + position: relative; + + .tool-box { + position: absolute; + right: 0px; + bottom: -10px; + } + } + + .title-text { + font-size: 16px; + font-weight: 500; + line-height: 20px; + } + + .spec-text { + font-size: 16px; + font-weight: 400; + color: #999999; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + } + + .price-text { + font-size: 16px; + font-weight: 500; + font-family: OPPOSANS; + } + + .total-text { + font-size: 16px; + font-weight: 400; + line-height: 16px; + color: #999999; + margin-left: 8px; + } +} + +.ss-line { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + + &-1 { + -webkit-line-clamp: 1; + } + + &-2 { + -webkit-line-clamp: 2; + } +} +</style> diff --git a/src/views/mall/promotion/kefu/components/tools/EmojiSelectPopover.vue b/src/views/mall/promotion/kefu/components/tools/EmojiSelectPopover.vue new file mode 100644 index 0000000..43c096d --- /dev/null +++ b/src/views/mall/promotion/kefu/components/tools/EmojiSelectPopover.vue @@ -0,0 +1,42 @@ +<!-- emoji 表情选择组件 --> +<template> + <el-popover :width="500" placement="top" trigger="click"> + <template #reference> + <Icon :size="30" class="ml-10px cursor-pointer" icon="twemoji:grinning-face" /> + </template> + <ElScrollbar height="300px"> + <ul class="ml-2 flex flex-wrap px-2"> + <li + v-for="(item, index) in emojiList" + :key="index" + :style="{ + borderColor: 'var(--el-color-primary)', + color: 'var(--el-color-primary)' + }" + :title="item.name" + class="icon-item mr-2 mt-1 w-1/10 flex cursor-pointer items-center justify-center border border-solid p-2" + @click="handleSelect(item)" + > + <img :src="item.url" class="w-24px h-24px" /> + </li> + </ul> + </ElScrollbar> + </el-popover> +</template> + +<script lang="ts" setup> +defineOptions({ name: 'EmojiSelectPopover' }) +import { Emoji, useEmoji } from './emoji' + +const { getEmojiList } = useEmoji() +const emojiList = computed(() => getEmojiList()) + +/** 选择 emoji 表情 */ +const emits = defineEmits<{ + (e: 'select-emoji', v: Emoji) +}>() +const handleSelect = (item: Emoji) => { + // 整个 emoji 数据传递出去,方便以后输入框直接显示表情 + emits('select-emoji', item) +} +</script> diff --git a/src/views/mall/promotion/kefu/components/tools/PictureSelectUpload.vue b/src/views/mall/promotion/kefu/components/tools/PictureSelectUpload.vue new file mode 100644 index 0000000..9742353 --- /dev/null +++ b/src/views/mall/promotion/kefu/components/tools/PictureSelectUpload.vue @@ -0,0 +1,93 @@ +<!-- 图片选择 --> +<template> + <div> + <img :src="Picture" class="w-35px h-35px" @click="selectAndUpload" /> + </div> +</template> + +<script lang="ts" setup> +import Picture from '@/views/mall/promotion/kefu/components/asserts/picture.svg' +import * as FileApi from '@/api/infra/file' + +defineOptions({ name: 'PictureSelectUpload' }) + +const message = useMessage() // 消息弹窗 + +/** 选择并上传文件 */ +const emits = defineEmits<{ + (e: 'send-picture', v: string): void +}>() +const selectAndUpload = async () => { + const files: any = await getFiles() + message.success('图片发送中请稍等。。。') + const res = await FileApi.updateFile({ file: files[0].file }) + emits('send-picture', res.data) +} + +/** + * 唤起文件选择窗口,并获取选择的文件 + * + * @param {Object} options - 配置选项 + * @param {boolean} [options.multiple=true] - 是否支持多选 + * @param {string} [options.accept=''] - 文件上传格式限制 + * @param {number} [options.limit=1] - 单次上传最大文件数 + * @param {number} [options.fileSize=500] - 单个文件大小限制(单位:MB) + * @returns {Promise<Array>} 选择的文件列表,每个文件带有一个uid + */ +async function getFiles(options = {}) { + const { multiple, accept, limit, fileSize } = { + multiple: true, + accept: 'image/jpeg, image/png, image/gif', // 默认选择图片 + limit: 1, + fileSize: 500, + ...options + } + + // 创建文件选择元素 + const input = document.createElement('input') + input.type = 'file' + input.style.display = 'none' + if (multiple) input.multiple = true + if (accept) input.accept = accept + + // 将文件选择元素添加到文档中 + document.body.appendChild(input) + + // 触发文件选择元素的点击事件 + input.click() + + // 等待文件选择元素的 change 事件 + try { + return await new Promise((resolve, reject) => { + input.addEventListener('change', (event: any) => { + const filesArray = Array.from(event?.target?.files || []) + + // 从文档中移除文件选择元素 + document.body.removeChild(input) + + // 判断是否超出上传数量限制 + if (filesArray.length > limit) { + reject({ errorType: 'limit', files: filesArray }) + return + } + + // 判断是否超出上传文件大小限制 + const overSizedFiles = filesArray.filter((file: File) => file.size / 1024 ** 2 > fileSize) + if (overSizedFiles.length > 0) { + reject({ errorType: 'fileSize', files: overSizedFiles }) + return + } + + // 生成文件列表,并添加 uid + const fileList = filesArray.map((file, index) => ({ file, uid: Date.now() + index })) + resolve(fileList) + }) + }) + } catch (error) { + console.error('选择文件出错:', error) + throw error + } +} +</script> + +<style lang="scss" scoped></style> diff --git a/src/views/mall/promotion/kefu/components/tools/constants.ts b/src/views/mall/promotion/kefu/components/tools/constants.ts new file mode 100644 index 0000000..750e7f5 --- /dev/null +++ b/src/views/mall/promotion/kefu/components/tools/constants.ts @@ -0,0 +1,17 @@ +// 客服消息类型枚举类 +export const KeFuMessageContentTypeEnum = { + TEXT: 1, // 文本消息 + IMAGE: 2, // 图片消息 + VOICE: 3, // 语音消息 + VIDEO: 4, // 视频消息 + SYSTEM: 5, // 系统消息 + // ========== 商城特殊消息 ========== + PRODUCT: 10, // 商品消息 + ORDER: 11 // 订单消息" +} + +// Promotion 的 WebSocket 消息类型枚举类 +export const WebSocketMessageTypeConstants = { + KEFU_MESSAGE_TYPE: 'kefu_message_type', // 客服消息类型 + KEFU_MESSAGE_ADMIN_READ: 'kefu_message_read_status_change' // 客服消息管理员已读 +} diff --git a/src/views/mall/promotion/kefu/components/tools/emoji.ts b/src/views/mall/promotion/kefu/components/tools/emoji.ts new file mode 100644 index 0000000..7e3f19b --- /dev/null +++ b/src/views/mall/promotion/kefu/components/tools/emoji.ts @@ -0,0 +1,126 @@ +import { isEmpty } from '@/utils/is' + +const emojiList = [ + { name: '[笑掉牙]', file: 'xiaodiaoya.png' }, + { name: '[可爱]', file: 'keai.png' }, + { name: '[冷酷]', file: 'lengku.png' }, + { name: '[闭嘴]', file: 'bizui.png' }, + { name: '[生气]', file: 'shengqi.png' }, + { name: '[惊恐]', file: 'jingkong.png' }, + { name: '[瞌睡]', file: 'keshui.png' }, + { name: '[大笑]', file: 'daxiao.png' }, + { name: '[爱心]', file: 'aixin.png' }, + { name: '[坏笑]', file: 'huaixiao.png' }, + { name: '[飞吻]', file: 'feiwen.png' }, + { name: '[疑问]', file: 'yiwen.png' }, + { name: '[开心]', file: 'kaixin.png' }, + { name: '[发呆]', file: 'fadai.png' }, + { name: '[流泪]', file: 'liulei.png' }, + { name: '[汗颜]', file: 'hanyan.png' }, + { name: '[惊悚]', file: 'jingshu.png' }, + { name: '[困~]', file: 'kun.png' }, + { name: '[心碎]', file: 'xinsui.png' }, + { name: '[天使]', file: 'tianshi.png' }, + { name: '[晕]', file: 'yun.png' }, + { name: '[啊]', file: 'a.png' }, + { name: '[愤怒]', file: 'fennu.png' }, + { name: '[睡着]', file: 'shuizhuo.png' }, + { name: '[面无表情]', file: 'mianwubiaoqing.png' }, + { name: '[难过]', file: 'nanguo.png' }, + { name: '[犯困]', file: 'fankun.png' }, + { name: '[好吃]', file: 'haochi.png' }, + { name: '[呕吐]', file: 'outu.png' }, + { name: '[龇牙]', file: 'ziya.png' }, + { name: '[懵比]', file: 'mengbi.png' }, + { name: '[白眼]', file: 'baiyan.png' }, + { name: '[饿死]', file: 'esi.png' }, + { name: '[凶]', file: 'xiong.png' }, + { name: '[感冒]', file: 'ganmao.png' }, + { name: '[流汗]', file: 'liuhan.png' }, + { name: '[笑哭]', file: 'xiaoku.png' }, + { name: '[流口水]', file: 'liukoushui.png' }, + { name: '[尴尬]', file: 'ganga.png' }, + { name: '[惊讶]', file: 'jingya.png' }, + { name: '[大惊]', file: 'dajing.png' }, + { name: '[不好意思]', file: 'buhaoyisi.png' }, + { name: '[大闹]', file: 'danao.png' }, + { name: '[不可思议]', file: 'bukesiyi.png' }, + { name: '[爱你]', file: 'aini.png' }, + { name: '[红心]', file: 'hongxin.png' }, + { name: '[点赞]', file: 'dianzan.png' }, + { name: '[恶魔]', file: 'emo.png' } +] + +export interface Emoji { + name: string + url: string +} + +export const useEmoji = () => { + const emojiPathList = ref<any[]>([]) + + /** 加载本地图片 */ + const initStaticEmoji = async () => { + const pathList = import.meta.glob( + '@/views/mall/promotion/kefu/components/asserts/*.{png,jpg,jpeg,svg}' + ) + for (const path in pathList) { + const imageModule: any = await pathList[path]() + emojiPathList.value.push(imageModule.default) + } + } + + /** 初始化 */ + onMounted(async () => { + if (isEmpty(emojiPathList.value)) { + await initStaticEmoji() + } + }) + + /** + * 将文本中的表情替换成图片 + * + * @param data 文本 + * @return 替换后的文本 + */ + const replaceEmoji = (content: string) => { + let newData = content + if (typeof newData !== 'object') { + const reg = /\[(.+?)]/g // [] 中括号 + const zhEmojiName = newData.match(reg) + if (zhEmojiName) { + zhEmojiName.forEach((item) => { + const emojiFile = getEmojiFileByName(item) + newData = newData.replace( + item, + `<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${emojiFile}"/>` + ) + }) + } + } + return newData + } + + /** + * 获得所有表情 + * + * @return 表情列表 + */ + function getEmojiList(): Emoji[] { + return emojiList.map((item) => ({ + url: getEmojiFileByName(item.name), + name: item.name + })) as Emoji[] + } + + function getEmojiFileByName(name: string) { + for (const emoji of emojiList) { + if (emoji.name === name) { + return emojiPathList.value.find((item: string) => item.indexOf(emoji.file) > -1) + } + } + return false + } + + return { replaceEmoji, getEmojiList } +} diff --git a/src/views/mall/promotion/kefu/index.vue b/src/views/mall/promotion/kefu/index.vue new file mode 100644 index 0000000..e6b4976 --- /dev/null +++ b/src/views/mall/promotion/kefu/index.vue @@ -0,0 +1,137 @@ +<template> + <el-row :gutter="10"> + <!-- 会话列表 --> + <el-col :span="6"> + <ContentWrap> + <KeFuConversationList ref="keFuConversationRef" @change="handleChange" /> + </ContentWrap> + </el-col> + <!-- 会话详情(选中会话的消息列表) --> + <el-col :span="12"> + <ContentWrap> + <KeFuMessageList ref="keFuChatBoxRef" @change="getConversationList" /> + </ContentWrap> + </el-col> + <!-- 会员足迹(选中会话的会员足迹) --> + <el-col :span="6"> + <ContentWrap> + <MemberBrowsingHistory ref="memberBrowsingHistoryRef" /> + </ContentWrap> + </el-col> + </el-row> +</template> + +<script lang="ts" setup> +import { KeFuConversationList, KeFuMessageList, MemberBrowsingHistory } from './components' +import { WebSocketMessageTypeConstants } from './components/tools/constants' +import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation' +import { getAccessToken } from '@/utils/auth' +import { useWebSocket } from '@vueuse/core' + +defineOptions({ name: 'KeFu' }) + +const message = useMessage() // 消息弹窗 + +// ======================= WebSocket start ======================= +const server = ref( + (import.meta.env.VITE_BASE_URL + '/infra/ws').replace('http', 'ws') + '?token=' + getAccessToken() +) // WebSocket 服务地址 + +/** 发起 WebSocket 连接 */ +const { data, close, open } = useWebSocket(server.value, { + autoReconnect: true, + heartbeat: true +}) + +/** 监听 WebSocket 数据 */ +watchEffect(() => { + if (!data.value) { + return + } + try { + // 1. 收到心跳 + if (data.value === 'pong') { + return + } + + // 2.1 解析 type 消息类型 + const jsonMessage = JSON.parse(data.value) + const type = jsonMessage.type + if (!type) { + message.error('未知的消息类型:' + data.value) + return + } + // 2.2 消息类型:KEFU_MESSAGE_TYPE + if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) { + // 刷新会话列表 + // TODO @puhui999:不应该刷新列表,而是根据消息,本地 update 列表的数据; + getConversationList() + // 刷新消息列表 + keFuChatBoxRef.value?.refreshMessageList(JSON.parse(jsonMessage.content)) + return + } + // 2.3 消息类型:KEFU_MESSAGE_ADMIN_READ + if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_ADMIN_READ) { + // 刷新会话列表 + // TODO @puhui999:不应该刷新列表,而是根据消息,本地 update 列表的数据; + getConversationList() + } + } catch (error) { + console.error(error) + } +}) +// ======================= WebSocket end ======================= +/** 加载会话列表 */ +const keFuConversationRef = ref<InstanceType<typeof KeFuConversationList>>() +const getConversationList = () => { + keFuConversationRef.value?.getConversationList() +} + +/** 加载指定会话的消息列表 */ +const keFuChatBoxRef = ref<InstanceType<typeof KeFuMessageList>>() +const memberBrowsingHistoryRef = ref<InstanceType<typeof MemberBrowsingHistory>>() +const handleChange = (conversation: KeFuConversationRespVO) => { + keFuChatBoxRef.value?.getNewMessageList(conversation) + memberBrowsingHistoryRef.value?.initHistory(conversation) +} + +/** 初始化 */ +onMounted(() => { + getConversationList() + // 打开 websocket 连接 + open() +}) + +/** 销毁 */ +onBeforeUnmount(() => { + // 关闭 websocket 连接 + close() +}) +</script> + +<style lang="scss"> +.kefu { + height: calc(100vh - 165px); + overflow: auto; /* 确保内容可滚动 */ +} + +/* 定义滚动条样式 */ +::-webkit-scrollbar { + width: 10px; + height: 6px; +} + +/* 定义滚动条轨道 内阴影+圆角 */ +::-webkit-scrollbar-track { + box-shadow: inset 0 0 0 rgba(240, 240, 240, 0.5); + border-radius: 10px; + background-color: #fff; +} + +/* 定义滑块 内阴影+圆角 */ +::-webkit-scrollbar-thumb { + border-radius: 10px; + box-shadow: inset 0 0 0 rgba(240, 240, 240, 0.5); + background-color: rgba(240, 240, 240, 0.5); +} +</style> diff --git a/src/views/mall/promotion/rewardActivity/RewardForm.vue b/src/views/mall/promotion/rewardActivity/RewardForm.vue new file mode 100644 index 0000000..9fb69a5 --- /dev/null +++ b/src/views/mall/promotion/rewardActivity/RewardForm.vue @@ -0,0 +1,325 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="80px" + v-loading="formLoading" + > + <el-form-item label="活动名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入活动名称" /> + </el-form-item> + <el-form-item label="活动时间" prop="startAndEndTime"> + <el-date-picker + v-model="formData.startAndEndTime" + type="datetimerange" + range-separator="-" + :start-placeholder="t('common.startTimeText')" + :end-placeholder="t('common.endTimeText')" + /> + </el-form-item> + <el-form-item label="条件类型" prop="conditionType"> + <el-radio-group v-model="formData.conditionType"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_CONDITION_TYPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="优惠设置"> + <template v-for="(item, index) in formData.rules" :key="index"> + <el-row type="flex"> + <el-col :span="24" style="font-weight: bold; display: flex"> + 活动层级{{ index + 1 }} + <el-button + link + type="danger" + style="margin-left: auto" + v-if="index != 0" + @click="deleteActivityRule(index)" + > + 删除 + </el-button> + </el-col> + <e-form :ref="'formRef' + index" :model="item"> + <el-form-item + label="优惠门槛:" + prop="limit" + label-width="100px" + style="padding-left: 50px" + > + 满 + <el-input + style="width: 150px; padding: 0 10px" + v-model="item.limit" + type="number" + placeholder="" + /> + 元 + </el-form-item> + <el-form-item label="优惠内容:" label-width="100px" style="padding-left: 50px"> + <el-checkbox-group v-model="activityRules[index]" style="width: 100%"> + <el-col :span="24"> + <el-checkbox label="订单金额优惠" name="type" /> + <el-form-item v-if="activityRules[index].includes('订单金额优惠')"> + 减 + <el-input + style="width: 150px; padding: 0 20px" + v-model="item.discountPrice" + type="number" + placeholder="" + /> + 元 + </el-form-item> + </el-col> + <el-col :span="24"> + <el-checkbox v-model="item.freeDelivery" label="包邮" name="type" /> + </el-col> + <el-col :span="24"> + <el-checkbox label="送积分" name="type" /> + <el-form-item v-if="activityRules[index].includes('送积分')"> + 送 + <el-input + style="width: 150px; padding: 0 20px" + v-model="item.point" + type="number" + placeholder="" + /> + 积分 + </el-form-item> + </el-col> + <!-- 优惠券待处理 也可以参考优惠劵的SpuShowcase--> + <!-- TODO 待实现!--> + <el-col :span="24"> + <el-checkbox label="送优惠券" name="type" /> + </el-col> + </el-checkbox-group> + </el-form-item> + </e-form> + </el-row> + </template> + <!-- TODO 实现:建议改成放在每一个【活动层级】的下面,有点类似主子表 --> + <el-button type="primary" @click="addActivityStratum">添加活动层级</el-button> + </el-form-item> + <el-form-item label="活动商品" prop="productScope"> + <el-radio-group v-model="formData.productScope"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <!-- TODO:活动商品的开发,可以参考优惠劵的,已经搞好啦; --> + <el-form-item + v-if="formData.productScope === PromotionProductScopeEnum.SPU.scope" + prop="productSpuIds" + > + <el-select + v-model="formData.productSpuIds" + placeholder="请选择活动商品" + clearable + size="small" + multiple + filterable + style="width: 400px" + > + <el-option v-for="item in productSpus" :key="item.id" :label="item.name" :value="item.id"> + <span style="float: left">{{ item.name }}</span> + <span style="float: right; font-size: 13px; color: #8492a6"> + ¥{{ (item.price / 100.0).toFixed(2) }} + </span> + </el-option> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { getSpuSimpleList } from '@/api/mall/product/spu' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as RewardActivityApi from '@/api/mall/promotion/reward/rewardActivity' +import { PromotionConditionTypeEnum, PromotionProductScopeEnum } from '@/utils/constants' + +/** 初始化 **/ +onMounted(() => { + getSpuSimpleList().then((response) => { + productSpus.value = response + }) +}) +defineOptions({ name: 'ProductBrandForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const productSpus = ref<any[]>([]) // 商品数据 +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + startAndEndTime: undefined, + startTime: undefined, + endTime: undefined, + conditionType: PromotionConditionTypeEnum.PRICE.type, + remark: undefined, + productScope: PromotionProductScopeEnum.ALL.scope, + productSpuIds: undefined, + rules: [ + { + limit: undefined, + discountPrice: undefined, + freeDelivery: undefined, + point: undefined, + couponIds: [], + couponCounts: [] + } + ] +}) +const activityRules = reactive([]) // 优惠设置。每个元素都是一个 [],放“包邮”、“送积分”、“订单金额优惠” +const formRules = reactive({ + name: [{ required: true, message: '活动名称不能为空', trigger: 'blur' }], + startAndEndTime: [{ required: true, message: '活动时间不能为空', trigger: 'blur' }], + conditionType: [{ required: true, message: '条件类型不能为空', trigger: 'change' }], + productScope: [{ required: true, message: '商品范围不能为空', trigger: 'blur' }], + productSpuIds: [{ required: true, message: '商品范围不能为空', trigger: 'blur' }] +}) +const formRef = ref([]) // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + let data = await RewardActivityApi.getReward(id) + data.startAndEndTime = [new Date(data.startTime), new Date(data.endTime)] + activityRules.splice(0, activityRules.length) + data.rules.forEach((item) => { + // TODO 是不是不用 reactive,直接 [] 就可以了? + let array: string[] = reactive([]) + if (item.freeDelivery) { + array.push('包邮') + } + if (item.point) { + array.push('送积分') + } + if (item.discountPrice) { + array.push('订单金额优惠') + } + activityRules.push(array) + }) + formData.value = data + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 处理下数据兼容接口 + formData.value.startTime = +new Date(formData.value.startAndEndTime[0]) + formData.value.endTime = +new Date(formData.value.startAndEndTime[1]) + activityRules.forEach((item, index) => { + formData.value.rules[index].freeDelivery = !!item.includes('包邮') + if (!item.includes('送积分')) { + formData.value.rules[index].point = undefined + } + if (!item.includes('订单金额优惠')) { + formData.value.rules[index].discountPrice = undefined + } + }) + + // 提交请求 + formLoading.value = true + try { + const data = formData.value as RewardActivityApi.DiscountActivityVO + if (formType.value === 'create') { + await RewardActivityApi.createRewardActivity(data) + message.success(t('common.createSuccess')) + } else { + await RewardActivityApi.updateRewardActivity(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +const addActivityStratum = () => { + formData.value.rules.push({ + limit: undefined, + discountPrice: undefined, + freeDelivery: undefined, + point: undefined, + couponIds: [], + couponCounts: [] + }) + activityRules.push([]) +} + +const deleteActivityRule = (index) => { + formData.value.rules.splice(index, 1) + activityRules.splice(index, 1) +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + startAndEndTime: undefined, + startTime: undefined, + endTime: undefined, + conditionType: PromotionConditionTypeEnum.PRICE.type, + remark: undefined, + productScope: PromotionProductScopeEnum.ALL.scope, + productSpuIds: undefined, + rules: [ + { + limit: undefined, + discountPrice: undefined, + freeDelivery: undefined, + point: undefined, + couponIds: [], + couponCounts: [] + } + ] + } + activityRules.splice(0, activityRules.length) + activityRules.push(reactive([])) + // 解决下有时刷新页面第一次点编辑报错 + nextTick(() => { + formRef.value?.resetFields() + }) +} +</script> diff --git a/src/views/mall/promotion/rewardActivity/index.vue b/src/views/mall/promotion/rewardActivity/index.vue new file mode 100644 index 0000000..4f6f8a6 --- /dev/null +++ b/src/views/mall/promotion/rewardActivity/index.vue @@ -0,0 +1,193 @@ +<template> + <doc-alert title="【营销】满减送" url="https://doc.iocoder.cn/mall/promotion-record/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="活动名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入活动名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="活动状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择活动状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_ACTIVITY_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="活动时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="活动开始日期" + end-placeholder="活动结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['product:brand:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" row-key="id" default-expand-all> + <el-table-column label="活动名称" prop="name" /> + <el-table-column + label="活动开始时间" + align="center" + prop="startTime" + :formatter="dateFormatter" + /> + <el-table-column + label="活动结束时间" + align="center" + prop="endTime" + :formatter="dateFormatter" + /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_ACTIVITY_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['product:brand:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['product:brand:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <RewardForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as RewardActivityApi from '@/api/mall/promotion/reward/rewardActivity' +import RewardForm from './RewardForm.vue' + +defineOptions({ name: 'PromotionRewardActivity' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref<any[]>([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await RewardActivityApi.getRewardActivityPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await RewardActivityApi.deleteRewardActivity(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue b/src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue new file mode 100644 index 0000000..486b71d --- /dev/null +++ b/src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue @@ -0,0 +1,196 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%"> + <Form + ref="formRef" + v-loading="formLoading" + :isCol="true" + :rules="rules" + :schema="allSchemas.formSchema" + > + <!-- 先选择 --> + <template #spuId> + <el-button @click="spuSelectRef.open()">选择商品</el-button> + <SpuAndSkuList + ref="spuAndSkuListRef" + :rule-config="ruleConfig" + :spu-list="spuList" + :spu-property-list-p="spuPropertyList" + > + <el-table-column align="center" label="秒杀库存" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number v-model="sku.productConfig.stock" :min="0" class="w-100%" /> + </template> + </el-table-column> + <el-table-column align="center" label="秒杀价格(元)" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number + v-model="sku.productConfig.seckillPrice" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + /> + </template> + </el-table-column> + </SpuAndSkuList> + </template> + </Form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <SpuSelect ref="spuSelectRef" :isSelectSku="true" @confirm="selectSpu" /> +</template> +<script lang="ts" setup> +import { SpuAndSkuList, SpuProperty, SpuSelect } from '../../components' +import { allSchemas, rules } from './seckillActivity.data' +import { cloneDeep } from 'lodash-es' + +import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity' +import { SeckillProductVO } from '@/api/mall/promotion/seckill/seckillActivity' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components' +import { convertToInteger, formatToFraction } from '@/utils' + +defineOptions({ name: 'PromotionSeckillActivityForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formRef = ref() // 表单 Ref + +// ================= 商品选择相关 ================= + +const spuSelectRef = ref() // 商品和属性选择 Ref +const spuAndSkuListRef = ref() // sku 秒杀配置组件Ref +const ruleConfig: RuleConfig[] = [ + { + name: 'productConfig.stock', + rule: (arg) => arg >= 1, + message: '商品秒杀库存必须大于等于 1 !!!' + }, + { + name: 'productConfig.seckillPrice', + rule: (arg) => arg >= 0.01, + message: '商品秒杀价格必须大于等于 0.01 !!!' + } +] +const spuList = ref<SeckillActivityApi.SpuExtension[]>([]) // 选择的 spu +const spuPropertyList = ref<SpuProperty<SeckillActivityApi.SpuExtension>[]>([]) +const selectSpu = (spuId: number, skuIds: number[]) => { + formRef.value.setValues({ spuId }) + getSpuDetails(spuId, skuIds) +} +/** + * 获取 SPU 详情 + */ +const getSpuDetails = async ( + spuId: number, + skuIds: number[] | undefined, + products?: SeckillProductVO[] +) => { + const spuProperties: SpuProperty<SeckillActivityApi.SpuExtension>[] = [] + const res = (await ProductSpuApi.getSpuDetailList([spuId])) as SeckillActivityApi.SpuExtension[] + if (res.length == 0) { + return + } + spuList.value = [] + // 因为只能选择一个 + const spu = res[0] + const selectSkus = + typeof skuIds === 'undefined' ? spu?.skus : spu?.skus?.filter((sku) => skuIds.includes(sku.id!)) + selectSkus?.forEach((sku) => { + let config: SeckillActivityApi.SeckillProductVO = { + skuId: sku.id!, + stock: 0, + seckillPrice: 0 + } + if (typeof products !== 'undefined') { + const product = products.find((item) => item.skuId === sku.id) + if (product) { + product.seckillPrice = formatToFraction(product.seckillPrice) + } + config = product || config + } + sku.productConfig = config + }) + spu.skus = selectSkus as SeckillActivityApi.SkuExtension[] + spuProperties.push({ + spuId: spu.id!, + spuDetail: spu, + propertyList: getPropertyList(spu) + }) + spuList.value.push(spu) + spuPropertyList.value = spuProperties +} + +// ================= end ================= + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + await resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = (await SeckillActivityApi.getSeckillActivity( + id + )) as SeckillActivityApi.SeckillActivityVO + await getSpuDetails(data.spuId!, data.products?.map((sku) => sku.skuId), data.products) + formRef.value.setValues(data) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.getElFormRef().validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + // 获取秒杀商品配置 + const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig')) + products.forEach((item: SeckillProductVO) => { + item.seckillPrice = convertToInteger(item.seckillPrice) + }) + const data = formRef.value.formModel as SeckillActivityApi.SeckillActivityVO + data.products = products + // 真正提交 + if (formType.value === 'create') { + await SeckillActivityApi.createSeckillActivity(data) + message.success(t('common.createSuccess')) + } else { + await SeckillActivityApi.updateSeckillActivity(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = async () => { + spuList.value = [] + spuPropertyList.value = [] + await nextTick() + formRef.value.getElFormRef().resetFields() +} +</script> diff --git a/src/views/mall/promotion/seckill/activity/index.vue b/src/views/mall/promotion/seckill/activity/index.vue new file mode 100644 index 0000000..bffe265 --- /dev/null +++ b/src/views/mall/promotion/seckill/activity/index.vue @@ -0,0 +1,256 @@ +<template> + <doc-alert title="【营销】秒杀活动" url="https://doc.iocoder.cn/mall/promotion-seckill/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="活动名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入活动名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="活动状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择活动状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:seckill-activity:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="活动编号" prop="id" min-width="80" /> + <el-table-column label="活动名称" prop="name" min-width="140" /> + <el-table-column + label="秒杀时段" + prop="configIds" + width="220px" + :show-overflow-tooltip="false" + > + <template #default="scope"> + <el-tag v-for="(configId, index) in scope.row.configIds" :key="index" class="mr-5px"> + {{ formatConfigNames(configId) }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="活动时间" min-width="210"> + <template #default="scope"> + {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }} + ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }} + </template> + </el-table-column> + <el-table-column label="商品图片" prop="spuName" min-width="80"> + <template #default="scope"> + <el-image + :src="scope.row.picUrl" + class="h-40px w-40px" + :preview-src-list="[scope.row.picUrl]" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="商品标题" prop="spuName" min-width="300" /> + <el-table-column + label="原价" + prop="marketPrice" + min-width="100" + :formatter="fenToYuanFormat" + /> + <el-table-column label="原价" prop="marketPrice" min-width="100" /> + <el-table-column label="秒杀价" prop="seckillPrice" min-width="100"> + <template #default="scope"> + {{ formatSeckillPrice(scope.row.products) }} + </template> + </el-table-column> + <el-table-column label="活动状态" align="center" prop="status" min-width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="库存" align="center" prop="stock" min-width="80" /> + <el-table-column label="总库存" align="center" prop="totalStock" min-width="80" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="150px" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['promotion:seckill-activity:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleClose(scope.row.id)" + v-if="scope.row.status === 0" + v-hasPermi="['promotion:seckill-activity:close']" + > + 关闭 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-else + v-hasPermi="['promotion:seckill-activity:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SeckillActivityForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity' +import { SeckillConfigApi } from '@/api/mall/promotion/seckill/seckillConfig' +import SeckillActivityForm from './SeckillActivityForm.vue' +import { formatDate } from '@/utils/formatTime' +import { fenToYuanFormat } from '@/utils/formatter' +import { fenToYuan } from '@/utils' + +defineOptions({ name: 'SeckillActivity' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + status: null +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SeckillActivityApi.getSeckillActivityPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 关闭按钮操作 */ +const handleClose = async (id: number) => { + try { + // 关闭的二次确认 + await message.confirm('确认关闭该秒杀活动吗?') + // 发起关闭 + await SeckillActivityApi.closeSeckillActivity(id) + message.success('关闭成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await SeckillActivityApi.deleteSeckillActivity(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +const configList = ref([]) // 时段配置精简列表 +const formatConfigNames = (configId) => { + const config = configList.value.find((item) => item.id === configId) + return config != null ? `${config.name}[${config.startTime} ~ ${config.endTime}]` : '' +} + +const formatSeckillPrice = (products) => { + const seckillPrice = Math.min(...products.map((item) => item.seckillPrice)) + return `¥${fenToYuan(seckillPrice)}` +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 获得秒杀时间段 + configList.value = await SeckillConfigApi.getSimpleSeckillConfigList() +}) +</script> diff --git a/src/views/mall/promotion/seckill/activity/seckillActivity.data.ts b/src/views/mall/promotion/seckill/activity/seckillActivity.data.ts new file mode 100644 index 0000000..b6e6422 --- /dev/null +++ b/src/views/mall/promotion/seckill/activity/seckillActivity.data.ts @@ -0,0 +1,163 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter2 } from '@/utils/formatTime' +import { SeckillConfigApi } from '@/api/mall/promotion/seckill/seckillConfig' + +// 表单校验 +export const rules = reactive({ + spuId: [required], + name: [required], + startTime: [required], + endTime: [required], + sort: [required], + configIds: [required], + totalLimitCount: [required], + singleLimitCount: [required], + totalStock: [required] +}) + +// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '秒杀活动名称', + field: 'name', + isSearch: true, + form: { + colProps: { + span: 24 + } + }, + table: { + width: 120 + } + }, + { + label: '活动开始时间', + field: 'startTime', + formatter: dateFormatter2, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD', + type: 'daterange' + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 120 + } + }, + { + label: '活动结束时间', + field: 'endTime', + formatter: dateFormatter2, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD', + type: 'daterange' + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 120 + } + }, + { + label: '秒杀时段', + field: 'configIds', + form: { + component: 'Select', + componentProps: { + multiple: true, + optionsAlias: { + labelField: 'name', + valueField: 'id' + } + }, + api: SeckillConfigApi.getSimpleSeckillConfigList + }, + table: { + width: 300 + } + }, + { + label: '总限购数量', + field: 'totalLimitCount', + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 120 + } + }, + { + label: '单次限够数量', + field: 'singleLimitCount', + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 120 + } + }, + { + label: '排序', + field: 'sort', + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 80 + } + }, + { + label: '秒杀活动商品', + field: 'spuId', + isTable: true, + isSearch: false, + form: { + colProps: { + span: 24 + } + }, + table: { + width: 300 + } + }, + { + label: '备注', + field: 'remark', + isSearch: false, + form: { + component: 'Input', + componentProps: { + type: 'textarea', + rows: 4 + }, + colProps: { + span: 24 + } + }, + table: { + width: 300 + } + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) diff --git a/src/views/mall/promotion/seckill/config/SeckillConfigForm.vue b/src/views/mall/promotion/seckill/config/SeckillConfigForm.vue new file mode 100644 index 0000000..a7ce5fe --- /dev/null +++ b/src/views/mall/promotion/seckill/config/SeckillConfigForm.vue @@ -0,0 +1,133 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label="秒杀时段名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入秒杀时段名称" /> + </el-form-item> + <el-form-item label="开始时间点" prop="startTime"> + <el-time-picker + v-model="formData.startTime" + value-format="HH:mm:ss" + placeholder="选择开始时间点" + /> + </el-form-item> + <el-form-item label="结束时间点" prop="endTime"> + <el-time-picker + v-model="formData.endTime" + value-format="HH:mm:ss" + placeholder="选择结束时间点" + /> + </el-form-item> + <el-form-item label="秒杀轮播图" prop="sliderPicUrls"> + <UploadImgs v-model="formData.sliderPicUrls" placeholder="请输入秒杀轮播图" /> + </el-form-item> + <el-form-item label="活动状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { SeckillConfigApi, SeckillConfigVO } from '@/api/mall/promotion/seckill/seckillConfig.ts' +import { CommonStatusEnum } from '@/utils/constants' + +/** 秒杀时段 表单 */ +defineOptions({ name: 'SeckillConfigForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + startTime: undefined, + endTime: undefined, + sliderPicUrls: undefined, + status: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '秒杀时段名称不能为空', trigger: 'blur' }], + startTime: [{ required: true, message: '开始时间点不能为空', trigger: 'blur' }], + endTime: [{ required: true, message: '结束时间点不能为空', trigger: 'blur' }], + status: [{ required: true, message: '活动状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await SeckillConfigApi.getSeckillConfig(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as SeckillConfigVO + if (formType.value === 'create') { + await SeckillConfigApi.createSeckillConfig(data) + message.success(t('common.createSuccess')) + } else { + await SeckillConfigApi.updateSeckillConfig(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + startTime: undefined, + endTime: undefined, + sliderPicUrls: [], + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/promotion/seckill/config/index.vue b/src/views/mall/promotion/seckill/config/index.vue new file mode 100644 index 0000000..9fa2c1e --- /dev/null +++ b/src/views/mall/promotion/seckill/config/index.vue @@ -0,0 +1,211 @@ +<template> + <doc-alert title="【营销】秒杀活动" url="https://doc.iocoder.cn/mall/promotion-seckill/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="108px" + > + <el-form-item label="秒杀时段名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入秒杀时段名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="活动状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择活动状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:seckill-config:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="秒杀时段名称" align="center" prop="name" /> + <el-table-column label="开始时间点" align="center" prop="startTime" /> + <el-table-column label="结束时间点" align="center" prop="endTime" /> + <el-table-column label="秒杀轮播图" align="center" prop="sliderPicUrls"> + <template #default="scope"> + <el-image + class="h-40px max-w-40px" + v-for="(url, index) in scope?.row.sliderPicUrls" + :key="index" + :src="url" + :preview-src-list="scope?.row.sliderPicUrls" + :initial-index="index" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="活动状态" align="center" prop="status"> + <template #default="scope"> + <el-switch + v-model="scope.row.status" + :active-value="0" + :inactive-value="1" + @change="handleStatusChange(scope.row)" + /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['promotion:seckill-config:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['promotion:seckill-config:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SeckillConfigForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { SeckillConfigApi, SeckillConfigVO } from '@/api/mall/promotion/seckill/seckillConfig.ts' +import SeckillConfigForm from './SeckillConfigForm.vue' +import { CommonStatusEnum } from '@/utils/constants' + +/** 秒杀时段 列表 */ +defineOptions({ name: 'SeckillConfig' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<SeckillConfigVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SeckillConfigApi.getSeckillConfigPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await SeckillConfigApi.deleteSeckillConfig(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 修改用户状态 */ +const handleStatusChange = async (row: SeckillConfigVO) => { + try { + // 修改状态的二次确认 + const text = row.status === CommonStatusEnum.ENABLE ? '启用' : '停用' + await message.confirm('确认要' + text + '"' + row.name + '"活动吗?') + // 发起修改状态 + await SeckillConfigApi.updateSeckillConfigStatus(row.id, row.status) + // 刷新列表 + await getList() + } catch { + // 取消后,进行恢复按钮 + row.status = + row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/statistics/member/components/MemberFunnelCard.vue b/src/views/mall/statistics/member/components/MemberFunnelCard.vue new file mode 100644 index 0000000..609c679 --- /dev/null +++ b/src/views/mall/statistics/member/components/MemberFunnelCard.vue @@ -0,0 +1,121 @@ +<template> + <el-card shadow="never"> + <template #header> + <div class="my--1.5 flex flex-row items-center justify-between"> + <CardTitle title="会员概览" /> + <!-- 查询条件 --> + <ShortcutDateRangePicker @change="handleTimeRangeChange" /> + </div> + </template> + <div class="min-w-225 py-1.75" v-loading="loading"> + <div class="relative h-24 flex"> + <div class="h-full w-75% bg-blue-50 <lg:w-35% <xl:w-55%"> + <div class="ml-15 h-full flex flex-col justify-center"> + <div class="font-bold"> + 注册用户数量:{{ analyseData?.comparison?.value?.registerUserCount || 0 }} + </div> + <div class="mt-2 text-3.5"> + 环比增长率:{{ + calculateRelativeRate( + analyseData?.comparison?.value?.registerUserCount, + analyseData?.comparison?.reference?.registerUserCount + ) + }}% + </div> + </div> + </div> + <div + class="trapezoid1 ml--38.5 mt-1.5 h-full w-77 flex flex-col items-center justify-center bg-blue-5 text-3.5 text-white" + > + <span class="text-6 font-bold">{{ analyseData?.visitUserCount || 0 }}</span> + <span>访客</span> + </div> + </div> + <div class="relative h-24 flex"> + <div class="h-full w-75% flex bg-cyan-50 <lg:w-35% <xl:w-55%"> + <div class="ml-15 h-full flex flex-col justify-center"> + <div class="font-bold"> + 活跃用户数量:{{ analyseData?.comparison?.value?.visitUserCount || 0 }} + </div> + <div class="mt-2 text-3.5"> + 环比增长率:{{ + calculateRelativeRate( + analyseData?.comparison?.value?.visitUserCount, + analyseData?.comparison?.reference?.visitUserCount + ) + }}% + </div> + </div> + </div> + <div + class="trapezoid2 ml--28 mt-1.7 h-25 w-56 flex flex-col items-center justify-center bg-cyan-5 text-3.5 text-white" + > + <span class="text-6 font-bold">{{ analyseData?.orderUserCount || 0 }}</span> + <span>下单</span> + </div> + </div> + <div class="relative h-24 flex"> + <div class="w-75% flex bg-slate-50 <lg:w-35% <xl:w-55%"> + <div class="ml-15 h-full flex flex-row gap-x-16"> + <div class="flex flex-col justify-center"> + <div class="font-bold"> + 充值用户数量:{{ analyseData?.comparison?.value?.rechargeUserCount || 0 }} + </div> + <div class="mt-2 text-3.5"> + 环比增长率:{{ + calculateRelativeRate( + analyseData?.comparison?.value?.rechargeUserCount, + analyseData?.comparison?.reference?.rechargeUserCount + ) + }}% + </div> + </div> + <div class="flex flex-col justify-center"> + <div class="font-bold">客单价:{{ fenToYuan(analyseData?.atv || 0) }}</div> + </div> + </div> + </div> + <div + class="trapezoid3 ml--18 mt-3.25 h-23 w-36 flex flex-col items-center justify-center bg-slate-5 text-3.5 text-white" + > + <span class="text-6 font-bold">{{ analyseData?.payUserCount || 0 }}</span> + <span>成交用户</span> + </div> + </div> + </div> + </el-card> +</template> +<script lang="ts" setup> +import * as MemberStatisticsApi from '@/api/mall/statistics/member' +import dayjs from 'dayjs' +import { calculateRelativeRate, fenToYuan } from '@/utils' +import { MemberAnalyseRespVO } from '@/api/mall/statistics/member' +import { CardTitle } from '@/components/Card' + +/** 会员概览卡片 */ +defineOptions({ name: 'MemberFunnelCard' }) + +const loading = ref(true) // 加载中 +const analyseData = ref<MemberAnalyseRespVO>() // 会员分析数据 + +/** 查询会员概览数据列表 */ +const handleTimeRangeChange = async (times: [dayjs.ConfigType, dayjs.ConfigType]) => { + loading.value = true + // 查询数据 + analyseData.value = await MemberStatisticsApi.getMemberAnalyse({ times }) + loading.value = false +} +</script> +<style lang="scss" scoped> +.trapezoid1 { + transform: perspective(5em) rotateX(-11deg); +} + +.trapezoid2 { + transform: perspective(7em) rotateX(-20deg); +} + +.trapezoid3 { + transform: perspective(3em) rotateX(-13deg); +} +</style> diff --git a/src/views/mall/statistics/member/components/MemberTerminalCard.vue b/src/views/mall/statistics/member/components/MemberTerminalCard.vue new file mode 100644 index 0000000..7bbab76 --- /dev/null +++ b/src/views/mall/statistics/member/components/MemberTerminalCard.vue @@ -0,0 +1,69 @@ +<template> + <el-card shadow="never" v-loading="loading"> + <template #header> + <CardTitle title="会员终端" /> + </template> + <Echart :height="300" :options="terminalChartOptions" /> + </el-card> +</template> +<script lang="ts" setup> +import * as MemberStatisticsApi from '@/api/mall/statistics/member' +import { EChartsOption } from 'echarts' +import { MemberTerminalStatisticsRespVO } from '@/api/mall/statistics/member' +import { DICT_TYPE, DictDataType, getIntDictOptions } from '@/utils/dict' +import { CardTitle } from '@/components/Card' + +/** 会员终端卡片 */ +defineOptions({ name: 'MemberTerminalCard' }) + +const loading = ref(true) // 加载中 + +/** 会员终端统计图配置 */ +const terminalChartOptions = reactive<EChartsOption>({ + tooltip: { + trigger: 'item', + confine: true, + formatter: '{a} <br/>{b} : {c} ({d}%)' + }, + legend: { + orient: 'vertical', + left: 'right' + }, + roseType: 'area', + series: [ + { + name: '会员终端', + type: 'pie', + label: { + show: false + }, + labelLine: { + show: false + }, + data: [] + } + ] +}) as EChartsOption + +/** 按照终端,查询会员统计列表 */ +const getMemberTerminalStatisticsList = async () => { + loading.value = true + const list = await MemberStatisticsApi.getMemberTerminalStatisticsList() + const dictDataList = getIntDictOptions(DICT_TYPE.TERMINAL) + terminalChartOptions.series![0].data = dictDataList.map((dictData: DictDataType) => { + const userCount = list.find( + (item: MemberTerminalStatisticsRespVO) => item.terminal === dictData.value + )?.userCount + return { + name: dictData.label, + value: userCount || 0 + } + }) + loading.value = false +} + +/** 初始化 **/ +onMounted(() => { + getMemberTerminalStatisticsList() +}) +</script> diff --git a/src/views/mall/statistics/member/index.vue b/src/views/mall/statistics/member/index.vue new file mode 100644 index 0000000..0e1bbaf --- /dev/null +++ b/src/views/mall/statistics/member/index.vue @@ -0,0 +1,313 @@ +<template> + <doc-alert title="【统计】会员、商品、交易统计" url="https://doc.iocoder.cn/mall/statistics/" /> + + <div class="flex flex-col"> + <el-row :gutter="16" class="summary"> + <el-col v-loading="loading" :sm="6" :xs="12"> + <SummaryCard + :value="summary?.userCount || 0" + icon="fa-solid:users" + icon-bg-color="text-blue-500" + icon-color="bg-blue-100" + title="累计会员数" + /> + </el-col> + <el-col v-loading="loading" :sm="6" :xs="12"> + <SummaryCard + :value="summary?.rechargeUserCount || 0" + icon="fa-solid:user" + icon-bg-color="text-purple-500" + icon-color="bg-purple-100" + title="累计充值人数" + /> + </el-col> + <el-col v-loading="loading" :sm="6" :xs="12"> + <SummaryCard + :decimals="2" + :value="fenToYuan(summary?.rechargePrice || 0)" + icon="fa-solid:money-check-alt" + icon-bg-color="text-yellow-500" + icon-color="bg-yellow-100" + prefix="¥" + title="累计充值金额" + /> + </el-col> + <el-col v-loading="loading" :sm="6" :xs="12"> + <SummaryCard + :decimals="2" + :value="fenToYuan(summary?.expensePrice || 0)" + icon="fa-solid:yen-sign" + icon-bg-color="text-green-500" + icon-color="bg-green-100" + prefix="¥" + title="累计消费金额" + /> + </el-col> + </el-row> + <el-row :gutter="16" class="mb-4"> + <el-col :md="18" :sm="24"> + <!-- 会员概览 --> + <MemberFunnelCard /> + </el-col> + <el-col :md="6" :sm="24"> + <!-- 会员终端 --> + <MemberTerminalCard /> + </el-col> + </el-row> + <el-row :gutter="16"> + <el-col :md="18" :sm="24"> + <el-card shadow="never"> + <template #header> + <CardTitle title="会员地域分布" /> + </template> + <el-row v-loading="loading"> + <el-col :span="10"> + <Echart :height="300" :options="areaChartOptions" /> + </el-col> + <el-col :span="14"> + <el-table :data="areaStatisticsList" :height="300"> + <el-table-column + :sort-method="(obj1, obj2) => obj1.areaName.localeCompare(obj2.areaName, 'zh-CN')" + align="center" + label="省份" + min-width="80" + prop="areaName" + show-overflow-tooltip + sortable + /> + <el-table-column + align="center" + label="会员数量" + min-width="105" + prop="userCount" + sortable + /> + <el-table-column + align="center" + label="订单创建数量" + min-width="135" + prop="orderCreateUserCount" + sortable + /> + <el-table-column + align="center" + label="订单支付数量" + min-width="135" + prop="orderPayUserCount" + sortable + /> + <el-table-column + :formatter="fenToYuanFormat" + align="center" + label="订单支付金额" + min-width="135" + prop="orderPayPrice" + sortable + /> + </el-table> + </el-col> + </el-row> + </el-card> + </el-col> + <el-col :md="6" :sm="24"> + <el-card v-loading="loading" shadow="never"> + <template #header> + <CardTitle title="会员性别比例" /> + </template> + <Echart :height="300" :options="sexChartOptions" /> + </el-card> + </el-col> + </el-row> + </div> +</template> +<script lang="ts" setup> +import * as MemberStatisticsApi from '@/api/mall/statistics/member' +import { + MemberAreaStatisticsRespVO, + MemberSexStatisticsRespVO, + MemberSummaryRespVO, + MemberTerminalStatisticsRespVO +} from '@/api/mall/statistics/member' +import SummaryCard from '@/components/SummaryCard/index.vue' +import { EChartsOption } from 'echarts' +import china from '@/assets/map/json/china.json' +import { areaReplace, fenToYuan } from '@/utils' +import { DICT_TYPE, DictDataType, getIntDictOptions } from '@/utils/dict' +import echarts from '@/plugins/echarts' +import { fenToYuanFormat } from '@/utils/formatter' +import MemberFunnelCard from './components/MemberFunnelCard.vue' +import MemberTerminalCard from './components/MemberTerminalCard.vue' +import { CardTitle } from '@/components/Card' + +/** 会员统计 */ +defineOptions({ name: 'MemberStatistics' }) + +const loading = ref(true) // 加载中 +const summary = ref<MemberSummaryRespVO>() // 会员统计数据 +const areaStatisticsList = shallowRef<MemberAreaStatisticsRespVO[]>() // 省份会员统计 + +// 注册地图 +echarts?.registerMap('china', china as any) + +/** 会员终端统计图配置 */ +const terminalChartOptions = reactive<EChartsOption>({ + tooltip: { + trigger: 'item', + confine: true, + formatter: '{a} <br/>{b} : {c} ({d}%)' + }, + legend: { + orient: 'vertical', + left: 'right' + }, + roseType: 'area', + series: [ + { + name: '会员终端', + type: 'pie', + label: { + show: false + }, + labelLine: { + show: false + }, + data: [] + } + ] +}) as EChartsOption + +/** 会员性别统计图配置 */ +const sexChartOptions = reactive<EChartsOption>({ + tooltip: { + trigger: 'item', + confine: true, + formatter: '{a} <br/>{b} : {c} ({d}%)' + }, + legend: { + orient: 'vertical', + left: 'right' + }, + roseType: 'area', + series: [ + { + name: '会员性别', + type: 'pie', + label: { + show: false + }, + labelLine: { + show: false + }, + data: [] + } + ] +}) as EChartsOption + +const areaChartOptions = reactive<EChartsOption>({ + tooltip: { + trigger: 'item', + formatter: (params: any) => { + return `${params?.data?.areaName || params?.name}<br/> +会员数量:${params?.data?.userCount || 0}<br/> +订单创建数量:${params?.data?.orderCreateUserCount || 0}<br/> +订单支付数量:${params?.data?.orderPayUserCount || 0}<br/> +订单支付金额:${fenToYuan(params?.data?.orderPayPrice || 0)}` + } + }, + visualMap: { + text: ['高', '低'], + realtime: false, + calculable: true, + top: 'middle', + inRange: { + color: ['#fff', '#3b82f6'] + } + }, + series: [ + { + name: '会员地域分布', + type: 'map', + map: 'china', + roam: false, + selectedMode: false, + data: [] + } + ] +}) as EChartsOption + +/** 查询会员统计 */ +const getMemberSummary = async () => { + summary.value = await MemberStatisticsApi.getMemberSummary() +} + +/** 按照省份,查询会员统计列表 */ +const getMemberAreaStatisticsList = async () => { + const list = await MemberStatisticsApi.getMemberAreaStatisticsList() + areaStatisticsList.value = list.map((item: MemberAreaStatisticsRespVO) => { + return { + ...item, + areaName: areaReplace(item.areaName) + } + }) + let min = 0 + let max = 0 + areaChartOptions.series![0].data = areaStatisticsList.value.map((item) => { + min = Math.min(min, item.orderPayUserCount || 0) + max = Math.max(max, item.orderPayUserCount || 0) + return { ...item, name: item.areaName, value: item.orderPayUserCount || 0 } + }) + areaChartOptions.visualMap!['min'] = min + areaChartOptions.visualMap!['max'] = max +} + +/** 按照性别,查询会员统计列表 */ +const getMemberSexStatisticsList = async () => { + const list = await MemberStatisticsApi.getMemberSexStatisticsList() + const dictDataList = getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX) + dictDataList.push({ label: '未知', value: null } as any) + sexChartOptions.series![0].data = dictDataList.map((dictData: DictDataType) => { + const userCount = list.find( + (item: MemberSexStatisticsRespVO) => item.sex === dictData.value + )?.userCount + return { + name: dictData.label, + value: userCount || 0 + } + }) +} + +/** 按照终端,查询会员统计列表 */ +const getMemberTerminalStatisticsList = async () => { + const list = await MemberStatisticsApi.getMemberTerminalStatisticsList() + const dictDataList = getIntDictOptions(DICT_TYPE.TERMINAL) + dictDataList.push({ label: '未知', value: null } as any) + terminalChartOptions.series![0].data = dictDataList.map((dictData: DictDataType) => { + const userCount = list.find( + (item: MemberTerminalStatisticsRespVO) => item.terminal === dictData.value + )?.userCount + return { + name: dictData.label, + value: userCount || 0 + } + }) +} + +/** 初始化 **/ +onMounted(async () => { + loading.value = true + await Promise.all([ + getMemberSummary(), + getMemberTerminalStatisticsList(), + getMemberAreaStatisticsList(), + getMemberSexStatisticsList() + ]) + loading.value = false +}) +</script> +<style lang="scss" scoped> +.summary { + .el-col { + margin-bottom: 1rem; + } +} +</style> diff --git a/src/views/mall/statistics/product/components/ProductRank.vue b/src/views/mall/statistics/product/components/ProductRank.vue new file mode 100644 index 0000000..cb513bc --- /dev/null +++ b/src/views/mall/statistics/product/components/ProductRank.vue @@ -0,0 +1,101 @@ +<template> + <el-card shadow="never"> + <template #header> + <!-- 标题 --> + <div class="flex flex-row items-center justify-between"> + <CardTitle title="商品排行" /> + <!-- 查询条件 --> + <ShortcutDateRangePicker ref="shortcutDateRangePicker" @change="handleDateRangeChange" /> + </div> + </template> + <!-- 排行列表 --> + <el-table v-loading="loading" :data="list" @sort-change="handleSortChange"> + <el-table-column label="商品ID" prop="spuId" min-width="70" /> + <el-table-column label="商品图片" align="center" prop="picUrl" width="80"> + <template #default="{ row }"> + <el-image + :src="row.picUrl" + :preview-src-list="[row.picUrl]" + class="h-30px w-30px" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="商品名称" prop="name" min-width="200" :show-overflow-tooltip="true" /> + <el-table-column label="浏览量" prop="browseCount" min-width="90" sortable="custom" /> + <el-table-column label="访客数" prop="browseUserCount" min-width="90" sortable="custom" /> + <el-table-column label="加购件数" prop="cartCount" min-width="105" sortable="custom" /> + <el-table-column label="下单件数" prop="orderCount" min-width="105" sortable="custom" /> + <el-table-column label="支付件数" prop="orderPayCount" min-width="105" sortable="custom" /> + <el-table-column label="支付金额" prop="orderPayPrice" min-width="105" sortable="custom" /> + <el-table-column label="收藏数" prop="favoriteCount" min-width="90" sortable="custom" /> + <el-table-column + label="访客-支付转化率(%)" + prop="browseConvertPercent" + min-width="180" + sortable="custom" + :formatter="formatConvertRate" + /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getSpuList" + /> + </el-card> +</template> +<script lang="ts" setup> +import { ProductStatisticsApi, ProductStatisticsVO } from '@/api/mall/statistics/product' +import { CardTitle } from '@/components/Card' +import { buildSortingField } from '@/utils' + +/** 商品排行 */ +defineOptions({ name: 'ProductRank' }) + +// 格式化:访客-支付转化率 +const formatConvertRate = (row: ProductStatisticsVO) => { + return `${row.browseConvertPercent}%` +} + +const handleSortChange = (params: any) => { + queryParams.sortingFields = [buildSortingField(params)] + getSpuList() +} + +const handleDateRangeChange = (times: any[]) => { + queryParams.times = times as [] + getSpuList() +} + +const shortcutDateRangePicker = ref() +// 查询参数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + times: [], + sortingFields: {} +}) +const loading = ref(false) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref<ProductStatisticsVO[]>([]) // 列表的数据 + +/** 查询商品列表 */ +const getSpuList = async () => { + loading.value = true + try { + const data = await ProductStatisticsApi.getProductStatisticsRankPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 初始化 **/ +onMounted(async () => { + await getSpuList() +}) +</script> +<style lang="scss" scoped></style> diff --git a/src/views/mall/statistics/product/components/ProductSummary.vue b/src/views/mall/statistics/product/components/ProductSummary.vue new file mode 100644 index 0000000..0669223 --- /dev/null +++ b/src/views/mall/statistics/product/components/ProductSummary.vue @@ -0,0 +1,304 @@ +<template> + <el-card shadow="never"> + <template #header> + <!-- 标题 --> + <div class="flex flex-row items-center justify-between"> + <CardTitle title="商品概况" /> + <!-- 查询条件 --> + <ShortcutDateRangePicker ref="shortcutDateRangePicker" @change="getProductTrendData"> + <el-button + class="ml-4" + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['statistics:product:export']" + > + <Icon icon="ep:download" class="mr-1" />导出 + </el-button> + </ShortcutDateRangePicker> + </div> + </template> + <!-- 统计值 --> + <el-row :gutter="16"> + <el-col :xl="4" :md="8" :sm="24"> + <SummaryCard + title="商品浏览量" + tooltip="在选定条件下,所有商品详情页被访问的次数,一个人在统计时间内访问多次记为多次" + icon="ep:view" + icon-color="bg-blue-100" + icon-bg-color="text-blue-500" + prefix="" + :decimals="0" + :value="trendSummary?.value?.browseCount || 0" + :percent=" + calculateRelativeRate( + trendSummary?.value?.browseCount, + trendSummary?.reference?.browseCount + ) + " + /> + </el-col> + <el-col :xl="4" :md="8" :sm="24"> + <SummaryCard + title="商品访客数" + tooltip="在选定条件下,访问任何商品详情页的人数,一个人在统计时间范围内访问多次只记为一个" + icon="ep:user-filled" + icon-color="bg-purple-100" + icon-bg-color="text-purple-500" + prefix="" + :decimals="0" + :value="trendSummary?.value?.browseUserCount || 0" + :percent=" + calculateRelativeRate( + trendSummary?.value?.browseUserCount, + trendSummary?.reference?.browseUserCount + ) + " + /> + </el-col> + <el-col :xl="4" :md="8" :sm="24"> + <SummaryCard + title="支付件数" + tooltip="在选定条件下,成功付款订单的商品件数之和" + icon="fa-solid:money-check-alt" + icon-color="bg-yellow-100" + icon-bg-color="text-yellow-500" + prefix="" + :decimals="0" + :value="trendSummary?.value?.orderPayCount || 0" + :percent=" + calculateRelativeRate( + trendSummary?.value?.orderPayCount, + trendSummary?.reference?.orderPayCount + ) + " + /> + </el-col> + <el-col :xl="4" :md="8" :sm="24"> + <SummaryCard + title="支付金额" + tooltip="在选定条件下,成功付款订单的商品金额之和" + icon="ep:warning-filled" + icon-color="bg-green-100" + icon-bg-color="text-green-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(trendSummary?.value?.orderPayPrice || 0)" + :percent=" + calculateRelativeRate( + trendSummary?.value?.orderPayPrice, + trendSummary?.reference?.orderPayPrice + ) + " + /> + </el-col> + <el-col :xl="4" :md="8" :sm="24"> + <SummaryCard + title="退款件数" + tooltip="在选定条件下,成功退款的商品件数之和" + icon="fa-solid:wallet" + icon-color="bg-cyan-100" + icon-bg-color="text-cyan-500" + prefix="" + :decimals="0" + :value="trendSummary?.value?.afterSaleCount || 0" + :percent=" + calculateRelativeRate( + trendSummary?.value?.afterSaleCount, + trendSummary?.reference?.afterSaleCount + ) + " + /> + </el-col> + <el-col :xl="4" :md="8" :sm="24"> + <SummaryCard + title="退款金额" + tooltip="在选定条件下,成功退款的商品金额之和" + icon="fa-solid:award" + icon-color="bg-yellow-100" + icon-bg-color="text-yellow-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(trendSummary?.value?.afterSaleRefundPrice || 0)" + :percent=" + calculateRelativeRate( + trendSummary?.value?.afterSaleRefundPrice, + trendSummary?.reference?.afterSaleRefundPrice + ) + " + /> + </el-col> + </el-row> + <!-- 折线图 --> + <el-skeleton :loading="trendLoading" animated> + <Echart :height="500" :options="lineChartOptions" /> + </el-skeleton> + </el-card> +</template> +<script lang="ts" setup> +import { ProductStatisticsApi, ProductStatisticsVO } from '@/api/mall/statistics/product' +import SummaryCard from '@/components/SummaryCard/index.vue' +import { EChartsOption } from 'echarts' +import { DataComparisonRespVO } from '@/api/mall/statistics/common' +import { calculateRelativeRate, fenToYuan } from '@/utils' +import download from '@/utils/download' +import { CardTitle } from '@/components/Card' +import * as DateUtil from '@/utils/formatTime' +import dayjs from 'dayjs' + +/** 商品概况 */ +defineOptions({ name: 'ProductSummary' }) + +const message = useMessage() // 消息弹窗 + +const trendLoading = ref(true) // 商品状态加载中 +const exportLoading = ref(false) // 导出的加载中 +const trendSummary = ref<DataComparisonRespVO<ProductStatisticsVO>>() // 商品状况统计数据 +const shortcutDateRangePicker = ref() + +/** 折线图配置 */ +const lineChartOptions = reactive<EChartsOption>({ + dataset: { + dimensions: ['time', 'browseCount', 'browseUserCount', 'orderPayPrice', 'afterSaleRefundPrice'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + top: 80, + containLabel: true + }, + legend: { + top: 50 + }, + series: [ + { name: '商品浏览量', type: 'line', smooth: true, itemStyle: { color: '#B37FEB' } }, + { name: '商品访客数', type: 'line', smooth: true, itemStyle: { color: '#FFAB2B' } }, + { name: '支付金额', type: 'bar', smooth: true, yAxisIndex: 1, itemStyle: { color: '#1890FF' } }, + { name: '退款金额', type: 'bar', smooth: true, yAxisIndex: 1, itemStyle: { color: '#00C050' } } + ], + toolbox: { + feature: { + // 数据区域缩放 + dataZoom: { + yAxisIndex: false // Y轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '商品状况' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross' + }, + padding: [5, 10] + }, + xAxis: { + type: 'category', + boundaryGap: true, + axisTick: { + show: false + } + }, + yAxis: [ + { + type: 'value', + name: '金额', + axisLine: { + show: false + }, + axisTick: { + show: false + }, + axisLabel: { + textStyle: { + color: '#7F8B9C' + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#F5F7F9' + } + } + }, + { + type: 'value', + name: '数量', + axisLine: { + show: false + }, + axisTick: { + show: false + }, + axisLabel: { + textStyle: { + color: '#7F8B9C' + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#F5F7F9' + } + } + } + ] +}) as EChartsOption + +/** 处理商品状况查询 */ +const getProductTrendData = async () => { + trendLoading.value = true + // 1. 处理时间: 开始与截止在同一天的, 折线图出不来, 需要延长一天 + const times = shortcutDateRangePicker.value.times + if (DateUtil.isSameDay(times[0], times[1])) { + // 前天 + times[0] = DateUtil.formatDate(dayjs(times[0]).subtract(1, 'd')) + } + // 查询数据 + await Promise.all([getProductTrendSummary(), getProductStatisticsList()]) + trendLoading.value = false +} + +/** 查询商品状况数据统计 */ +const getProductTrendSummary = async () => { + const times = shortcutDateRangePicker.value.times + trendSummary.value = await ProductStatisticsApi.getProductStatisticsAnalyse({ times }) +} + +/** 查询商品状况数据列表 */ +const getProductStatisticsList = async () => { + // 查询数据 + const times = shortcutDateRangePicker.value.times + const list: ProductStatisticsVO[] = await ProductStatisticsApi.getProductStatisticsList({ times }) + // 处理数据 + for (let item of list) { + item.orderPayPrice = fenToYuan(item.orderPayPrice) + item.afterSaleRefundPrice = fenToYuan(item.afterSaleRefundPrice) + } + // 更新 Echarts 数据 + if (lineChartOptions.dataset && lineChartOptions.dataset['source']) { + lineChartOptions.dataset['source'] = list + } +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const times = shortcutDateRangePicker.value.times + const data = await ProductStatisticsApi.exportProductStatisticsExcel({ times }) + download.excel(data, '商品状况.xls') + } catch { + } finally { + exportLoading.value = false + } +} +</script> +<style lang="scss" scoped></style> diff --git a/src/views/mall/statistics/product/index.vue b/src/views/mall/statistics/product/index.vue new file mode 100644 index 0000000..d1bcba6 --- /dev/null +++ b/src/views/mall/statistics/product/index.vue @@ -0,0 +1,16 @@ +<template> + <doc-alert title="【统计】会员、商品、交易统计" url="https://doc.iocoder.cn/mall/statistics/" /> + + <!-- 商品概览 --> + <ProductSummary /> + <!-- 商品排行 --> + <ProductRank class="mt-16px" /> +</template> +<script lang="ts" setup> +import ProductSummary from './components/ProductSummary.vue' +import ProductRank from './components/ProductRank.vue' + +/** 商品统计 */ +defineOptions({ name: 'ProductStatistics' }) +</script> +<style lang="scss" scoped></style> diff --git a/src/views/mall/statistics/trade/components/TradeStatisticValue.vue b/src/views/mall/statistics/trade/components/TradeStatisticValue.vue new file mode 100644 index 0000000..77b8822 --- /dev/null +++ b/src/views/mall/statistics/trade/components/TradeStatisticValue.vue @@ -0,0 +1,36 @@ +<template> + <div class="flex flex-col gap-2 bg-[var(--el-bg-color-overlay)] p-6"> + <div class="flex items-center justify-between text-gray-500"> + <span>{{ title }}</span> + <el-tooltip :content="tooltip" placement="top-start" v-if="tooltip"> + <Icon icon="ep:warning" /> + </el-tooltip> + </div> + <div class="mb-4 text-3xl"> + <CountTo :prefix="prefix" :end-val="value" :decimals="decimals" /> + </div> + <div class="flex flex-row gap-1 text-sm"> + <span class="text-gray-500">环比</span> + <span :class="toNumber(percent) > 0 ? 'text-red-500' : 'text-green-500'"> + {{ Math.abs(toNumber(percent)) }}% + <Icon :icon="toNumber(percent) > 0 ? 'ep:caret-top' : 'ep:caret-bottom'" class="!text-sm" /> + </span> + </div> + </div> +</template> +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { toNumber } from 'lodash-es' + +/** 交易统计值组件 */ +defineOptions({ name: 'TradeStatisticValue' }) + +defineProps({ + tooltip: propTypes.string.def(''), + title: propTypes.string.def(''), + prefix: propTypes.string.def(''), + value: propTypes.number.def(0), + decimals: propTypes.number.def(0), + percent: propTypes.oneOfType([Number, String]).def(0) +}) +</script> diff --git a/src/views/mall/statistics/trade/index.vue b/src/views/mall/statistics/trade/index.vue new file mode 100644 index 0000000..0a25fd7 --- /dev/null +++ b/src/views/mall/statistics/trade/index.vue @@ -0,0 +1,363 @@ +<template> + <doc-alert title="【统计】会员、商品、交易统计" url="https://doc.iocoder.cn/mall/statistics/" /> + + <div class="flex flex-col"> + <el-row :gutter="16" class="summary"> + <el-col :sm="6" :xs="12"> + <TradeStatisticValue + tooltip="昨日订单数量" + title="昨日订单数量" + :value="summary?.value?.yesterdayOrderCount || 0" + :percent=" + calculateRelativeRate( + summary?.value?.yesterdayOrderCount, + summary?.reference?.yesterdayOrderCount + ) + " + /> + </el-col> + <el-col :sm="6" :xs="12"> + <TradeStatisticValue + tooltip="本月订单数量" + title="本月订单数量" + :value="summary?.value?.monthOrderCount || 0" + :percent=" + calculateRelativeRate( + summary?.value?.monthOrderCount, + summary?.reference?.monthOrderCount + ) + " + /> + </el-col> + <el-col :sm="6" :xs="12"> + <TradeStatisticValue + tooltip="昨日支付金额" + title="昨日支付金额" + prefix="¥" + :decimals="2" + :value="fenToYuan(summary?.value?.yesterdayPayPrice || 0)" + :percent=" + calculateRelativeRate( + summary?.value?.yesterdayPayPrice, + summary?.reference?.yesterdayPayPrice + ) + " + /> + </el-col> + <el-col :sm="6" :xs="12"> + <TradeStatisticValue + tooltip="本月支付金额" + title="本月支付金额" + prefix="¥" + ::decimals="2" + :value="fenToYuan(summary?.value?.monthPayPrice || 0)" + :percent=" + calculateRelativeRate(summary?.value?.monthPayPrice, summary?.reference?.monthPayPrice) + " + /> + </el-col> + </el-row> + <el-card shadow="never"> + <template #header> + <!-- 标题 --> + <div class="flex flex-row items-center justify-between"> + <CardTitle title="交易状况" /> + <!-- 查询条件 --> + <ShortcutDateRangePicker ref="shortcutDateRangePicker" @change="getTradeTrendData"> + <el-button + class="ml-4" + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['statistics:trade:export']" + > + <Icon icon="ep:download" class="mr-1" />导出 + </el-button> + </ShortcutDateRangePicker> + </div> + </template> + <!-- 统计值 --> + <el-row :gutter="16"> + <el-col :md="6" :sm="12" :xs="24"> + <SummaryCard + title="营业额" + tooltip="商品支付金额、充值金额" + icon="fa-solid:yen-sign" + icon-color="bg-blue-100" + icon-bg-color="text-blue-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(trendSummary?.value?.turnoverPrice || 0)" + :percent=" + calculateRelativeRate( + trendSummary?.value?.turnoverPrice, + trendSummary?.reference?.turnoverPrice + ) + " + /> + </el-col> + <el-col :md="6" :sm="12" :xs="24"> + <SummaryCard + title="商品支付金额" + tooltip="用户购买商品的实际支付金额,包括微信支付、余额支付、支付宝支付、线下支付金额(拼团商品在成团之后计入,线下支付订单在后台确认支付后计入)" + icon="fa-solid:shopping-cart" + icon-color="bg-purple-100" + icon-bg-color="text-purple-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(trendSummary?.value?.orderPayPrice || 0)" + :percent=" + calculateRelativeRate( + trendSummary?.value?.orderPayPrice, + trendSummary?.reference?.orderPayPrice + ) + " + /> + </el-col> + <el-col :md="6" :sm="12" :xs="24"> + <SummaryCard + title="充值金额" + tooltip="用户成功充值的金额" + icon="fa-solid:money-check-alt" + icon-color="bg-yellow-100" + icon-bg-color="text-yellow-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(trendSummary?.value?.rechargePrice || 0)" + :percent=" + calculateRelativeRate( + trendSummary?.value?.rechargePrice, + trendSummary?.reference?.rechargePrice + ) + " + /> + </el-col> + <el-col :md="6" :sm="12" :xs="24"> + <SummaryCard + title="支出金额" + tooltip="余额支付金额、支付佣金金额、商品退款金额" + icon="ep:warning-filled" + icon-color="bg-green-100" + icon-bg-color="text-green-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(trendSummary?.value?.expensePrice || 0)" + :percent=" + calculateRelativeRate( + trendSummary?.value?.expensePrice, + trendSummary?.reference?.expensePrice + ) + " + /> + </el-col> + <el-col :md="6" :sm="12" :xs="24"> + <SummaryCard + title="余额支付金额" + tooltip="用户下单时使用余额实际支付的金额" + icon="fa-solid:wallet" + icon-color="bg-cyan-100" + icon-bg-color="text-cyan-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(trendSummary?.value?.walletPayPrice || 0)" + :percent=" + calculateRelativeRate( + trendSummary?.value?.walletPayPrice, + trendSummary?.reference?.walletPayPrice + ) + " + /> + </el-col> + <el-col :md="6" :sm="12" :xs="24"> + <SummaryCard + title="支付佣金金额" + tooltip="后台给推广员支付的推广佣金,以实际支付为准" + icon="fa-solid:award" + icon-color="bg-yellow-100" + icon-bg-color="text-yellow-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(trendSummary?.value?.brokerageSettlementPrice || 0)" + :percent=" + calculateRelativeRate( + trendSummary?.value?.brokerageSettlementPrice, + trendSummary?.reference?.brokerageSettlementPrice + ) + " + /> + </el-col> + <el-col :md="6" :sm="12" :xs="24"> + <SummaryCard + title="商品退款金额" + tooltip="用户成功退款的商品金额" + icon="fa-solid:times-circle" + icon-color="bg-blue-100" + icon-bg-color="text-blue-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(trendSummary?.value?.afterSaleRefundPrice || 0)" + :percent=" + calculateRelativeRate( + trendSummary?.value?.afterSaleRefundPrice, + trendSummary?.reference?.afterSaleRefundPrice + ) + " + /> + </el-col> + </el-row> + <!-- 折线图 --> + <el-skeleton :loading="trendLoading" animated> + <Echart :height="500" :options="lineChartOptions" /> + </el-skeleton> + </el-card> + </div> +</template> +<script lang="ts" setup> +import * as TradeStatisticsApi from '@/api/mall/statistics/trade' +import TradeStatisticValue from './components/TradeStatisticValue.vue' +import SummaryCard from '@/components/SummaryCard/index.vue' +import { EChartsOption } from 'echarts' +import { DataComparisonRespVO } from '@/api/mall/statistics/common' +import { TradeSummaryRespVO, TradeTrendSummaryRespVO } from '@/api/mall/statistics/trade' +import { calculateRelativeRate, fenToYuan } from '@/utils' +import download from '@/utils/download' +import { CardTitle } from '@/components/Card' +import * as DateUtil from '@/utils/formatTime' +import dayjs from 'dayjs' + +/** 交易统计 */ +defineOptions({ name: 'TradeStatistics' }) + +const message = useMessage() // 消息弹窗 + +const trendLoading = ref(true) // 交易状态加载中 +const exportLoading = ref(false) // 导出的加载中 +const summary = ref<DataComparisonRespVO<TradeSummaryRespVO>>() // 交易统计数据 +const trendSummary = ref<DataComparisonRespVO<TradeTrendSummaryRespVO>>() // 交易状况统计数据 +const shortcutDateRangePicker = ref() + +/** 折线图配置 */ +const lineChartOptions = reactive<EChartsOption>({ + dataset: { + dimensions: ['date', 'turnoverPrice', 'orderPayPrice', 'rechargePrice', 'expensePrice'], + source: [] + }, + grid: { + left: 20, + right: 20, + bottom: 20, + top: 80, + containLabel: true + }, + legend: { + top: 50 + }, + series: [ + { name: '营业额', type: 'line', smooth: true }, + { name: '商品支付金额', type: 'line', smooth: true }, + { name: '充值金额', type: 'line', smooth: true }, + { name: '支出金额', type: 'line', smooth: true } + ], + toolbox: { + feature: { + // 数据区域缩放 + dataZoom: { + yAxisIndex: false // Y轴不缩放 + }, + brush: { + type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮 + }, + saveAsImage: { show: true, name: '交易状况' } // 保存为图片 + } + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross' + }, + padding: [5, 10] + }, + xAxis: { + type: 'category', + boundaryGap: false, + axisTick: { + show: false + } + }, + yAxis: { + axisTick: { + show: false + } + } +}) as EChartsOption + +/** 处理交易状况查询 */ +const getTradeTrendData = async () => { + trendLoading.value = true + // 1. 处理时间: 开始与截止在同一天的, 折线图出不来, 需要延长一天 + const times = shortcutDateRangePicker.value.times + if (DateUtil.isSameDay(times[0], times[1])) { + // 前天 + times[0] = DateUtil.formatDate(dayjs(times[0]).subtract(1, 'd')) + } + // 查询数据 + await Promise.all([getTradeStatisticsAnalyse(), getTradeStatisticsList()]) + trendLoading.value = false +} + +/** 查询交易统计 */ +const getTradeStatisticsSummary = async () => { + summary.value = await TradeStatisticsApi.getTradeStatisticsSummary() +} + +/** 查询交易状况数据统计 */ +const getTradeStatisticsAnalyse = async () => { + const times = shortcutDateRangePicker.value.times + trendSummary.value = await TradeStatisticsApi.getTradeStatisticsAnalyse({ times }) +} + +/** 查询交易状况数据列表 */ +const getTradeStatisticsList = async () => { + // 查询数据 + const times = shortcutDateRangePicker.value.times + const list = await TradeStatisticsApi.getTradeStatisticsList({ times }) + // 处理数据 + for (let item of list) { + item.turnoverPrice = fenToYuan(item.turnoverPrice) + item.orderPayPrice = fenToYuan(item.orderPayPrice) + item.rechargePrice = fenToYuan(item.rechargePrice) + item.expensePrice = fenToYuan(item.expensePrice) + } + // 更新 Echarts 数据 + if (lineChartOptions.dataset && lineChartOptions.dataset['source']) { + lineChartOptions.dataset['source'] = list + } +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const times = shortcutDateRangePicker.value.times + const data = await TradeStatisticsApi.exportTradeStatisticsExcel({ times }) + download.excel(data, '交易状况.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(async () => { + await getTradeStatisticsSummary() +}) +</script> +<style lang="scss" scoped> +.summary { + .el-col { + margin-bottom: 1rem; + } +} +</style> diff --git a/src/views/mall/trade/afterSale/detail/index.vue b/src/views/mall/trade/afterSale/detail/index.vue new file mode 100644 index 0000000..26df0d3 --- /dev/null +++ b/src/views/mall/trade/afterSale/detail/index.vue @@ -0,0 +1,354 @@ +<template> + <ContentWrap> + <!-- 订单信息 --> + <el-descriptions title="订单信息"> + <el-descriptions-item label="订单号: ">{{ formData.orderNo }}</el-descriptions-item> + <el-descriptions-item label="配送方式: "> + <dict-tag :type="DICT_TYPE.TRADE_DELIVERY_TYPE" :value="formData.order.deliveryType" /> + </el-descriptions-item> + <el-descriptions-item label="订单类型: "> + <dict-tag :type="DICT_TYPE.TRADE_ORDER_TYPE" :value="formData.order.type" /> + </el-descriptions-item> + <el-descriptions-item label="收货人: "> + {{ formData.order.receiverName }} + </el-descriptions-item> + <el-descriptions-item label="买家留言: "> + {{ formData.order.userRemark }} + </el-descriptions-item> + <el-descriptions-item label="订单来源: "> + <dict-tag :type="DICT_TYPE.TERMINAL" :value="formData.order.terminal" /> + </el-descriptions-item> + <el-descriptions-item label="联系电话: "> + {{ formData.order.receiverMobile }} + </el-descriptions-item> + <el-descriptions-item label="商家备注: ">{{ formData.order.remark }}</el-descriptions-item> + <el-descriptions-item label="支付单号: "> + {{ formData.order.payOrderId }} + </el-descriptions-item> + <el-descriptions-item label="付款方式: "> + <dict-tag :type="DICT_TYPE.PAY_CHANNEL_CODE" :value="formData.order.payChannelCode" /> + </el-descriptions-item> + <el-descriptions-item label="买家: ">{{ formData?.user?.nickname }}</el-descriptions-item> + </el-descriptions> + + <!-- 售后信息 --> + <el-descriptions title="售后信息"> + <el-descriptions-item label="退款编号: ">{{ formData.no }}</el-descriptions-item> + <el-descriptions-item label="申请时间: "> + {{ formatDate(formData.auditTime) }} + </el-descriptions-item> + <el-descriptions-item label="售后类型: "> + <dict-tag :type="DICT_TYPE.TRADE_AFTER_SALE_TYPE" :value="formData.type" /> + </el-descriptions-item> + <el-descriptions-item label="售后方式: "> + <dict-tag :type="DICT_TYPE.TRADE_AFTER_SALE_WAY" :value="formData.way" /> + </el-descriptions-item> + <el-descriptions-item label="退款金额: "> + {{ fenToYuan(formData.refundPrice) }} + </el-descriptions-item> + <el-descriptions-item label="退款原因: ">{{ formData.applyReason }}</el-descriptions-item> + <el-descriptions-item label="补充描述: "> + {{ formData.applyDescription }} + </el-descriptions-item> + <el-descriptions-item label="凭证图片: "> + <el-image + v-for="(item, index) in formData.applyPicUrls" + :key="index" + :src="item.url" + class="mr-10px h-60px w-60px" + @click="imagePreview(formData.applyPicUrls)" + /> + </el-descriptions-item> + </el-descriptions> + + <!-- 退款状态 --> + <el-descriptions :column="1" title="退款状态"> + <el-descriptions-item label="退款状态: "> + <dict-tag :type="DICT_TYPE.TRADE_AFTER_SALE_STATUS" :value="formData.status" /> + </el-descriptions-item> + <el-descriptions-item label-class-name="no-colon"> + <el-button v-if="formData.status === 10" type="primary" @click="agree">同意售后</el-button> + <el-button v-if="formData.status === 10" type="primary" @click="disagree"> + 拒绝售后 + </el-button> + <el-button v-if="formData.status === 30" type="primary" @click="receive"> + 确认收货 + </el-button> + <el-button v-if="formData.status === 30" type="primary" @click="refuse">拒绝收货</el-button> + <el-button v-if="formData.status === 40" type="primary" @click="refund">确认退款</el-button> + </el-descriptions-item> + <el-descriptions-item> + <template #label><span style="color: red">提醒: </span></template> + 如果未发货,请点击同意退款给买家。<br /> + 如果实际已发货,请主动与买家联系。<br /> + 如果订单整体退款后,优惠券和余额会退还给买家. + </el-descriptions-item> + </el-descriptions> + + <!-- 商品信息 --> + <el-descriptions title="商品信息"> + <el-descriptions-item labelClassName="no-colon"> + <el-row :gutter="20"> + <el-col :span="15"> + <el-table :data="[formData.orderItem]" border> + <el-table-column label="商品" prop="spuName" width="auto"> + <template #default="{ row }"> + {{ row.spuName }} + <el-tag v-for="property in row.properties" :key="property.propertyId"> + {{ property.propertyName }}: {{ property.valueName }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="商品原价" prop="price" width="150"> + <template #default="{ row }">{{ fenToYuan(row.price) }} 元</template> + </el-table-column> + <el-table-column label="数量" prop="count" width="100" /> + <el-table-column label="合计" prop="payPrice" width="150"> + <template #default="{ row }">{{ fenToYuan(row.payPrice) }} 元</template> + </el-table-column> + </el-table> + </el-col> + <el-col :span="10" /> + </el-row> + </el-descriptions-item> + </el-descriptions> + + <!-- 操作日志 --> + <el-descriptions title="售后日志"> + <el-descriptions-item labelClassName="no-colon"> + <el-timeline> + <el-timeline-item + v-for="saleLog in formData.logs" + :key="saleLog.id" + :timestamp="formatDate(saleLog.createTime)" + placement="top" + > + <div class="el-timeline-right-content"> + <span>{{ saleLog.content }}</span> + </div> + <template #dot> + <span + :style="{ backgroundColor: getUserTypeColor(saleLog.userType) }" + class="dot-node-style" + > + {{ getDictLabel(DICT_TYPE.USER_TYPE, saleLog.userType)[0] || '系' }} + </span> + </template> + </el-timeline-item> + </el-timeline> + </el-descriptions-item> + </el-descriptions> + </ContentWrap> + + <!-- 各种操作的弹窗 --> + <UpdateAuditReasonForm ref="updateAuditReasonFormRef" @success="getDetail" /> +</template> +<script lang="ts" setup> +import * as AfterSaleApi from '@/api/mall/trade/afterSale/index' +import { fenToYuan } from '@/utils' +import { DICT_TYPE, getDictLabel, getDictObj } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import UpdateAuditReasonForm from '@/views/mall/trade/afterSale/form/AfterSaleDisagreeForm.vue' +import { createImageViewer } from '@/components/ImageViewer' +import { isArray } from '@/utils/is' +import { useTagsViewStore } from '@/store/modules/tagsView' + +defineOptions({ name: 'TradeAfterSaleDetail' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const { params } = useRoute() // 查询参数 +const { push, currentRoute } = useRouter() // 路由 +const formData = ref({ + order: {}, + logs: [] +}) +const updateAuditReasonFormRef = ref() // 拒绝售后表单 Ref + +/** 获得 userType 颜色 */ +const getUserTypeColor = (type: number) => { + const dict = getDictObj(DICT_TYPE.USER_TYPE, type) + switch (dict?.colorType) { + case 'success': + return '#67C23A' + case 'info': + return '#909399' + case 'warning': + return '#E6A23C' + case 'danger': + return '#F56C6C' + } + return '#409EFF' +} + +/** 获得详情 */ +const getDetail = async () => { + const id = params.id as unknown as number + if (id) { + const res = await AfterSaleApi.getAfterSale(id) + // 没有表单信息则关闭页面返回 + if (res == null) { + message.notifyError('售后订单不存在') + close() + } + formData.value = res + } +} + +/** 同意售后 */ +const agree = async () => { + try { + // 二次确认 + await message.confirm('是否同意售后?') + await AfterSaleApi.agree(formData.value.id) + // 提示成功 + message.success(t('common.success')) + await getDetail() + } catch {} +} + +/** 拒绝售后 */ +const disagree = async () => { + updateAuditReasonFormRef.value?.open(formData.value) +} + +/** 确认收货 */ +const receive = async () => { + try { + // 二次确认 + await message.confirm('是否确认收货?') + await AfterSaleApi.receive(formData.value.id) + // 提示成功 + message.success(t('common.success')) + await getDetail() + } catch {} +} + +/** 拒绝收货 */ +const refuse = async () => { + try { + // 二次确认 + await message.confirm('是否拒绝收货?') + await AfterSaleApi.refuse(formData.value.id) + // 提示成功 + message.success(t('common.success')) + await getDetail() + } catch {} +} + +/** 确认退款 */ +const refund = async () => { + try { + // 二次确认 + await message.confirm('是否确认退款?') + await AfterSaleApi.refund(formData.value.id) + // 提示成功 + message.success(t('common.success')) + await getDetail() + } catch {} +} + +/** 图片预览 */ +const imagePreview = (args) => { + const urlList = [] + if (isArray(args)) { + args.forEach((item) => { + urlList.push(item.url) + }) + } else { + urlList.push(args) + } + createImageViewer({ + urlList + }) +} +const { delView } = useTagsViewStore() // 视图操作 +/** 关闭 tag */ +const close = () => { + delView(unref(currentRoute)) + push({ name: 'TradeAfterSale' }) +} +onMounted(async () => { + await getDetail() +}) +</script> +<style lang="scss" scoped> +:deep(.el-descriptions) { + &:not(:nth-child(1)) { + margin-top: 20px; + } + + .el-descriptions__title { + display: flex; + align-items: center; + + &::before { + display: inline-block; + width: 3px; + height: 20px; + margin-right: 10px; + background-color: #409eff; + content: ''; + } + } + + .el-descriptions-item__container { + margin: 0 10px; + + .no-colon { + margin: 0; + + &::after { + content: ''; + } + } + } +} + +// 时间线样式调整 +:deep(.el-timeline) { + margin: 10px 0 0 160px; + + .el-timeline-item__wrapper { + position: relative; + top: -20px; + + .el-timeline-item__timestamp { + position: absolute !important; + top: 10px; + left: -150px; + } + } + + .el-timeline-right-content { + display: flex; + align-items: center; + min-height: 30px; + padding: 10px; + background-color: #f7f8fa; + + &::before { + position: absolute; + top: 10px; + left: 13px; + border-color: transparent #f7f8fa transparent transparent; /* 尖角颜色,左侧朝向 */ + border-style: solid; + border-width: 8px; /* 调整尖角大小 */ + content: ''; + } + } + + .dot-node-style { + position: absolute; + left: -5px; + display: flex; + width: 20px; + height: 20px; + font-size: 10px; + color: #fff; + border-radius: 50%; + justify-content: center; + align-items: center; + } +} +</style> diff --git a/src/views/mall/trade/afterSale/form/AfterSaleDisagreeForm.vue b/src/views/mall/trade/afterSale/form/AfterSaleDisagreeForm.vue new file mode 100644 index 0000000..af3ab35 --- /dev/null +++ b/src/views/mall/trade/afterSale/form/AfterSaleDisagreeForm.vue @@ -0,0 +1,70 @@ +<template> + <Dialog v-model="dialogVisible" title="拒绝售后" width="45%"> + <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="80px"> + <el-form-item label="审批备注"> + <el-input + v-model="formData.auditReason" + :rows="3" + placeholder="请输入审批备注" + type="textarea" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as AfterSaleApi from '@/api/mall/trade/afterSale/index' + +defineOptions({ name: 'AfterSaleDisagreeForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, // 售后订单编号 + auditReason: '' // 审批备注 +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (row: AfterSaleApi.TradeAfterSaleVO) => { + resetForm() + // 设置数据 + formData.value.id = row.id + formData.value.auditReason = row.auditReason + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 提交请求 + formLoading.value = true + try { + const data = unref(formData) + await AfterSaleApi.disagree(data) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success', true) + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, // 售后订单编号 + auditReason: '' // 审批备注 + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/trade/afterSale/index.vue b/src/views/mall/trade/afterSale/index.vue new file mode 100644 index 0000000..52051c3 --- /dev/null +++ b/src/views/mall/trade/afterSale/index.vue @@ -0,0 +1,269 @@ +<template> + <doc-alert title="【交易】售后退款" url="https://doc.iocoder.cn/mall/trade-aftersale/" /> + + <!-- 搜索 --> + <ContentWrap> + <el-form ref="queryFormRef" :inline="true" :model="queryParams" label-width="68px"> + <el-form-item label="商品名称" prop="spuName"> + <el-input + v-model="queryParams.spuName" + class="!w-280px" + clearable + placeholder="请输入商品 SPU 名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="退款编号" prop="no"> + <el-input + v-model="queryParams.no" + class="!w-280px" + clearable + placeholder="请输入退款编号" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="订单编号" prop="orderNo"> + <el-input + v-model="queryParams.orderNo" + class="!w-280px" + clearable + placeholder="请输入订单编号" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="售后状态" prop="status"> + <el-select + v-model="queryParams.status" + class="!w-280px" + clearable + placeholder="请选择售后状态" + > + <el-option label="全部" value="0" /> + <el-option + v-for="dict in getDictOptions(DICT_TYPE.TRADE_AFTER_SALE_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="售后方式" prop="way"> + <el-select + v-model="queryParams.way" + class="!w-280px" + clearable + placeholder="请选择售后方式" + > + <el-option + v-for="dict in getDictOptions(DICT_TYPE.TRADE_AFTER_SALE_WAY)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="售后类型" prop="type"> + <el-select + v-model="queryParams.type" + class="!w-280px" + clearable + placeholder="请选择售后类型" + > + <el-option + v-for="dict in getDictOptions(DICT_TYPE.TRADE_AFTER_SALE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-260px" + end-placeholder="自定义时间" + start-placeholder="自定义时间" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-tabs v-model="queryParams.status" @tab-click="tabClick"> + <el-tab-pane + v-for="item in statusTabs" + :key="item.label" + :label="item.label" + :name="item.value" + /> + </el-tabs> + <!-- 列表 --> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="退款编号" min-width="200" prop="no" /> + <el-table-column align="center" label="订单编号" min-width="200" prop="orderNo"> + <template #default="{ row }"> + <el-button link type="primary" @click="openOrderDetail(row.orderId)"> + {{ row.orderNo }} + </el-button> + </template> + </el-table-column> + <el-table-column label="商品信息" min-width="600" prop="spuName"> + <template #default="{ row }"> + <div class="flex items-center"> + <el-image + :src="row.picUrl" + class="mr-10px h-30px w-30px" + @click="imagePreview(row.picUrl)" + /> + <span class="mr-10px">{{ row.spuName }}</span> + <el-tag v-for="property in row.properties" :key="property.propertyId" class="mr-10px"> + {{ property.propertyName }}: {{ property.valueName }} + </el-tag> + </div> + </template> + </el-table-column> + <el-table-column align="center" label="订单金额" prop="refundPrice"> + <template #default="scope"> + <span>{{ fenToYuan(scope.row.refundPrice) }} 元</span> + </template> + </el-table-column> + <el-table-column align="center" label="买家" prop="user.nickname" /> + <el-table-column align="center" label="申请时间" prop="createTime" width="180"> + <template #default="scope"> + <span>{{ formatDate(scope.row.createTime) }}</span> + </template> + </el-table-column> + <el-table-column align="center" label="售后状态" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.TRADE_AFTER_SALE_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column align="center" label="售后方式"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.TRADE_AFTER_SALE_WAY" :value="scope.row.way" /> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="160"> + <template #default="{ row }"> + <el-button link type="primary" @click="openAfterSaleDetail(row.id)">处理退款</el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as AfterSaleApi from '@/api/mall/trade/afterSale/index' +import { DICT_TYPE, getDictOptions } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import { createImageViewer } from '@/components/ImageViewer' +import { TabsPaneContext } from 'element-plus' +import { cloneDeep } from 'lodash-es' +import { fenToYuan } from '@/utils' + +defineOptions({ name: 'TradeAfterSale' }) + +const { push } = useRouter() // 路由跳转 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref<AfterSaleApi.TradeAfterSaleVO[]>([]) // 列表的数据 +const statusTabs = ref([ + { + label: '全部', + value: '0' + } +]) +const queryFormRef = ref() // 搜索的表单 +// 查询参数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: null, + status: '0', + orderNo: null, + spuName: null, + createTime: [], + way: null, + type: null +}) +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = cloneDeep(queryParams) + // 处理掉全部的状态,不传就是全部 + if (data.status === '0') { + delete data.status + } + // 执行查询 + const res = (await AfterSaleApi.getAfterSalePage(data)) as AfterSaleApi.TradeAfterSaleVO[] + list.value = res.list + total.value = res.total + } finally { + loading.value = false + } +} +/** 搜索按钮操作 */ +const handleQuery = async () => { + queryParams.pageNo = 1 + await getList() +} +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + handleQuery() +} +/** tab 切换 */ +const tabClick = async (tab: TabsPaneContext) => { + queryParams.status = tab.paneName + await getList() +} + +/** 处理退款 */ +const openAfterSaleDetail = (id: number) => { + push({ name: 'TradeAfterSaleDetail', params: { id } }) +} + +/** 查看订单详情 */ +const openOrderDetail = (id: number) => { + push({ name: 'TradeOrderDetail', params: { id } }) +} + +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} + +onMounted(async () => { + await getList() + // 设置 statuses 过滤 + for (const dict of getDictOptions(DICT_TYPE.TRADE_AFTER_SALE_STATUS)) { + statusTabs.value.push({ + label: dict.label, + value: dict.value + }) + } +}) +</script> diff --git a/src/views/mall/trade/brokerage/record/index.vue b/src/views/mall/trade/brokerage/record/index.vue new file mode 100644 index 0000000..8f138ad --- /dev/null +++ b/src/views/mall/trade/brokerage/record/index.vue @@ -0,0 +1,171 @@ +<template> + <doc-alert title="【交易】分销返佣" url="https://doc.iocoder.cn/mall/trade-brokerage/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-input + v-model="queryParams.userId" + placeholder="请输入用户编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="业务类型" prop="bizType"> + <el-select + v-model="queryParams.bizType" + placeholder="请选择业务类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_RECORD_BIZ_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_RECORD_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" min-width="60" /> + <el-table-column label="用户编号" align="center" prop="userId" min-width="80" /> + <el-table-column label="头像" align="center" prop="userAvatar" width="70px"> + <template #default="scope"> + <el-avatar :src="scope.row.userAvatar" /> + </template> + </el-table-column> + <el-table-column label="昵称" align="center" prop="userNickname" min-width="80px" /> + <el-table-column label="业务类型" align="center" prop="bizType" min-width="85"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BROKERAGE_RECORD_BIZ_TYPE" :value="scope.row.bizType" /> + </template> + </el-table-column> + <el-table-column label="业务编号" align="center" prop="bizId" min-width="80" /> + <el-table-column label="标题" align="center" prop="title" min-width="110" /> + <el-table-column + label="金额" + align="center" + prop="price" + min-width="60" + :formatter="fenToYuanFormat" + /> + <el-table-column label="说明" align="center" prop="description" min-width="120" /> + <el-table-column label="状态" align="center" prop="status" min-width="85"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BROKERAGE_RECORD_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="解冻时间" + align="center" + prop="unfreezeTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as BrokerageRecordApi from '@/api/mall/trade/brokerage/record' +import { fenToYuanFormat } from '@/utils/formatter' + +defineOptions({ name: 'TradeBrokerageRecord' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: null, + bizType: null, + price: null, + status: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BrokerageRecordApi.getBrokerageRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/trade/brokerage/user/BrokerageOrderListDialog.vue b/src/views/mall/trade/brokerage/user/BrokerageOrderListDialog.vue new file mode 100644 index 0000000..54e3c16 --- /dev/null +++ b/src/views/mall/trade/brokerage/user/BrokerageOrderListDialog.vue @@ -0,0 +1,152 @@ +<template> + <Dialog v-model="dialogVisible" title="推广人列表" width="75%"> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="85px" + > + <el-form-item label="用户类型" prop="level"> + <el-radio-group v-model="queryParams.level" @change="handleQuery"> + <el-radio-button checked>全部</el-radio-button> + <el-radio-button label="1">一级推广人</el-radio-button> + <el-radio-button label="2">二级推广人</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_RECORD_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="绑定时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="订单编号" align="center" prop="bizId" min-width="80px" /> + <el-table-column label="用户编号" align="center" prop="sourceUserId" min-width="80px" /> + <el-table-column label="头像" align="center" prop="sourceUserAvatar" width="70px"> + <template #default="scope"> + <el-avatar :src="scope.row.sourceUserAvatar" /> + </template> + </el-table-column> + <el-table-column label="昵称" align="center" prop="sourceUserNickname" min-width="80px" /> + <el-table-column + label="佣金" + align="center" + prop="price" + min-width="100px" + :formatter="fenToYuanFormat" + /> + <el-table-column label="状态" align="center" prop="status" min-width="85"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BROKERAGE_RECORD_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + </Dialog> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as BrokerageRecordApi from '@/api/mall/trade/brokerage/record' +import { BrokerageRecordBizTypeEnum } from '@/utils/constants' +import { fenToYuanFormat } from '@/utils/formatter' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' + +/** 推广订单列表 */ +defineOptions({ name: 'BrokerageOrderListDialog' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: null, + bizType: BrokerageRecordBizTypeEnum.ORDER.type, + level: '', + createTime: [], + status: null +}) +const queryFormRef = ref() // 搜索的表单 + +/** 打开弹窗 */ +const dialogVisible = ref(false) // 弹窗的是否展示 +const open = async (userId: any) => { + dialogVisible.value = true + queryParams.userId = userId + resetQuery() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BrokerageRecordApi.getBrokerageRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + handleQuery() +} +</script> diff --git a/src/views/mall/trade/brokerage/user/BrokerageUserListDialog.vue b/src/views/mall/trade/brokerage/user/BrokerageUserListDialog.vue new file mode 100644 index 0000000..87dc8f6 --- /dev/null +++ b/src/views/mall/trade/brokerage/user/BrokerageUserListDialog.vue @@ -0,0 +1,137 @@ +<template> + <Dialog v-model="dialogVisible" title="推广人列表" width="75%"> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="85px" + > + <el-form-item label="用户类型" prop="level"> + <el-radio-group v-model="queryParams.level" @change="handleQuery"> + <el-radio-button checked>全部</el-radio-button> + <el-radio-button label="1">一级推广人</el-radio-button> + <el-radio-button label="2">二级推广人</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item label="绑定时间" prop="bindUserTime"> + <el-date-picker + v-model="queryParams.bindUserTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="用户编号" align="center" prop="id" min-width="80px" /> + <el-table-column label="头像" align="center" prop="avatar" width="70px"> + <template #default="scope"> + <el-avatar :src="scope.row.avatar" /> + </template> + </el-table-column> + <el-table-column label="昵称" align="center" prop="nickname" min-width="80px" /> + <el-table-column + label="推广人数" + align="center" + prop="brokerageUserCount" + min-width="80px" + /> + <el-table-column + label="推广订单数量" + align="center" + prop="brokerageOrderCount" + min-width="110px" + /> + <el-table-column label="推广资格" align="center" prop="brokerageEnabled" min-width="80px"> + <template #default="scope"> + <el-tag v-if="scope.row.brokerageEnabled">有</el-tag> + <el-tag v-else type="info">无</el-tag> + </template> + </el-table-column> + <el-table-column + label="绑定时间" + align="center" + prop="bindUserTime" + :formatter="dateFormatter" + width="180px" + /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + </Dialog> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as BrokerageUserApi from '@/api/mall/trade/brokerage/user' + +/** 推广人列表 */ +defineOptions({ name: 'BrokerageUserListDialog' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + bindUserId: null, + level: '', + bindUserTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 打开弹窗 */ +const dialogVisible = ref(false) // 弹窗的是否展示 +const open = async (bindUserId: any) => { + dialogVisible.value = true + queryParams.bindUserId = bindUserId + resetQuery() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BrokerageUserApi.getBrokerageUserPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + handleQuery() +} +</script> diff --git a/src/views/mall/trade/brokerage/user/UpdateBindUserForm.vue b/src/views/mall/trade/brokerage/user/UpdateBindUserForm.vue new file mode 100644 index 0000000..77ffac7 --- /dev/null +++ b/src/views/mall/trade/brokerage/user/UpdateBindUserForm.vue @@ -0,0 +1,127 @@ +<template> + <Dialog v-model="dialogVisible" title="修改上级推广人" width="500"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="推广人" prop="bindUserId"> + <el-input + v-model="formData.bindUserId" + placeholder="请输入推广员编号" + v-loading="formLoading" + > + <template #append> + <el-button @click="handleGetUser"><Icon icon="ep:search" class="mr-5px" /></el-button> + </template> + </el-input> + </el-form-item> + </el-form> + <!-- 展示上级推广人的信息 --> + <el-descriptions v-if="bindUser" :column="1" border> + <el-descriptions-item label="头像"> + <el-avatar :src="bindUser.avatar" /> + </el-descriptions-item> + <el-descriptions-item label="昵称">{{ bindUser.nickname }}</el-descriptions-item> + <el-descriptions-item label="推广资格"> + <el-tag v-if="bindUser.brokerageEnabled">有</el-tag> + <el-tag v-else type="info">无</el-tag> + </el-descriptions-item> + <el-descriptions-item label="成为推广员的时间"> + {{ formatDate(bindUser.brokerageTime) }} + </el-descriptions-item> + </el-descriptions> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as BrokerageUserApi from '@/api/mall/trade/brokerage/user' +import { formatDate } from '@/utils/formatTime' + +/** 修改上级推广人表单 */ +defineOptions({ name: 'UpdateBindUserForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref() +const formRef = ref() // 表单 Ref +const formRules = reactive({ + bindUserId: [{ required: true, message: '推广员人不能为空', trigger: 'blur' }] +}) + +/** 打开弹窗 */ +const open = async (row: BrokerageUserApi.BrokerageUserVO) => { + resetForm() + // 设置数据 + formData.value.id = row.id + formData.value.bindUserId = row.bindUserId + // 反显上级推广人 + if (row.bindUserId) { + await handleGetUser() + } + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +/** 修改上级推广人 */ +const submitForm = async () => { + if (formLoading.value) return + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 未查找到合适的上级 + if (!bindUser.value) { + message.error('请先查询并确认推广人') + return + } + + // 提交请求 + formLoading.value = true + try { + // 发起修改 + await BrokerageUserApi.updateBindUser(formData.value) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success', true) + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + bindUserId: undefined + } + formRef.value?.resetFields() + bindUser.value = undefined +} + +/** 查询推广员 */ +const bindUser = ref<BrokerageUserApi.BrokerageUserVO>() +const handleGetUser = async () => { + if (formData.value.bindUserId == formData.value.id) { + message.error('不能绑定自己为推广人') + return + } + formLoading.value = true + bindUser.value = await BrokerageUserApi.getBrokerageUser(formData.value.bindUserId) + if (!bindUser.value) { + message.warning('推广员不存在') + } + formLoading.value = false +} +</script> diff --git a/src/views/mall/trade/brokerage/user/index.vue b/src/views/mall/trade/brokerage/user/index.vue new file mode 100644 index 0000000..22daf1b --- /dev/null +++ b/src/views/mall/trade/brokerage/user/index.vue @@ -0,0 +1,307 @@ +<template> + <doc-alert title="【交易】分销返佣" url="https://doc.iocoder.cn/mall/trade-brokerage/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="85px" + > + <el-form-item label="推广员编号" prop="bindUserId"> + <el-input + v-model="queryParams.bindUserId" + placeholder="请输入推广员编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="推广资格" prop="brokerageEnabled"> + <el-select + v-model="queryParams.brokerageEnabled" + class="!w-240px" + clearable + placeholder="请选择推广资格" + > + <el-option label="有" :value="true" /> + <el-option label="无" :value="false" /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="用户编号" align="center" prop="id" min-width="80px" /> + <el-table-column label="头像" align="center" prop="avatar" width="70px"> + <template #default="scope"> + <el-avatar :src="scope.row.avatar" /> + </template> + </el-table-column> + <el-table-column label="昵称" align="center" prop="nickname" min-width="80px" /> + <el-table-column label="推广人数" align="center" prop="brokerageUserCount" width="80px" /> + <el-table-column + label="推广订单数量" + align="center" + prop="brokerageOrderCount" + min-width="110px" + /> + <el-table-column + label="推广订单金额" + align="center" + prop="brokerageOrderPrice" + min-width="110px" + :formatter="fenToYuanFormat" + /> + <el-table-column + label="已提现金额" + align="center" + prop="withdrawPrice" + min-width="100px" + :formatter="fenToYuanFormat" + /> + <el-table-column label="已提现次数" align="center" prop="withdrawCount" min-width="100px" /> + <el-table-column + label="未提现金额" + align="center" + prop="price" + min-width="100px" + :formatter="fenToYuanFormat" + /> + <el-table-column + label="冻结中佣金" + align="center" + prop="frozenPrice" + min-width="100px" + :formatter="fenToYuanFormat" + /> + <el-table-column label="推广资格" align="center" prop="brokerageEnabled" min-width="80px"> + <template #default="scope"> + <el-switch + v-model="scope.row.brokerageEnabled" + active-text="有" + inactive-text="无" + inline-prompt + :disabled="!checkPermi(['trade:brokerage-user:update-bind-user'])" + @change="handleBrokerageEnabledChange(scope.row)" + /> + </template> + </el-table-column> + <el-table-column + label="成为推广员时间" + align="center" + prop="brokerageTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="上级推广员编号" align="center" prop="bindUserId" width="150px" /> + <el-table-column + label="推广员绑定时间" + align="center" + prop="bindUserTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="150px" fixed="right"> + <template #default="scope"> + <el-dropdown + @command="(command) => handleCommand(command, scope.row)" + v-hasPermi="[ + 'trade:brokerage-user:user-query', + 'trade:brokerage-user:order-query', + 'trade:brokerage-user:update-bind-user', + 'trade:brokerage-user:clear-bind-user' + ]" + > + <el-button link type="primary"> + <Icon icon="ep:d-arrow-right" /> + 更多 + </el-button> + <template #dropdown> + <el-dropdown-menu> + <el-dropdown-item + command="openBrokerageUserTable" + v-if="checkPermi(['trade:brokerage-user:user-query'])" + > + 推广人 + </el-dropdown-item> + <el-dropdown-item + command="openBrokerageOrderTable" + v-if="checkPermi(['trade:brokerage-user:order-query'])" + > + 推广订单 + </el-dropdown-item> + <el-dropdown-item + command="openUpdateBindUserForm" + v-if="checkPermi(['trade:brokerage-user:update-bind-user'])" + > + 修改上级推广人 + </el-dropdown-item> + <el-dropdown-item + command="handleClearBindUser" + v-if=" + scope.row.bindUserId && checkPermi(['trade:brokerage-user:clear-bind-user']) + " + > + 清除上级推广人 + </el-dropdown-item> + </el-dropdown-menu> + </template> + </el-dropdown> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + <!-- 修改上级推广人表单 --> + <UpdateBindUserForm ref="updateBindUserFormRef" @success="getList" /> + <!-- 推广人列表 --> + <BrokerageUserListDialog ref="brokerageUserListDialogRef" /> + <!-- 推广订单列表 --> + <BrokerageOrderListDialog ref="brokerageOrderListDialogRef" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as BrokerageUserApi from '@/api/mall/trade/brokerage/user' +import { checkPermi } from '@/utils/permission' +import { fenToYuanFormat } from '@/utils/formatter' +import UpdateBindUserForm from '@/views/mall/trade/brokerage/user/UpdateBindUserForm.vue' +import BrokerageUserListDialog from '@/views/mall/trade/brokerage/user/BrokerageUserListDialog.vue' +import BrokerageOrderListDialog from '@/views/mall/trade/brokerage/user/BrokerageOrderListDialog.vue' + +defineOptions({ name: 'TradeBrokerageUser' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + bindUserId: null, + brokerageEnabled: true, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BrokerageUserApi.getBrokerageUserPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +const handleCommand = (command: string, row: BrokerageUserApi.BrokerageUserVO) => { + switch (command) { + case 'openBrokerageUserTable': + openBrokerageUserTable(row.id) + break + case 'openBrokerageOrderTable': + openBrokerageOrderTable(row.id) + break + case 'openUpdateBindUserForm': + openUpdateBindUserForm(row) + break + case 'handleClearBindUser': + handleClearBindUser(row) + break + } +} + +/** 打开推广人列表 */ +const brokerageUserListDialogRef = ref() +const openBrokerageUserTable = (id: number) => { + brokerageUserListDialogRef.value.open(id) +} + +/** 打开推广订单列表 */ +const brokerageOrderListDialogRef = ref() +const openBrokerageOrderTable = (id: number) => { + brokerageOrderListDialogRef.value.open(id) +} + +/** 打开表单:修改上级推广人 */ +const updateBindUserFormRef = ref() +const openUpdateBindUserForm = (row: BrokerageUserApi.BrokerageUserVO) => { + updateBindUserFormRef.value.open(row) +} + +/** 清除上级推广人 */ +const handleClearBindUser = async (row: BrokerageUserApi.BrokerageUserVO) => { + try { + // 二次确认 + await message.confirm(`确认要清除"${row.nickname}"的上级推广人吗?`) + // 发起修改 + await BrokerageUserApi.clearBindUser({ id: row.id }) + message.success('清除成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 推广资格:开通/关闭 */ +const handleBrokerageEnabledChange = async (row: BrokerageUserApi.BrokerageUserVO) => { + try { + // 二次确认 + const text = row.brokerageEnabled ? '开通' : '关闭' + await message.confirm(`确认要${text}"${row.nickname}"的推广资格吗?`) + // 发起修改 + await BrokerageUserApi.updateBrokerageEnabled({ id: row.id, enabled: row.brokerageEnabled }) + message.success(text + '成功') + // 刷新列表 + await getList() + } catch { + // 异常时,需要重置回之前的值 + row.brokerageEnabled = !row.brokerageEnabled + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/trade/brokerage/withdraw/BrokerageWithdrawRejectForm.vue b/src/views/mall/trade/brokerage/withdraw/BrokerageWithdrawRejectForm.vue new file mode 100644 index 0000000..2a69b5b --- /dev/null +++ b/src/views/mall/trade/brokerage/withdraw/BrokerageWithdrawRejectForm.vue @@ -0,0 +1,73 @@ +<template> + <Dialog title="审核" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="驳回原因" prop="auditReason"> + <el-input v-model="formData.auditReason" type="textarea" placeholder="请输入驳回原因" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as BrokerageWithdrawApi from '@/api/mall/trade/brokerage/withdraw' + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, + auditReason: undefined +}) +const formRules = reactive({ + auditReason: [{ required: true, message: '驳回原因不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + resetForm() + formData.value.id = id +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as BrokerageWithdrawApi.BrokerageWithdrawVO + await BrokerageWithdrawApi.rejectBrokerageWithdraw(data) + message.success('驳回成功') + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + auditReason: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/trade/brokerage/withdraw/index.vue b/src/views/mall/trade/brokerage/withdraw/index.vue new file mode 100644 index 0000000..762451f --- /dev/null +++ b/src/views/mall/trade/brokerage/withdraw/index.vue @@ -0,0 +1,268 @@ +<template> + <doc-alert title="【交易】分销返佣" url="https://doc.iocoder.cn/mall/trade-brokerage/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-input + v-model="queryParams.userId" + placeholder="请输入用户编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="提现类型" prop="type"> + <el-select + v-model="queryParams.type" + placeholder="请选择提现类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_WITHDRAW_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="账号" prop="accountNo"> + <el-input + v-model="queryParams.accountNo" + placeholder="请输入账号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="提现银行" prop="bankName"> + <el-select + v-model="queryParams.bankName" + placeholder="请选择提现银行" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.BROKERAGE_BANK_NAME)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_WITHDRAW_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="申请时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="left" prop="id" min-width="60px" /> + <el-table-column label="用户信息" align="left" min-width="120px"> + <template #default="scope"> + <div>编号:{{ scope.row.userId }}</div> + <div>昵称:{{ scope.row.userNickname }}</div> + </template> + </el-table-column> + <el-table-column label="提现金额" align="left" prop="price" min-width="80px"> + <template #default="scope"> + <div>金 额:¥{{ fenToYuan(scope.row.price) }}</div> + <div>手续费:¥{{ fenToYuan(scope.row.feePrice) }}</div> + </template> + </el-table-column> + <el-table-column label="提现方式" align="left" prop="type" min-width="120px"> + <template #default="scope"> + <div v-if="scope.row.type === BrokerageWithdrawTypeEnum.WALLET.type"> 余额 </div> + <div v-else> + {{ getDictLabel(DICT_TYPE.BROKERAGE_WITHDRAW_TYPE, scope.row.type) }} + <span v-if="scope.row.accountNo">账号:{{ scope.row.accountNo }}</span> + </div> + <template v-if="scope.row.type === BrokerageWithdrawTypeEnum.BANK.type"> + <div>真实姓名:{{ scope.row.name }}</div> + <div> + 银行名称: + <dict-tag :type="DICT_TYPE.BROKERAGE_BANK_NAME" :value="scope.row.bankName" /> + </div> + <div>开户地址:{{ scope.row.bankAddress }}</div> + </template> + </template> + </el-table-column> + <el-table-column label="收款码" align="left" prop="accountQrCodeUrl" min-width="70px"> + <template #default="scope"> + <el-image + v-if="scope.row.accountQrCodeUrl" + :src="scope.row.accountQrCodeUrl" + class="h-40px w-40px" + :preview-src-list="[scope.row.accountQrCodeUrl]" + preview-teleported + /> + <span v-else>无</span> + </template> + </el-table-column> + <el-table-column + label="申请时间" + align="left" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="备注" align="left" prop="remark" /> + <el-table-column label="状态" align="left" prop="status" min-width="120px"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.BROKERAGE_WITHDRAW_STATUS" :value="scope.row.status" /> + <div v-if="scope.row.auditTime" class="text-xs"> + 时间:{{ formatDate(scope.row.auditTime) }} + </div> + <div v-if="scope.row.auditReason" class="text-xs"> + 原因:{{ scope.row.auditReason }} + </div> + </template> + </el-table-column> + <el-table-column label="操作" align="left" width="110px" fixed="right"> + <template #default="scope"> + <template v-if="scope.row.status === BrokerageWithdrawStatusEnum.AUDITING.status"> + <el-button + link + type="primary" + @click="handleApprove(scope.row.id)" + v-hasPermi="['trade:brokerage-withdraw:audit']" + > + 通过 + </el-button> + <el-button + link + type="danger" + @click="openForm(scope.row.id)" + v-hasPermi="['trade:brokerage-withdraw:audit']" + > + 驳回 + </el-button> + </template> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <BrokerageWithdrawRejectForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getDictLabel, getIntDictOptions, getStrDictOptions } from '@/utils/dict' +import { dateFormatter, formatDate } from '@/utils/formatTime' +import * as BrokerageWithdrawApi from '@/api/mall/trade/brokerage/withdraw' +import BrokerageWithdrawRejectForm from './BrokerageWithdrawRejectForm.vue' +import { BrokerageWithdrawStatusEnum, BrokerageWithdrawTypeEnum } from '@/utils/constants' +import { fenToYuanFormat } from '@/utils/formatter' +import { fenToYuan } from '@/utils' + +defineOptions({ name: 'BrokerageWithdraw' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: null, + type: null, + name: null, + accountNo: null, + bankName: null, + status: null, + auditReason: null, + auditTime: [], + remark: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await BrokerageWithdrawApi.getBrokerageWithdrawPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (id: number) => { + formRef.value.open(id) +} + +/** 审核通过 */ +const handleApprove = async (id: number) => { + try { + loading.value = true + await message.confirm('确定要审核通过吗?') + await BrokerageWithdrawApi.approveBrokerageWithdraw(id) + await message.success(t('common.success')) + await getList() + } finally { + loading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/trade/config/index.vue b/src/views/mall/trade/config/index.vue new file mode 100644 index 0000000..cdaf812 --- /dev/null +++ b/src/views/mall/trade/config/index.vue @@ -0,0 +1,291 @@ +<template> + <doc-alert title="【交易】交易订单" url="https://doc.iocoder.cn/mall/trade-order/" /> + <doc-alert title="【交易】购物车" url="https://doc.iocoder.cn/mall/trade-cart/" /> + + <ContentWrap> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="120px" + > + <el-form-item v-show="false" label="hideId"> + <el-input v-model="formData.id" /> + </el-form-item> + <el-tabs> + <!-- 售后 --> + <el-tab-pane label="售后"> + <el-form-item label="退款理由" prop="afterSaleRefundReasons"> + <el-select + v-model="formData.afterSaleRefundReasons" + allow-create + filterable + multiple + placeholder="请直接输入退款理由" + > + <el-option + v-for="reason in formData.afterSaleRefundReasons" + :key="reason" + :label="reason" + :value="reason" + /> + </el-select> + </el-form-item> + <el-form-item label="退货理由" prop="afterSaleReturnReasons"> + <el-select + v-model="formData.afterSaleReturnReasons" + allow-create + filterable + multiple + placeholder="请直接输入退货理由" + > + <el-option + v-for="reason in formData.afterSaleReturnReasons" + :key="reason" + :label="reason" + :value="reason" + /> + </el-select> + </el-form-item> + </el-tab-pane> + <!-- 配送 --> + <el-tab-pane label="配送"> + <el-form-item label="启用包邮" prop="deliveryExpressFreeEnabled"> + <el-switch v-model="formData.deliveryExpressFreeEnabled" style="user-select: none" /> + <el-text class="w-full" size="small" type="info"> 商城是否启用全场包邮</el-text> + </el-form-item> + <el-form-item label="满额包邮" prop="deliveryExpressFreePrice"> + <el-input-number + v-model="formData.deliveryExpressFreePrice" + :min="0" + :precision="2" + class="!w-xs" + placeholder="请输入满额包邮" + /> + <el-text class="w-full" size="small" type="info"> + 商城商品满多少金额即可包邮,单位:元 + </el-text> + </el-form-item> + <el-form-item label="启用门店自提" prop="deliveryPickUpEnabled"> + <el-switch v-model="formData.deliveryPickUpEnabled" style="user-select: none" /> + </el-form-item> + </el-tab-pane> + <!-- 分销 --> + <el-tab-pane label="分销"> + <el-form-item label="分佣启用" prop="brokerageEnabled"> + <el-switch v-model="formData.brokerageEnabled" style="user-select: none" /> + <el-text class="w-full" size="small" type="info"> 商城是否开启分销模式</el-text> + </el-form-item> + <el-form-item label="分佣模式" prop="brokerageEnabledCondition"> + <el-radio-group v-model="formData.brokerageEnabledCondition"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_ENABLED_CONDITION)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + <el-text class="w-full" size="small" type="info"> + 人人分销:每个用户都可以成为推广员 + </el-text> + <el-text class="w-full" size="small" type="info"> + 指定分销:仅可在后台手动设置推广员 + </el-text> + </el-form-item> + <el-form-item label="分销关系绑定" prop="brokerageBindMode"> + <el-radio-group v-model="formData.brokerageBindMode"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_BIND_MODE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + <el-text class="w-full" size="small" type="info"> + 首次绑定:只要用户没有推广人,随时都可以绑定推广关系 + </el-text> + <el-text class="w-full" size="small" type="info"> + 注册绑定:只有新用户注册时或首次进入系统时才可以绑定推广关系 + </el-text> + </el-form-item> + <el-form-item label="分销海报图"> + <UploadImgs v-model="formData.brokeragePosterUrls" height="125px" width="75px" /> + <el-text class="w-full" size="small" type="info"> + 个人中心分销海报图片,建议尺寸 600x1000 + </el-text> + </el-form-item> + <el-form-item label="一级返佣比例" prop="brokerageFirstPercent"> + <el-input-number + v-model="formData.brokerageFirstPercent" + :max="100" + :min="0" + class="!w-xs" + placeholder="请输入一级返佣比例" + /> + <el-text class="w-full" size="small" type="info"> + 订单交易成功后给推广人返佣的百分比 + </el-text> + </el-form-item> + <el-form-item label="二级返佣比例" prop="brokerageSecondPercent"> + <el-input-number + v-model="formData.brokerageSecondPercent" + :max="100" + :min="0" + class="!w-xs" + placeholder="请输入二级返佣比例" + /> + <el-text class="w-full" size="small" type="info"> + 订单交易成功后给推广人的推荐人返佣的百分比 + </el-text> + </el-form-item> + <el-form-item label="佣金冻结天数" prop="brokerageFrozenDays"> + <el-input-number + v-model="formData.brokerageFrozenDays" + :min="0" + class="!w-xs" + placeholder="请输入佣金冻结天数" + /> + <el-text class="w-full" size="small" type="info"> + 防止用户退款,佣金被提现了,所以需要设置佣金冻结时间,单位:天 + </el-text> + </el-form-item> + <el-form-item label="提现最低金额" prop="brokerageWithdrawMinPrice"> + <el-input-number + v-model="formData.brokerageWithdrawMinPrice" + :min="0" + :precision="2" + class="!w-xs" + placeholder="请输入提现最低金额" + /> + <el-text class="w-full" size="small" type="info"> + 用户提现最低金额限制,单位:元 + </el-text> + </el-form-item> + <el-form-item label="提现手续费" prop="brokerageWithdrawFeePercent"> + <el-input-number + v-model="formData.brokerageWithdrawFeePercent" + :max="100" + :min="0" + class="!w-xs" + placeholder="请输入提现手续费" + /> + <el-text class="w-full" size="small" type="info"> + 提现手续费百分比,范围 0-100,0 为无提现手续费。例:设置 10,即收取 10% 手续费,提现 + 10 元,到账 9 元,1 元手续费 + </el-text> + </el-form-item> + <el-form-item label="提现方式" prop="brokerageWithdrawTypes"> + <el-checkbox-group v-model="formData.brokerageWithdrawTypes"> + <el-checkbox + v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_WITHDRAW_TYPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-checkbox> + </el-checkbox-group> + <el-text class="w-full" size="small" type="info"> 商城开通提现的付款方式</el-text> + </el-form-item> + </el-tab-pane> + </el-tabs> + <!-- 保存 --> + <el-form-item> + <el-button :loading="formLoading" type="primary" @click="submitForm"> 保存</el-button> + </el-form-item> + </el-form> + </ContentWrap> +</template> + +<script lang="ts" setup> +import * as ConfigApi from '@/api/mall/trade/config' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { cloneDeep } from 'lodash-es' + +defineOptions({ name: 'TradeConfig' }) + +const message = useMessage() // 消息弹窗 + +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formRef = ref() +const formData = ref({ + id: null, + afterSaleRefundReasons: [], + afterSaleReturnReasons: [], + deliveryExpressFreeEnabled: false, + deliveryExpressFreePrice: 0, + deliveryPickUpEnabled: false, + brokerageEnabled: false, + brokerageEnabledCondition: undefined, + brokerageBindMode: undefined, + brokeragePosterUrls: [], + brokerageFirstPercent: 0, + brokerageSecondPercent: 0, + brokerageWithdrawMinPrice: 0, + brokerageWithdrawFeePercent: 0, + brokerageFrozenDays: 0, + brokerageWithdrawTypes: [] +}) +const formRules = reactive({ + deliveryExpressFreePrice: [{ required: true, message: '满额包邮不能为空', trigger: 'blur' }], + brokerageEnabledCondition: [{ required: true, message: '分佣模式不能为空', trigger: 'blur' }], + brokerageBindMode: [{ required: true, message: '分销关系绑定模式不能为空', trigger: 'blur' }], + brokerageFirstPercent: [{ required: true, message: '一级返佣比例不能为空', trigger: 'blur' }], + brokerageSecondPercent: [{ required: true, message: '二级返佣比例不能为空', trigger: 'blur' }], + brokerageWithdrawMinPrice: [ + { required: true, message: '用户提现最低金额不能为空', trigger: 'blur' } + ], + brokerageWithdrawFeePercent: [{ required: true, message: '提现手续费不能为空', trigger: 'blur' }], + brokerageFrozenDays: [{ required: true, message: '佣金冻结时间不能为空', trigger: 'blur' }], + brokerageWithdrawTypes: [ + { + required: true, + message: '提现方式不能为空', + trigger: 'change' + } + ] +}) + +const submitForm = async () => { + if (formLoading.value) return + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = cloneDeep(unref(formData.value)) as unknown as ConfigApi.ConfigVO + // 金额放大 + data.deliveryExpressFreePrice = data.deliveryExpressFreePrice * 100 + data.brokerageWithdrawMinPrice = data.brokerageWithdrawMinPrice * 100 + await ConfigApi.saveTradeConfig(data) + message.success('保存成功') + } finally { + formLoading.value = false + } +} + +/** 查询交易中心配置 */ +const getConfig = async () => { + formLoading.value = true + try { + const data = await ConfigApi.getTradeConfig() + if (data != null) { + formData.value = data + // 金额缩小 + formData.value.deliveryExpressFreePrice = data.deliveryExpressFreePrice / 100 + formData.value.brokerageWithdrawMinPrice = data.brokerageWithdrawMinPrice / 100 + } + } finally { + formLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getConfig() +}) +</script> diff --git a/src/views/mall/trade/delivery/express/ExpressForm.vue b/src/views/mall/trade/delivery/express/ExpressForm.vue new file mode 100644 index 0000000..232fb79 --- /dev/null +++ b/src/views/mall/trade/delivery/express/ExpressForm.vue @@ -0,0 +1,126 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label="公司编码" prop="code"> + <el-input v-model="formData.code" placeholder="请输入快递编码" /> + </el-form-item> + <el-form-item label="公司名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入快递名称" /> + </el-form-item> + <el-form-item label="公司 logo" prop="logo"> + <UploadImg v-model="formData.logo" :limit="1" :is-show-tip="false" /> + <div style="font-size: 10px" class="pl-10px">推荐 180x180 图片分辨率</div> + </el-form-item> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="formData.sort" controls-position="right" :min="0" /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express' + +defineOptions({ name: 'ExpressForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + code: '', + name: '', + logo: '', + sort: 0, + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive({ + code: [{ required: true, message: '快递编码不能为空', trigger: 'blur' }], + name: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }], + logo: [{ required: true, message: '分类图片不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '分类排序不能为空', trigger: 'blur' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await DeliveryExpressApi.getDeliveryExpress(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as DeliveryExpressApi.DeliveryExpressVO + if (formType.value === 'create') { + await DeliveryExpressApi.createDeliveryExpress(data) + message.success(t('common.createSuccess')) + } else { + await DeliveryExpressApi.updateDeliveryExpress(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + picUrl: '', + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/trade/delivery/express/index.vue b/src/views/mall/trade/delivery/express/index.vue new file mode 100644 index 0000000..1cde87d --- /dev/null +++ b/src/views/mall/trade/delivery/express/index.vue @@ -0,0 +1,189 @@ +<template> + <doc-alert title="【交易】快递发货" url="https://doc.iocoder.cn/mall/trade-delivery-express/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="100px" + > + <el-form-item label="快递公司编号" prop="code"> + <el-input + v-model="queryParams.code" + placeholder="请输快递公司编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="快递公司名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输快递公司名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['trade:delivery:express:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['trade:delivery:express:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="公司编码" prop="code" /> + <el-table-column label="公司名称" prop="name" /> + <el-table-column label="公司 logo " prop="logo"> + <template #default="scope"> + <img v-if="scope.row.logo" :src="scope.row.logo" alt="公司logo" class="h-40px" /> + </template> + </el-table-column> + <el-table-column label="排序" align="center" prop="sort" /> + <el-table-column label="开启状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['trade:delivery:express:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['trade:delivery:express:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ExpressForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express' +import ExpressForm from './ExpressForm.vue' + +defineOptions({ name: 'Express' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const total = ref(0) // 列表的总页数 +const loading = ref(true) // 列表的加载中 +const list = ref<any[]>([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + code: '', + name: '' +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DeliveryExpressApi.getDeliveryExpressPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DeliveryExpressApi.deleteDeliveryExpress(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await DeliveryExpressApi.exportDeliveryExpressApi(queryParams) + download.excel(data, '快递公司.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue b/src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue new file mode 100644 index 0000000..edbcbc3 --- /dev/null +++ b/src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue @@ -0,0 +1,321 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="1300px"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="80px" + v-loading="formLoading" + > + <el-form-item label="模板名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入模板名称" /> + </el-form-item> + <el-form-item label="计费方式" prop="chargeMode"> + <el-radio-group v-model="formData.chargeMode" @change="changeChargeMode"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.EXPRESS_CHARGE_MODE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="运费" prop="charges"> + <el-table border style="width: 100%" :data="formData.charges"> + <el-table-column align="center" label="区域" width="360"> + <template #default="{ row }"> + <el-cascader + v-model="row.areaIds" + :options="areaTree" + :props="defaultProps2" + class="w-1/1" + clearable + placeholder="请选择地区" + filterable + collapse-tags + /> + </template> + </el-table-column> + <el-table-column + align="center" + :label="columnTitle.startCountTitle" + width="180" + prop="startCount" + > + <template #default="{ row }"> + <el-input-number v-model="row.startCount" :min="1" /> + </template> + </el-table-column> + <el-table-column width="180" align="center" label="运费(元)" prop="startPrice"> + <template #default="{ row }"> + <el-input-number v-model="row.startPrice" :min="1" /> + </template> + </el-table-column> + <el-table-column + width="180" + align="center" + :label="columnTitle.extraCountTitle" + prop="extraCount" + > + <template #default="{ row }"> + <el-input-number v-model="row.extraCount" :min="1" /> + </template> + </el-table-column> + <el-table-column width="180" align="center" label="续费(元)" prop="extraPrice"> + <template #default="{ row }"> + <el-input-number v-model="row.extraPrice" :min="1" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button link type="danger" @click="deleteChargeArea(scope.$index)"> + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </el-form-item> + <el-form-item> + <el-button type="primary" plain @click="addChargeArea()"> + <Icon icon="ep:plus" class="mr-5px" /> 添加区域 + </el-button> + </el-form-item> + <el-form-item label="包邮区域" prop="frees"> + <el-table border style="width: 100%" :data="formData.frees"> + <el-table-column align="center" label="区域" width="360"> + <template #default="{ row }"> + <el-cascader + v-model="row.areaIds" + :options="areaTree" + :props="defaultProps2" + class="w-1/1" + clearable + placeholder="请选择商品分类" + filterable + collapse-tags + /> + </template> + </el-table-column> + <el-table-column align="center" :label="columnTitle.freeCountTitle" prop="freeCount"> + <template #default="{ row }"> + <el-input-number v-model="row.freeCount" :min="1" /> + </template> + </el-table-column> + <el-table-column align="center" label="包邮金额(元)" prop="freePrice"> + <template #default="{ row }"> + <el-input-number v-model="row.freePrice" :min="1" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button link type="danger" @click="deleteFreeArea(scope.$index)"> 删除 </el-button> + </template> + </el-table-column> + </el-table> + </el-form-item> + <el-form-item> + <el-button type="primary" plain @click="addFreeArea()"> + <Icon icon="ep:plus" class="mr-5px" /> 添加区域 + </el-button> + </el-form-item> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="formData.sort" controls-position="right" :min="0" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as DeliveryExpressTemplateApi from '@/api/mall/trade/delivery/expressTemplate' +import * as AreaApi from '@/api/system/area' +import { defaultProps } from '@/utils/tree' +import { yuanToFen, fenToYuan } from '@/utils' +import { cloneDeep } from 'lodash-es' +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const defaultProps2 = { + ...defaultProps, + multiple: true +} + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + chargeMode: 1, + sort: 0, + charges: [], + frees: [] +}) +const columnTitleMap = new Map() +const columnTitle = ref({ + startCountTitle: '首件', + extraCountTitle: '续件', + freeCountTitle: '包邮件数' +}) +const formRules = reactive({ + name: [{ required: true, message: '模板名称不能为空', trigger: 'blur' }], + chargeMode: [{ required: true, message: '配送计费方式不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '分类排序不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + try { + // 修改时,设置数据 + if (id) { + formLoading.value = true + formData.value = await DeliveryExpressTemplateApi.getDeliveryExpressTemplate(id) + columnTitle.value = columnTitleMap.get(formData.value.chargeMode) + formData.value.charges.forEach((item) => { + // 前端价格以元展示 + item.startPrice = fenToYuan(item.startPrice) + item.extraPrice = fenToYuan(item.extraPrice) + }) + formData.value.frees.forEach((item) => { + item.freePrice = fenToYuan(item.freePrice) + }) + } + } finally { + formLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = cloneDeep(formData.value) as DeliveryExpressTemplateApi.DeliveryExpressTemplateVO + // 前端价格以元展示,提交到后端。用分计算 + data.charges.forEach((item) => { + item.startPrice = yuanToFen(item.startPrice) + item.extraPrice = yuanToFen(item.extraPrice) + }) + data.frees.forEach((item) => { + item.freePrice = yuanToFen(item.freePrice) + }) + if (formType.value === 'create') { + await DeliveryExpressTemplateApi.createDeliveryExpressTemplate(data) + message.success(t('common.createSuccess')) + } else { + await DeliveryExpressTemplateApi.updateDeliveryExpressTemplate(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + chargeMode: 1, + charges: [ + { + areaIds: [1], + startCount: 2, + startPrice: 5, + extraCount: 5, + extraPrice: 10 + } + ], + frees: [], + sort: 0 + } + columnTitle.value = columnTitleMap.get(1) + formRef.value?.resetFields() +} + +/** 配送计费方法改变 */ +const changeChargeMode = (chargeMode: number) => { + columnTitle.value = columnTitleMap.get(chargeMode) +} + +/** 初始化数据 */ +const areaTree = ref([]) +const initData = async () => { + // 表头标题和计费方式的映射 + columnTitleMap.set(1, { + startCountTitle: '首件', + extraCountTitle: '续件', + freeCountTitle: '包邮件数' + }) + columnTitleMap.set(2, { + startCountTitle: '首件重量(kg)', + extraCountTitle: '续件重量(kg)', + freeCountTitle: '包邮重量(kg)' + }) + columnTitleMap.set(3, { + startCountTitle: '首件体积(m³)', + extraCountTitle: '续件体积(m³)', + freeCountTitle: '包邮体积(m³)' + }) + // 加载区域数据 + areaTree.value = await AreaApi.getAreaTree() +} + +/** 添加计费区域 */ +const addChargeArea = () => { + const data = formData.value + data.charges.push({ + areaIds: [], + startCount: 1, + startPrice: 1, + extraCount: 1, + extraPrice: 1 + }) +} + +/** 删除计费区域 */ +const deleteChargeArea = (index) => { + const data = formData.value + data.charges.splice(index, 1) +} + +/** 添加包邮区域 */ +const addFreeArea = () => { + const data = formData.value + data.frees.push({ + areaIds: [], + freeCount: 1, + freePrice: 1 + }) +} + +/** 删除包邮区域 */ +const deleteFreeArea = (index) => { + const data = formData.value + data.frees.splice(index, 1) +} + +/** 初始化 **/ +onMounted(() => { + initData() +}) +</script> diff --git a/src/views/mall/trade/delivery/expressTemplate/index.vue b/src/views/mall/trade/delivery/expressTemplate/index.vue new file mode 100644 index 0000000..9d0688a --- /dev/null +++ b/src/views/mall/trade/delivery/expressTemplate/index.vue @@ -0,0 +1,165 @@ +<template> + <doc-alert title="【交易】快递发货" url="https://doc.iocoder.cn/mall/trade-delivery-express/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="100px" + > + <el-form-item label="模板名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入模板名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="计费方式" prop="chargeMode"> + <el-select + v-model="queryParams.chargeMode" + placeholder="计费方式" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.EXPRESS_CHARGE_MODE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['trade:delivery:express-template:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" min-width="60" prop="id" /> + <el-table-column label="模板名称" min-width="100" prop="name" /> + <el-table-column label="计费方式" prop="chargeMode" min-width="100" align="center"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.EXPRESS_CHARGE_MODE" :value="scope.row.chargeMode" /> + </template> + </el-table-column> + <el-table-column label="排序" min-width="100" prop="sort" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['trade:delivery:express-template:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['trade:delivery:express-template:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ExpressTemplateForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as DeliveryExpressTemplateApi from '@/api/mall/trade/delivery/expressTemplate' +import ExpressTemplateForm from './ExpressTemplateForm.vue' + +defineOptions({ name: 'DeliveryExpressTemplate' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const total = ref(0) // 列表的总页数 +const loading = ref(true) // 列表的加载中 +const list = ref<any[]>([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: '', + chargeMode: undefined +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DeliveryExpressTemplateApi.getDeliveryExpressTemplatePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DeliveryExpressTemplateApi.deleteDeliveryExpressTemplate(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/trade/delivery/pickUpOrder/index.vue b/src/views/mall/trade/delivery/pickUpOrder/index.vue new file mode 100644 index 0000000..e52a3e3 --- /dev/null +++ b/src/views/mall/trade/delivery/pickUpOrder/index.vue @@ -0,0 +1,328 @@ +<template> + <doc-alert title="【交易】交易订单" url="https://doc.iocoder.cn/mall/trade-order/" /> + <doc-alert title="【交易】购物车" url="https://doc.iocoder.cn/mall/trade-cart/" /> + + <!-- 搜索 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-280px" + end-placeholder="自定义时间" + start-placeholder="自定义时间" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item label="自提门店" prop="pickUpStoreId"> + <el-select + v-model="queryParams.pickUpStoreId" + class="!w-280px" + clearable + multiple + placeholder="全部" + > + <el-option + v-for="item in pickUpStoreList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="聚合搜索"> + <el-input + v-show="true" + v-model="queryParams[queryType.queryParam]" + class="!w-280px" + clearable + placeholder="请输入" + :type="queryType.queryParam === 'userId' ? 'number' : 'text'" + > + <template #prepend> + <el-select + v-model="queryType.queryParam" + class="!w-110px" + placeholder="全部" + @change="inputChangeSelect" + > + <el-option + v-for="dict in dynamicSearchList" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </template> + </el-input> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button @click="handlePickup" type="success" plain v-hasPermi="['trade:order:pick-up']"> + <Icon class="mr-5px" icon="ep:check" /> + 核销 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 统计卡片 --> + <el-row :gutter="16" class="summary"> + <el-col :sm="6" :xs="12" v-loading="loading"> + <SummaryCard + title="订单数量" + icon="icon-park-outline:transaction-order" + icon-color="bg-blue-100" + icon-bg-color="text-blue-500" + :value="summary?.orderCount || 0" + /> + </el-col> + <el-col :sm="6" :xs="12" v-loading="loading"> + <SummaryCard + title="订单金额" + icon="streamline:money-cash-file-dollar-common-money-currency-cash-file" + icon-color="bg-purple-100" + icon-bg-color="text-purple-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(summary?.orderPayPrice || 0)" + /> + </el-col> + <el-col :sm="6" :xs="12" v-loading="loading"> + <SummaryCard + title="退款单数" + icon="heroicons:receipt-refund" + icon-color="bg-yellow-100" + icon-bg-color="text-yellow-500" + :value="summary?.afterSaleCount || 0" + /> + </el-col> + <el-col :sm="6" :xs="12" v-loading="loading"> + <SummaryCard + title="退款金额" + icon="ri:refund-2-line" + icon-color="bg-green-100" + icon-bg-color="text-green-500" + prefix="¥" + :decimals="2" + :value="fenToYuan(summary?.afterSalePrice || 0)" + /> + </el-col> + </el-row> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="订单号" align="center" prop="no" min-width="180" /> + <el-table-column label="用户信息" align="center" prop="user.nickname" min-width="80" /> + <el-table-column + label="推荐人信息" + align="center" + prop="brokerageUser.nickname" + min-width="100" + /> + <el-table-column label="商品信息" align="center" prop="spuName" min-width="300"> + <template #default="{ row }"> + <div class="flex items-center" v-for="item in row.items" :key="item.id"> + <el-image + :src="item.picUrl" + class="mr-10px h-30px w-30px flex-shrink-0" + :preview-src-list="[item.picUrl]" + preview-teleported + /> + <span class="mr-10px">{{ item.spuName }}</span> + <div class="flex flex-col flex-wrap gap-1"> + <el-tag + v-for="property in item.properties" + :key="property.propertyId" + class="mr-10px" + > + {{ property.propertyName }}: {{ property.valueName }} + </el-tag> + <span>{{ floatToFixed2(item.price) }} 元 x {{ item.count }}</span> + </div> + </div> + </template> + </el-table-column> + <el-table-column + label="实付金额(元)" + align="center" + prop="payPrice" + min-width="110" + :formatter="fenToYuanFormat" + /> + <el-table-column label="核销员" align="center" prop="storeStaffName" min-width="70" /> + <el-table-column label="核销门店" align="center" prop="pickUpStoreId" min-width="80"> + <template #default="{ row }"> + {{ pickUpStoreList.find((p) => p.id === row.pickUpStoreId)?.name }} + </template> + </el-table-column> + <el-table-column label="支付状态" align="center" prop="payStatus" min-width="80"> + <template #default="{ row }"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.payStatus || false" /> + </template> + </el-table-column> + <el-table-column align="center" label="订单状态" prop="status" width="120"> + <template #default="{ row }"> + <dict-tag :type="DICT_TYPE.TRADE_ORDER_STATUS" :value="row.status" /> + </template> + </el-table-column> + <el-table-column + label="下单时间" + align="center" + prop="createTime" + min-width="170" + :formatter="dateFormatter" + /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 各种操作的弹窗 --> + <OrderPickUpForm ref="pickUpForm" @success="getList" /> +</template> + +<script lang="ts" setup> +import type { FormInstance } from 'element-plus' +import * as TradeOrderApi from '@/api/mall/trade/order' +import * as PickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore' +import { DICT_TYPE } from '@/utils/dict' +import { fenToYuan, floatToFixed2 } from '@/utils' +import { fenToYuanFormat } from '@/utils/formatter' +import SummaryCard from '@/components/SummaryCard/index.vue' +import { dateFormatter } from '@/utils/formatTime' +import { DeliveryTypeEnum } from '@/utils/constants' +import { TradeOrderSummaryRespVO } from '@/api/mall/trade/order' +import { DeliveryPickUpStoreVO } from '@/api/mall/trade/delivery/pickUpStore' +import OrderPickUpForm from '@/views/mall/trade/order/form/OrderPickUpForm.vue' + +defineOptions({ name: 'PickUpOrder' }) + +// 列表的加载中 +const loading = ref(true) +// 列表的总页数 +const total = ref(2) +// 列表的数据 +const list = ref<TradeOrderApi.OrderVO[]>([]) +// 搜索的表单 +const queryFormRef = ref<FormInstance>() +// 初始表单参数 +const INIT_QUERY_PARAMS = { + // 页数 + pageNo: 1, + // 每页显示数量 + pageSize: 10, + // 创建时间 + createTime: undefined, + // 配送方式 + deliveryType: DeliveryTypeEnum.PICK_UP.type, + // 自提门店 + pickUpStoreId: undefined +} +// 表单搜索 +const queryParams = ref({ ...INIT_QUERY_PARAMS }) +// 订单搜索类型 queryParam +const queryType = reactive({ queryParam: 'no' }) +// 订单统计数据 +const summary = ref<TradeOrderSummaryRespVO>() + +// 订单聚合搜索 select 类型配置(动态搜索) +const dynamicSearchList = ref([ + { value: 'no', label: '订单号' }, + { value: 'userId', label: '用户UID' }, + { value: 'userNickname', label: '用户昵称' }, + { value: 'userMobile', label: '用户电话' } +]) +/** + * 聚合搜索切换查询对象时触发 + * @param val + */ +const inputChangeSelect = (val: string) => { + dynamicSearchList.value + .filter((item) => item.value !== val) + ?.forEach((item) => { + // 清除集合搜索无用属性 + if (queryParams.value.hasOwnProperty(item.value)) { + delete queryParams.value[item.value] + } + }) +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + // 统计 + summary.value = await TradeOrderApi.getOrderSummary(unref(queryParams)) + // 分页 + const data = await TradeOrderApi.getOrderPage(unref(queryParams)) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = async () => { + queryParams.value.pageNo = 1 + await getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + queryParams.value = { ...INIT_QUERY_PARAMS } + handleQuery() +} + +/** 自提门店精简列表 */ +const pickUpStoreList = ref<DeliveryPickUpStoreVO[]>([]) +const getPickUpStoreList = async () => { + pickUpStoreList.value = await PickUpStoreApi.getListAllSimple() +} + +/** 显示核销表单 */ +const pickUpForm = ref() +const handlePickup = () => { + pickUpForm.value.open() +} + +/** 初始化 **/ +onMounted(() => { + getList() + getPickUpStoreList() +}) +</script> +<style lang="scss" scoped> +:deep(.order-table-col > .cell) { + padding: 0; +} + +.summary { + .el-col { + margin-bottom: 1rem; + } +} +</style> diff --git a/src/views/mall/trade/delivery/pickUpStore/PickUpStoreForm.vue b/src/views/mall/trade/delivery/pickUpStore/PickUpStoreForm.vue new file mode 100644 index 0000000..5900558 --- /dev/null +++ b/src/views/mall/trade/delivery/pickUpStore/PickUpStoreForm.vue @@ -0,0 +1,273 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="60%"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-row> + <el-col :span="12"> + <el-form-item label="门店 logo" prop="logo"> + <UploadImg v-model="formData.logo" :limit="1" :is-show-tip="false" /> + <div style="font-size: 10px" class="pl-10px">推荐 180x180 图片分辨率</div> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="门店状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="门店名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入门店名称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="门店手机" prop="phone"> + <el-input v-model="formData.phone" placeholder="请输入门店手机" /> + </el-form-item> + </el-col> + </el-row> + <el-form-item label="门店简介" prop="introduction"> + <el-input + v-model="formData.introduction" + :rows="3" + type="textarea" + placeholder="请输入门店简介" + /> + </el-form-item> + <el-row> + <el-col :span="12"> + <el-form-item label="门店所在地区" prop="areaId"> + <el-cascader v-model="formData.areaId" :options="areaList" :props="defaultProps" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="门店详细地址" prop="detailAddress"> + <el-input v-model="formData.detailAddress" placeholder="请输入门店详细地址" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="营业开始时间" prop="openingTime"> + <el-time-select + v-model="formData.openingTime" + :max-time="formData.closingTime" + placeholder="开始时间" + start="08:30" + step="00:15" + end="23:30" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="营业结束时间" prop="closingTime"> + <el-time-select + v-model="formData.closingTime" + :min-time="formData.openingTime" + placeholder="结束时间" + start="08:30" + step="00:15" + end="23:30" + /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="经度" prop="longitude"> + <el-input v-model="formData.longitude" placeholder="请输入门店经度" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="纬度" prop="latitude"> + <el-input v-model="formData.latitude" placeholder="请输入门店纬度" /> + </el-form-item> + </el-col> + </el-row> + <el-form-item label="获取经纬度"> + <el-button type="primary" @click="mapDialogVisible = true">获取</el-button> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + <el-dialog + v-model="mapDialogVisible" + title="获取经纬度" + append-to-body + width="500px" + class="mapBox" + > + <iframe id="mapPage" width="100%" height="100%" frameborder="0" :src="tencentLbsUrl"></iframe> + </el-dialog> + </Dialog> +</template> +<script setup lang="ts"> +import * as DeliveryPickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import { defaultProps } from '@/utils/tree' +import { getAreaTree } from '@/api/system/area' +import * as ConfigApi from '@/api/mall/trade/config' +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const mapDialogVisible = ref(false) // 地图弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + phone: '', + logo: '', + detailAddress: '', + introduction: '', + areaId: 0, + openingTime: undefined, + closingTime: undefined, + latitude: undefined, + longitude: undefined, + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive({ + name: [{ required: true, message: '门店名称不能为空', trigger: 'blur' }], + logo: [{ required: true, message: '门店 logo 不能为空', trigger: 'blur' }], + phone: [ + { required: true, message: '门店手机不能为空', trigger: 'blur' }, + { pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: '请输入正确的手机号码', trigger: 'blur' } + ], + areaId: [{ required: true, message: '门店所在区域不能为空', trigger: 'blur' }], + detailAddress: [{ required: true, message: '门店详细地址不能为空', trigger: 'blur' }], + openingTime: [{ required: true, message: '营业开始时间不能为空', trigger: 'blur' }], + closingTime: [{ required: true, message: '营业结束时间不能为空', trigger: 'blur' }], + latitude: [{ required: true, message: '纬度不能为空', trigger: 'blur' }], + longitude: [{ required: true, message: '经度不能为空', trigger: 'blur' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const areaList = ref() // 区域树 +const tencentLbsUrl = ref('') // 腾讯位置服务 url + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await DeliveryPickUpStoreApi.getDeliveryPickUpStore(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as DeliveryPickUpStoreApi.DeliveryPickUpStoreVO + if (formType.value === 'create') { + await DeliveryPickUpStoreApi.createDeliveryPickUpStore(data) + message.success(t('common.createSuccess')) + } else { + await DeliveryPickUpStoreApi.updateDeliveryPickUpStore(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + phone: '', + logo: '', + detailAddress: '', + introduction: '', + areaId: undefined, + openingTime: undefined, + closingTime: undefined, + latitude: undefined, + longitude: undefined, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} + +/** 选择经纬度 */ +const selectAddress = function (loc: any): void { + if (loc.latlng && loc.latlng.lat) { + formData.value.latitude = loc.latlng.lat + } + if (loc.latlng && loc.latlng.lng) { + formData.value.longitude = loc.latlng.lng + } + mapDialogVisible.value = false +} + +/** 初始化腾讯地图 */ +const initTencentLbsMap = async () => { + window.selectAddress = selectAddress + window.addEventListener( + 'message', + function (event) { + // 接收位置信息,用户选择确认位置点后选点组件会触发该事件,回传用户的位置信息 + let loc = event.data + if (loc && loc.module === 'locationPicker') { + // 防止其他应用也会向该页面 post 信息,需判断 module 是否为 'locationPicker' + window.parent.selectAddress(loc) + } + }, + false + ) + const data = await ConfigApi.getTradeConfig() + const key = data.tencentLbsKey + tencentLbsUrl.value = `https://apis.map.qq.com/tools/locpicker?type=1&key=${key}&referer=myapp` +} + +/** 初始化 **/ +onMounted(async () => { + areaList.value = await getAreaTree() + // 加载地图 + await initTencentLbsMap() +}) +</script> +<style lang="scss"> +.mapBox .el-dialog__body { + height: 640px !important; +} +</style> diff --git a/src/views/mall/trade/delivery/pickUpStore/index.vue b/src/views/mall/trade/delivery/pickUpStore/index.vue new file mode 100644 index 0000000..eddf64e --- /dev/null +++ b/src/views/mall/trade/delivery/pickUpStore/index.vue @@ -0,0 +1,190 @@ +<template> + <doc-alert title="【交易】快递发货" url="https://doc.iocoder.cn/mall/trade-delivery-express/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form ref="queryFormRef" :inline="true" :model="queryParams" class="-mb-15px"> + <el-form-item label="门店手机" prop="phone"> + <el-input + v-model="queryParams.phone" + class="!w-240px" + clearable + placeholder="请输门店手机" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="门店名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输门店名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="门店状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="门店状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="datetimerange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['trade:delivery:pick-up-store:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" min-width="80" prop="id" /> + <el-table-column label="门店 logo" min-width="100" prop="logo"> + <template #default="scope"> + <img v-if="scope.row.logo" :src="scope.row.logo" alt="门店 logo" class="h-50px" /> + </template> + </el-table-column> + <el-table-column label="门店名称" min-width="150" prop="name" /> + <el-table-column label="门店手机" min-width="100" prop="phone" /> + <el-table-column label="地址" min-width="100" prop="detailAddress" /> + <el-table-column label="营业时间" min-width="180"> + <template #default="scope"> + {{ scope.row.openingTime }} ~ {{ scope.row.closingTime }} + </template> + </el-table-column> + <el-table-column align="center" label="开启状态" min-width="100" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['trade:delivery:pick-up-store:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['trade:delivery:pick-up-store:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </ContentWrap> + <!-- 表单弹窗:添加/修改 --> + <DeliveryPickUpStoreForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" name="DeliveryPickUpStore" setup> +import * as DeliveryPickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore' +import DeliveryPickUpStoreForm from './PickUpStoreForm.vue' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const total = ref(0) // 列表的总页数 +const loading = ref(true) // 列表的加载中 +const list = ref<any[]>([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + status: undefined, + phone: undefined, + name: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DeliveryPickUpStoreApi.deleteDeliveryPickUpStore(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DeliveryPickUpStoreApi.getDeliveryPickUpStorePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/trade/order/components/OrderTableColumn.vue b/src/views/mall/trade/order/components/OrderTableColumn.vue new file mode 100644 index 0000000..5d1e25e --- /dev/null +++ b/src/views/mall/trade/order/components/OrderTableColumn.vue @@ -0,0 +1,263 @@ +<template> + <el-table-column class-name="order-table-col"> + <template #header> + <div class="flex items-center" style="width: 100%"> + <div :style="{ width: orderTableHeadWidthList[0] + 'px' }" class="flex justify-center"> + 商品信息 + </div> + <div :style="{ width: orderTableHeadWidthList[1] + 'px' }" class="flex justify-center"> + 单价(元)/数量 + </div> + <div :style="{ width: orderTableHeadWidthList[2] + 'px' }" class="flex justify-center"> + 售后状态 + </div> + <div :style="{ width: orderTableHeadWidthList[3] + 'px' }" class="flex justify-center"> + 实付金额(元) + </div> + <div :style="{ width: orderTableHeadWidthList[4] + 'px' }" class="flex justify-center"> + 买家/收货人 + </div> + <div :style="{ width: orderTableHeadWidthList[5] + 'px' }" class="flex justify-center"> + 配送方式 + </div> + <div :style="{ width: orderTableHeadWidthList[6] + 'px' }" class="flex justify-center"> + 订单状态 + </div> + <div :style="{ width: orderTableHeadWidthList[7] + 'px' }" class="flex justify-center"> + 操作 + </div> + </div> + </template> + <template #default="scope"> + <el-table + :ref="setOrderTableRef" + :border="true" + :data="scope.row.items" + :header-cell-style="headerStyle" + :span-method="spanMethod" + style="width: 100%" + > + <el-table-column min-width="300" prop="spuName"> + <template #header> + <div + class="mr-[20px] h-[35px] flex items-center pl-[10px] pr-[10px]" + style="background-color: #f7f7f7" + > + <span class="mr-20px">订单号:{{ scope.row.no }} </span> + <span class="mr-20px">下单时间:{{ formatDate(scope.row.createTime) }}</span> + <span>订单来源:</span> + <dict-tag :type="DICT_TYPE.TERMINAL" :value="scope.row.terminal" class="mr-20px" /> + <span>支付方式:</span> + <dict-tag + v-if="scope.row.payChannelCode" + :type="DICT_TYPE.PAY_CHANNEL_CODE" + :value="scope.row.payChannelCode" + class="mr-20px" + /> + <span v-else class="mr-20px">未支付</span> + <span v-if="scope.row.payTime" class="mr-20px"> + 支付时间:{{ formatDate(scope.row.payTime) }} + </span> + <span>订单类型:</span> + <dict-tag :type="DICT_TYPE.TRADE_ORDER_TYPE" :value="scope.row.type" /> + </div> + </template> + <template #default="{ row }"> + <div class="flex flex-wrap"> + <div class="mb-[10px] mr-[10px] flex items-start"> + <div class="mr-[10px]"> + <el-image + :src="row.picUrl" + class="!h-[45px] !w-[45px]" + fit="contain" + @click="imagePreview(row.picUrl)" + > + <template #error> + <div class="image-slot"> + <icon icon="ep:picture" /> + </div> + </template> + </el-image> + </div> + <ElTooltip :content="row.spuName" placement="top"> + <span class="overflow-ellipsis max-h-[45px] overflow-hidden"> + {{ row.spuName }} + </span> + </ElTooltip> + </div> + <el-tag + v-for="property in row.properties" + :key="property.propertyId" + class="mb-[10px] mr-[10px]" + > + {{ property.propertyName }}: {{ property.valueName }} + </el-tag> + </div> + </template> + </el-table-column> + <el-table-column label="商品原价*数量" prop="price" width="150"> + <template #default="{ row }"> + {{ floatToFixed2(row.price) }} 元 / {{ row.count }} + </template> + </el-table-column> + <el-table-column label="售后状态" prop="afterSaleStatus" width="120"> + <template #default="{ row }"> + <dict-tag + :type="DICT_TYPE.TRADE_ORDER_ITEM_AFTER_SALE_STATUS" + :value="row.afterSaleStatus" + /> + </template> + </el-table-column> + <el-table-column align="center" label="实际支付" min-width="120" prop="payPrice"> + <template #default> + {{ floatToFixed2(scope.row.payPrice) + '元' }} + </template> + </el-table-column> + <el-table-column label="买家/收货人" min-width="160"> + <template #default> + <!-- 快递发货 --> + <div + v-if="scope.row.deliveryType === DeliveryTypeEnum.EXPRESS.type" + class="flex flex-col" + > + <span>买家:{{ scope.row.user.nickname }}</span> + <span> + 收货人:{{ scope.row.receiverName }} {{ scope.row.receiverMobile }} + {{ scope.row.receiverAreaName }} {{ scope.row.receiverDetailAddress }} + </span> + </div> + <!-- 自提 --> + <div + v-if="scope.row.deliveryType === DeliveryTypeEnum.PICK_UP.type" + class="flex flex-col" + > + <span> + 门店名称: + {{ pickUpStoreList.find((p) => p.id === scope.row.pickUpStoreId)?.name }} + </span> + <span> + 门店手机: + {{ pickUpStoreList.find((p) => p.id === scope.row.pickUpStoreId)?.phone }} + </span> + <span> + 自提门店: + {{ pickUpStoreList.find((p) => p.id === scope.row.pickUpStoreId)?.detailAddress }} + </span> + </div> + </template> + </el-table-column> + <el-table-column align="center" label="配送方式" width="120"> + <template #default> + <dict-tag :type="DICT_TYPE.TRADE_DELIVERY_TYPE" :value="scope.row.deliveryType" /> + </template> + </el-table-column> + <el-table-column align="center" label="订单状态" width="120"> + <template #default> + <dict-tag :type="DICT_TYPE.TRADE_ORDER_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column align="center" fixed="right" label="操作" width="160"> + <template #default> + <slot :row="scope.row"></slot> + </template> + </el-table-column> + </el-table> + </template> + </el-table-column> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { DeliveryTypeEnum } from '@/utils/constants' +import { formatDate } from '@/utils/formatTime' +import { floatToFixed2 } from '@/utils' +import * as TradeOrderApi from '@/api/mall/trade/order' +import { OrderVO } from '@/api/mall/trade/order' +import type { TableColumnCtx, TableInstance } from 'element-plus' +import { createImageViewer } from '@/components/ImageViewer' +import type { DeliveryPickUpStoreVO } from '@/api/mall/trade/delivery/pickUpStore' + +defineOptions({ name: 'OrderTableColumn' }) + +const props = defineProps<{ + list: OrderVO[] + pickUpStoreList: DeliveryPickUpStoreVO[] +}>() + +const headerStyle = ({ row, columnIndex }: any) => { + // 表头第一行第一列占 8 + if (columnIndex === 0) { + row[columnIndex].colSpan = 8 + } else { + // 其余的不要 + row[columnIndex].colSpan = 0 + return { + display: 'none' + } + } +} + +interface SpanMethodProps { + row: TradeOrderApi.OrderItemRespVO + column: TableColumnCtx<TradeOrderApi.OrderItemRespVO> + rowIndex: number + columnIndex: number +} + +type spanMethodResp = number[] | { rowspan: number; colspan: number } | undefined +const spanMethod = ({ row, rowIndex, columnIndex }: SpanMethodProps): spanMethodResp => { + const len = props.list.find( + (order) => order.items?.findIndex((item) => item.id === row.id) !== -1 + )?.items?.length + // 要合并的列,从零开始 + const colIndex = [3, 4, 5, 6, 7] + if (colIndex.includes(columnIndex)) { + // 除了第一行其余的不要 + if (rowIndex !== 0) { + return { + rowspan: 0, + colspan: 0 + } + } + // 动态合并行 + return { + rowspan: len!, + colspan: 1 + } + } +} + +/** 解决 ref 在 v-for 中的获取问题*/ +const setOrderTableRef = (el: TableInstance) => { + if (!el) return + // 只要第一个表也就是开始的第一行 + if (el.tableId !== 'el-table_2') { + return + } + tableHeadWidthAuto(el) +} +// 头部 col 宽度初始化 +const orderTableHeadWidthList = ref([300, 150, 120, 120, 160, 120, 120, 160]) +// 头部宽度自适应 +const tableHeadWidthAuto = (el: TableInstance) => { + const columns = el.store.states.columns.value + if (columns.length === 0) { + return + } + columns.forEach((col: TableColumnCtx<TableInstance>, index: number) => { + if (col.realWidth) { + orderTableHeadWidthList.value[index] = col.realWidth + } + }) +} +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} +</script> +<style lang="scss" scoped> +:deep(.order-table-col > .cell) { + padding: 0; +} +</style> diff --git a/src/views/mall/trade/order/components/index.ts b/src/views/mall/trade/order/components/index.ts new file mode 100644 index 0000000..9cce9fa --- /dev/null +++ b/src/views/mall/trade/order/components/index.ts @@ -0,0 +1,3 @@ +import OrderTableColumn from './OrderTableColumn.vue' + +export { OrderTableColumn } diff --git a/src/views/mall/trade/order/detail/index.vue b/src/views/mall/trade/order/detail/index.vue new file mode 100644 index 0000000..67e5476 --- /dev/null +++ b/src/views/mall/trade/order/detail/index.vue @@ -0,0 +1,426 @@ +<template> + <ContentWrap> + <!-- 订单信息 --> + <el-descriptions title="订单信息"> + <el-descriptions-item label="订单号: ">{{ formData.no }}</el-descriptions-item> + <el-descriptions-item label="买家: ">{{ formData?.user?.nickname }}</el-descriptions-item> + <el-descriptions-item label="订单类型: "> + <dict-tag :type="DICT_TYPE.TRADE_ORDER_TYPE" :value="formData.type!" /> + </el-descriptions-item> + <el-descriptions-item label="订单来源: "> + <dict-tag :type="DICT_TYPE.TERMINAL" :value="formData.terminal!" /> + </el-descriptions-item> + <el-descriptions-item label="买家留言: ">{{ formData.userRemark }}</el-descriptions-item> + <el-descriptions-item label="商家备注: ">{{ formData.remark }}</el-descriptions-item> + <el-descriptions-item label="支付单号: ">{{ formData.payOrderId }}</el-descriptions-item> + <el-descriptions-item label="付款方式: "> + <dict-tag :type="DICT_TYPE.PAY_CHANNEL_CODE" :value="formData.payChannelCode!" /> + </el-descriptions-item> + <el-descriptions-item v-if="formData.brokerageUser" label="推广用户: "> + {{ formData.brokerageUser?.nickname }} + </el-descriptions-item> + </el-descriptions> + + <!-- 订单状态 --> + <el-descriptions :column="1" title="订单状态"> + <el-descriptions-item label="订单状态: "> + <dict-tag :type="DICT_TYPE.TRADE_ORDER_STATUS" :value="formData.status!" /> + </el-descriptions-item> + <el-descriptions-item v-hasPermi="['trade:order:update']" label-class-name="no-colon"> + <el-button + v-if="formData.status! === TradeOrderStatusEnum.UNPAID.status" + type="primary" + @click="updatePrice" + > + 调整价格 + </el-button> + <el-button type="primary" @click="remark">备注</el-button> + <!-- 待发货 --> + <template v-if="formData.status! === TradeOrderStatusEnum.UNDELIVERED.status"> + <!-- 快递发货 --> + <el-button + v-if="formData.deliveryType === DeliveryTypeEnum.EXPRESS.type" + type="primary" + @click="delivery" + > + 发货 + </el-button> + <el-button + v-if="formData.deliveryType === DeliveryTypeEnum.EXPRESS.type" + type="primary" + @click="updateAddress" + > + 修改地址 + </el-button> + <!-- 到店自提 --> + <el-button + v-if="formData.deliveryType === DeliveryTypeEnum.PICK_UP.type && showPickUp" + type="primary" + @click="handlePickUp" + > + 核销 + </el-button> + </template> + </el-descriptions-item> + <el-descriptions-item> + <template #label><span style="color: red">提醒: </span></template> + 买家付款成功后,货款将直接进入您的商户号(微信、支付宝)<br /> + 请及时关注你发出的包裹状态,确保可以配送至买家手中 <br /> + 如果买家表示没收到货或货物有问题,请及时联系买家处理,友好协商 + </el-descriptions-item> + </el-descriptions> + + <!-- 商品信息 --> + <el-descriptions title="商品信息"> + <el-descriptions-item labelClassName="no-colon"> + <el-row :gutter="20"> + <el-col :span="15"> + <el-table :data="formData.items" border> + <el-table-column label="商品" prop="spuName" width="auto"> + <template #default="{ row }"> + {{ row.spuName }} + <el-tag v-for="property in row.properties" :key="property.propertyId"> + {{ property.propertyName }}: {{ property.valueName }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="商品原价" prop="price" width="150"> + <template #default="{ row }">{{ fenToYuan(row.price) }}元</template> + </el-table-column> + <el-table-column label="数量" prop="count" width="100" /> + <el-table-column label="合计" prop="payPrice" width="150"> + <template #default="{ row }">{{ fenToYuan(row.payPrice) }}元</template> + </el-table-column> + <el-table-column label="售后状态" prop="afterSaleStatus" width="120"> + <template #default="{ row }"> + <dict-tag + :type="DICT_TYPE.TRADE_ORDER_ITEM_AFTER_SALE_STATUS" + :value="row.afterSaleStatus" + /> + </template> + </el-table-column> + </el-table> + </el-col> + <el-col :span="10" /> + </el-row> + </el-descriptions-item> + </el-descriptions> + <el-descriptions :column="4"> + <!-- 第一层 --> + <el-descriptions-item label="商品总额: "> + {{ fenToYuan(formData.totalPrice!) }} 元 + </el-descriptions-item> + <el-descriptions-item label="运费金额: "> + {{ fenToYuan(formData.deliveryPrice!) }} 元 + </el-descriptions-item> + <el-descriptions-item label="订单调价: "> + {{ fenToYuan(formData.adjustPrice!) }} 元 + </el-descriptions-item> + <el-descriptions-item v-for="item in 1" :key="item" label-class-name="no-colon" /> + <!-- 第二层 --> + <el-descriptions-item> + <template #label><span style="color: red">优惠劵优惠: </span></template> + {{ fenToYuan(formData.couponPrice!) }} 元 + </el-descriptions-item> + <el-descriptions-item> + <template #label><span style="color: red">VIP 优惠: </span></template> + {{ fenToYuan(formData.vipPrice!) }} 元 + </el-descriptions-item> + <el-descriptions-item> + <template #label><span style="color: red">活动优惠: </span></template> + {{ fenToYuan(formData.discountPrice!) }} 元 + </el-descriptions-item> + <el-descriptions-item> + <template #label><span style="color: red">积分抵扣: </span></template> + {{ fenToYuan(formData.pointPrice!) }} 元 + </el-descriptions-item> + <!-- 第三层 --> + <el-descriptions-item v-for="item in 3" :key="item" label-class-name="no-colon" /> + <el-descriptions-item label="应付金额: "> + {{ fenToYuan(formData.payPrice!) }} 元 + </el-descriptions-item> + </el-descriptions> + + <!-- 物流信息 --> + <el-descriptions :column="4" title="收货信息"> + <el-descriptions-item label="配送方式: "> + <dict-tag :type="DICT_TYPE.TRADE_DELIVERY_TYPE" :value="formData.deliveryType!" /> + </el-descriptions-item> + <el-descriptions-item label="收货人: ">{{ formData.receiverName }}</el-descriptions-item> + <el-descriptions-item label="联系电话: ">{{ formData.receiverMobile }}</el-descriptions-item> + <!-- 快递配送 --> + <div v-if="formData.deliveryType === DeliveryTypeEnum.EXPRESS.type"> + <el-descriptions-item v-if="formData.receiverDetailAddress" label="收货地址: "> + {{ formData.receiverAreaName }} {{ formData.receiverDetailAddress }} + <el-link + v-clipboard:copy="formData.receiverAreaName + ' ' + formData.receiverDetailAddress" + v-clipboard:success="clipboardSuccess" + icon="ep:document-copy" + type="primary" + /> + </el-descriptions-item> + <el-descriptions-item v-if="formData.logisticsId" label="物流公司: "> + {{ deliveryExpressList.find((item) => item.id === formData.logisticsId)?.name }} + </el-descriptions-item> + <el-descriptions-item v-if="formData.logisticsId" label="运单号: "> + {{ formData.logisticsNo }} + </el-descriptions-item> + <el-descriptions-item v-if="formatDate.deliveryTime" label="发货时间: "> + {{ formatDate(formData.deliveryTime) }} + </el-descriptions-item> + <el-descriptions-item v-for="item in 2" :key="item" label-class-name="no-colon" /> + <el-descriptions-item v-if="expressTrackList.length > 0" label="物流详情: "> + <el-timeline> + <el-timeline-item + v-for="(express, index) in expressTrackList" + :key="index" + :timestamp="formatDate(express.time)" + > + {{ express.content }} + </el-timeline-item> + </el-timeline> + </el-descriptions-item> + </div> + <!-- 自提门店 --> + <div v-if="formData.deliveryType === DeliveryTypeEnum.PICK_UP.type"> + <el-descriptions-item v-if="formData.pickUpStoreId" label="自提门店: "> + {{ pickUpStore?.name }} + </el-descriptions-item> + </div> + </el-descriptions> + + <!-- 订单日志 --> + <el-descriptions title="订单操作日志"> + <el-descriptions-item labelClassName="no-colon"> + <el-timeline> + <el-timeline-item + v-for="(log, index) in formData.logs" + :key="index" + :timestamp="formatDate(log.createTime!)" + placement="top" + > + <div class="el-timeline-right-content"> + {{ log.content }} + </div> + <template #dot> + <span + :style="{ backgroundColor: getUserTypeColor(log.userType!) }" + class="dot-node-style" + > + {{ getDictLabel(DICT_TYPE.USER_TYPE, log.userType)[0] }} + </span> + </template> + </el-timeline-item> + </el-timeline> + </el-descriptions-item> + </el-descriptions> + </ContentWrap> + + <!-- 各种操作的弹窗 --> + <OrderDeliveryForm ref="deliveryFormRef" @success="getDetail" /> + <OrderUpdateRemarkForm ref="updateRemarkForm" @success="getDetail" /> + <OrderUpdateAddressForm ref="updateAddressFormRef" @success="getDetail" /> + <OrderUpdatePriceForm ref="updatePriceFormRef" @success="getDetail" /> +</template> +<script lang="ts" setup> +import * as TradeOrderApi from '@/api/mall/trade/order' +import { fenToYuan } from '@/utils' +import { formatDate } from '@/utils/formatTime' +import { DICT_TYPE, getDictLabel, getDictObj } from '@/utils/dict' +import OrderUpdateRemarkForm from '@/views/mall/trade/order/form/OrderUpdateRemarkForm.vue' +import OrderDeliveryForm from '@/views/mall/trade/order/form/OrderDeliveryForm.vue' +import OrderUpdateAddressForm from '@/views/mall/trade/order/form/OrderUpdateAddressForm.vue' +import OrderUpdatePriceForm from '@/views/mall/trade/order/form/OrderUpdatePriceForm.vue' +import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express' +import { useTagsViewStore } from '@/store/modules/tagsView' +import { DeliveryTypeEnum, TradeOrderStatusEnum } from '@/utils/constants' +import * as DeliveryPickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore' +import { propTypes } from '@/utils/propTypes' + +defineOptions({ name: 'TradeOrderDetail' }) + +const message = useMessage() // 消息弹窗 + +/** 获得 userType 颜色 */ +const getUserTypeColor = (type: number) => { + const dict = getDictObj(DICT_TYPE.USER_TYPE, type) + switch (dict?.colorType) { + case 'success': + return '#67C23A' + case 'info': + return '#909399' + case 'warning': + return '#E6A23C' + case 'danger': + return '#F56C6C' + } + return '#409EFF' +} + +// 订单详情 +const formData = ref<TradeOrderApi.OrderVO>({ + logs: [] +}) + +/** 各种操作 */ +const updateRemarkForm = ref() // 订单备注表单 Ref +const remark = () => { + updateRemarkForm.value?.open(formData.value) +} +const deliveryFormRef = ref() // 发货表单 Ref +const delivery = () => { + deliveryFormRef.value?.open(formData.value) +} +const updateAddressFormRef = ref() // 收货地址表单 Ref +const updateAddress = () => { + updateAddressFormRef.value?.open(formData.value) +} +const updatePriceFormRef = ref() // 订单调价表单 Ref +const updatePrice = () => { + updatePriceFormRef.value?.open(formData.value) +} + +/** 核销 */ +const handlePickUp = async () => { + try { + // 二次确认 + await message.confirm('确认核销订单吗?') + // 提交 + await TradeOrderApi.pickUpOrder(formData.value.id!) + message.success('核销成功') + // 刷新列表 + await getDetail() + } catch {} +} + +/** 获得详情 */ +const { params } = useRoute() // 查询参数 +const props = defineProps({ + id: propTypes.number.def(undefined), // 订单ID + showPickUp: propTypes.bool.def(true) // 显示核销按钮 +}) +const id = (params.id || props.id) as unknown as number +const getDetail = async () => { + if (id) { + const res = (await TradeOrderApi.getOrder(id)) as TradeOrderApi.OrderVO + // 没有表单信息则关闭页面返回 + if (!res) { + message.error('交易订单不存在') + close() + } + formData.value = res + } +} + +/** 关闭 tag */ +const { delView } = useTagsViewStore() // 视图操作 +const { push, currentRoute } = useRouter() // 路由 +const close = () => { + delView(unref(currentRoute)) + push({ name: 'TradeOrder' }) +} + +/** 复制 */ +const clipboardSuccess = () => { + message.success('复制成功') +} + +/** 初始化 **/ +const deliveryExpressList = ref([]) // 物流公司 +const expressTrackList = ref([]) // 物流详情 +const pickUpStore = ref({}) // 自提门店 +onMounted(async () => { + await getDetail() + // 如果配送方式为快递,则查询物流公司 + if (formData.value.deliveryType === DeliveryTypeEnum.EXPRESS.type) { + deliveryExpressList.value = await DeliveryExpressApi.getSimpleDeliveryExpressList() + if (form.value.logisticsId) { + expressTrackList.value = await TradeOrderApi.getExpressTrackList(formData.value.id!) + } + } else if (formData.value.deliveryType === DeliveryTypeEnum.PICK_UP.type) { + pickUpStore.value = await DeliveryPickUpStoreApi.getDeliveryPickUpStore( + formData.value.pickUpStoreId + ) + } +}) +</script> +<style lang="scss" scoped> +:deep(.el-descriptions) { + &:not(:nth-child(1)) { + margin-top: 20px; + } + + .el-descriptions__title { + display: flex; + align-items: center; + + &::before { + display: inline-block; + width: 3px; + height: 20px; + margin-right: 10px; + background-color: #409eff; + content: ''; + } + } + + .el-descriptions-item__container { + margin: 0 10px; + + .no-colon { + margin: 0; + + &::after { + content: ''; + } + } + } +} + +// 时间线样式调整 +:deep(.el-timeline) { + margin: 10px 0 0 160px; + + .el-timeline-item__wrapper { + position: relative; + top: -20px; + + .el-timeline-item__timestamp { + position: absolute !important; + top: 10px; + left: -150px; + } + } + + .el-timeline-right-content { + display: flex; + align-items: center; + min-height: 30px; + padding: 10px; + background-color: #f7f8fa; + + &::before { + position: absolute; + top: 10px; + left: 13px; /* 将伪元素水平居中 */ + border-color: transparent #f7f8fa transparent transparent; /* 尖角颜色,左侧朝向 */ + border-style: solid; + border-width: 8px; /* 调整尖角大小 */ + content: ''; /* 必须设置 content 属性 */ + } + } + + .dot-node-style { + position: absolute; + left: -5px; + display: flex; + width: 20px; + height: 20px; + font-size: 10px; + color: #fff; + border-radius: 50%; + justify-content: center; + align-items: center; + } +} +</style> diff --git a/src/views/mall/trade/order/form/OrderDeliveryForm.vue b/src/views/mall/trade/order/form/OrderDeliveryForm.vue new file mode 100644 index 0000000..3b98c2e --- /dev/null +++ b/src/views/mall/trade/order/form/OrderDeliveryForm.vue @@ -0,0 +1,99 @@ +<template> + <Dialog v-model="dialogVisible" title="订单发货" width="25%"> + <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="80px"> + <el-form-item label="发货方式"> + <el-radio-group v-model="expressType"> + <el-radio border label="express">快递物流</el-radio> + <el-radio border label="none">无需发货</el-radio> + </el-radio-group> + </el-form-item> + <template v-if="expressType === 'express'"> + <el-form-item label="物流公司"> + <el-select v-model="formData.logisticsId" placeholder="请选择" style="width: 100%"> + <el-option + v-for="item in deliveryExpressList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="物流单号"> + <el-input v-model="formData.logisticsNo" /> + </el-form-item> + </template> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express' +import * as TradeOrderApi from '@/api/mall/trade/order' +import { copyValueToTarget } from '@/utils' + +defineOptions({ name: 'OrderDeliveryForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const expressType = ref('express') // 如果值是 express,则是快递;none 则是无;未来做同城配送; +const formData = ref<TradeOrderApi.DeliveryVO>({ + id: undefined, // 订单编号 + logisticsId: null, // 物流公司编号 + logisticsNo: '' // 物流编号 +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (row: TradeOrderApi.OrderVO) => { + resetForm() + // 设置数据 + copyValueToTarget(formData.value, row) + if (row.logisticsId === 0) { + expressType.value = 'none' + } + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 提交请求 + formLoading.value = true + try { + const data = unref(formData) + if (expressType.value === 'none') { + // 无需发货的情况 + data.logisticsId = 0 + data.logisticsNo = '' + } + await TradeOrderApi.deliveryOrder(data) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success', true) + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, // 订单编号 + logisticsId: null, // 物流公司编号 + logisticsNo: '' // 物流编号 + } + formRef.value?.resetFields() +} +const deliveryExpressList = ref([]) +onMounted(async () => { + deliveryExpressList.value = await DeliveryExpressApi.getSimpleDeliveryExpressList() +}) +</script> diff --git a/src/views/mall/trade/order/form/OrderPickUpForm.vue b/src/views/mall/trade/order/form/OrderPickUpForm.vue new file mode 100644 index 0000000..529263c --- /dev/null +++ b/src/views/mall/trade/order/form/OrderPickUpForm.vue @@ -0,0 +1,108 @@ +<template> + <!-- 核销对话框 --> + <Dialog v-model="dialogVisible" title="订单核销" width="35%"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item prop="pickUpVerifyCode" label="核销码"> + <el-input v-model="formData.pickUpVerifyCode" placeholder="请输入核销码" /> + </el-form-item> + </el-form> + <template #footer> + <el-button type="primary" :disabled="formLoading" @click="getOrderByPickUpVerifyCode"> + 查询 + </el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <!-- 核销确认对话框 --> + <Dialog v-model="detailDialogVisible" title="订单详情" width="55%"> + <TradeOrderDetail v-if="orderDetails.id" :id="orderDetails.id" :show-pick-up="false" /> + <template #footer> + <el-button type="primary" :disabled="formLoading" @click="submitForm"> 确认核销 </el-button> + <el-button @click="detailDialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as TradeOrderApi from '@/api/mall/trade/order' +import { OrderVO } from '@/api/mall/trade/order' +import { DeliveryTypeEnum, TradeOrderStatusEnum } from '@/utils/constants' +import TradeOrderDetail from '@/views/mall/trade/order/detail/index.vue' + +/** 订单核销表单 */ +defineOptions({ name: 'OrderPickUpForm' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailDialogVisible = ref(false) // 详情弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formRules = reactive({ + pickUpVerifyCode: [{ required: true, message: '核销码不能为空', trigger: 'blur' }] +}) +const formData = ref({ + pickUpVerifyCode: '' // 核销码 +}) +const formRef = ref() // 表单 Ref +const orderDetails = ref<OrderVO>({}) + +/** 打开弹窗 */ +const open = async () => { + resetForm() + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 提交请求 + formLoading.value = true + try { + await TradeOrderApi.pickUpOrderByVerifyCode(formData.value.pickUpVerifyCode) + message.success('核销成功') + detailDialogVisible.value = false + dialogVisible.value = false + // 发送操作成功的事件 + emit('success', true) + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + pickUpVerifyCode: '' // 核销码 + } + formRef.value?.resetFields() +} + +/** 查询核销码对应的订单 */ +const getOrderByPickUpVerifyCode = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + + formLoading.value = true + const data = await TradeOrderApi.getOrderByPickUpVerifyCode(formData.value.pickUpVerifyCode) + formLoading.value = false + if (data?.deliveryType !== DeliveryTypeEnum.PICK_UP.type) { + message.error('请输入正确的核销码') + return + } + if (data?.status !== TradeOrderStatusEnum.UNDELIVERED.status) { + message.error('订单不是待核销状态') + return + } + orderDetails.value = data + // 显示详情对话框 + detailDialogVisible.value = true +} +</script> diff --git a/src/views/mall/trade/order/form/OrderUpdateAddressForm.vue b/src/views/mall/trade/order/form/OrderUpdateAddressForm.vue new file mode 100644 index 0000000..baedb4a --- /dev/null +++ b/src/views/mall/trade/order/form/OrderUpdateAddressForm.vue @@ -0,0 +1,98 @@ +<template> + <Dialog v-model="dialogVisible" title="修改订单收货地址" width="35%"> + <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="120px"> + <el-form-item label="收件人"> + <el-input v-model="formData.receiverName" placeholder="请输入收件人名称" /> + </el-form-item> + <el-form-item label="手机号"> + <el-input v-model="formData.receiverMobile" placeholder="请输入收件人手机号" /> + </el-form-item> + <el-form-item label="所在地"> + <el-tree-select + v-model="formData.receiverAreaId" + :data="areaList" + :props="defaultProps" + :render-after-expand="true" + /> + </el-form-item> + <el-form-item label="详细地址"> + <el-input + v-model="formData.receiverDetailAddress" + :rows="3" + placeholder="请输入收件人详细地址" + type="textarea" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as TradeOrderApi from '@/api/mall/trade/order' +import { getAreaTree } from '@/api/system/area' +import { copyValueToTarget } from '@/utils' +import { defaultProps } from '@/utils/tree' + +defineOptions({ name: 'OrderUpdateAddressForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, // 订单编号 + receiverName: '', // 收件人名称 + receiverMobile: '', // 收件人手机 + receiverAreaId: null, //收件人地区编号 + receiverDetailAddress: '' //收件人详细地址 +}) +const areaList = ref([]) // 地区列表 +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (row: TradeOrderApi.OrderVO) => { + resetForm() + // 设置数据 + copyValueToTarget(formData.value, row) + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 提交请求 + formLoading.value = true + try { + const data = unref(formData) + await TradeOrderApi.updateOrderAddress(data) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success', true) + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, // 订单编号 + receiverName: '', // 收件人名称 + receiverMobile: '', // 收件人手机 + receiverAreaId: null, //收件人地区编号 + receiverDetailAddress: '' //收件人详细地址 + } + formRef.value?.resetFields() +} + +onMounted(async () => { + // 获得地区列表 + areaList.value = await getAreaTree() +}) +</script> diff --git a/src/views/mall/trade/order/form/OrderUpdatePriceForm.vue b/src/views/mall/trade/order/form/OrderUpdatePriceForm.vue new file mode 100644 index 0000000..8332e31 --- /dev/null +++ b/src/views/mall/trade/order/form/OrderUpdatePriceForm.vue @@ -0,0 +1,95 @@ +<template> + <Dialog v-model="dialogVisible" title="订单调价" width="25%"> + <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="100px"> + <el-form-item label="应付金额(总)"> + <el-input v-model="formData.payPrice" disabled /> + </el-form-item> + <el-form-item label="订单调价"> + <el-input-number v-model="formData.adjustPrice" :precision="2" :step="0.1" class="w-100%" /> + <el-tag class="ml-10px" type="warning">订单调价。 正数,加价;负数,减价</el-tag> + </el-form-item> + <el-form-item label="调价后"> + <el-input v-model="formData.newPayPrice" disabled /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as TradeOrderApi from '@/api/mall/trade/order' +import { convertToInteger, floatToFixed2, formatToFraction } from '@/utils' +import { cloneDeep } from 'lodash-es' + +defineOptions({ name: 'OrderUpdatePriceForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, // 订单编号 + adjustPrice: 0, // 订单调价 + payPrice: '', // 应付金额(总) + newPayPrice: '' // 调价后应付金额(总) +}) +watch( + () => formData.value.adjustPrice, + (adjustPrice: number | string) => { + const numMatch = formData.value.payPrice.match(/\d+(\.\d+)?/) + if (numMatch) { + const payPriceNum = parseFloat(numMatch[0]) + adjustPrice = typeof adjustPrice === 'string' ? parseFloat(adjustPrice) : adjustPrice + formData.value.newPayPrice = (payPriceNum + adjustPrice).toFixed(2) + '元' + } + } +) + +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (row: TradeOrderApi.OrderVO) => { + resetForm() + formData.value.id = row.id! + // 设置数据 + formData.value.adjustPrice = formatToFraction(row.adjustPrice!) + formData.value.payPrice = floatToFixed2(row.payPrice!) + '元' + formData.value.newPayPrice = formData.value.payPrice + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 提交请求 + formLoading.value = true + try { + const data = cloneDeep(unref(formData)) + data.adjustPrice = convertToInteger(data.adjustPrice) + delete data.payPrice + delete data.newPayPrice + await TradeOrderApi.updateOrderPrice(data) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success', true) + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, // 订单编号 + adjustPrice: 0, // 订单调价 + payPrice: '', // 应付金额(总) + newPayPrice: '' // 调价后应付金额(总) + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/trade/order/form/OrderUpdateRemarkForm.vue b/src/views/mall/trade/order/form/OrderUpdateRemarkForm.vue new file mode 100644 index 0000000..e979501 --- /dev/null +++ b/src/views/mall/trade/order/form/OrderUpdateRemarkForm.vue @@ -0,0 +1,70 @@ +<template> + <Dialog v-model="dialogVisible" title="商家备注" width="45%"> + <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="80px"> + <el-form-item label="备注"> + <el-input + v-model="formData.remark" + :rows="3" + placeholder="请输入订单备注" + type="textarea" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as TradeOrderApi from '@/api/mall/trade/order' + +defineOptions({ name: 'OrderUpdateRemarkForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, // 订单编号 + remark: '' // 订单备注 +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (row: TradeOrderApi.OrderVO) => { + resetForm() + // 设置数据 + formData.value.id = row.id + formData.value.remark = row.remark + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 提交请求 + formLoading.value = true + try { + const data = unref(formData) + await TradeOrderApi.updateOrderRemark(data) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success', true) + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, // 订单编号 + remark: '' // 订单备注 + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/trade/order/index.vue b/src/views/mall/trade/order/index.vue new file mode 100644 index 0000000..56aa57b --- /dev/null +++ b/src/views/mall/trade/order/index.vue @@ -0,0 +1,357 @@ +<template> + <doc-alert title="【交易】交易订单" url="https://doc.iocoder.cn/mall/trade-order/" /> + <doc-alert title="【交易】购物车" url="https://doc.iocoder.cn/mall/trade-cart/" /> + + <!-- 搜索 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="订单状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-280px" clearable placeholder="全部"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_ORDER_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="支付方式" prop="payChannelCode"> + <el-select + v-model="queryParams.payChannelCode" + class="!w-280px" + clearable + placeholder="全部" + > + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.PAY_CHANNEL_CODE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-280px" + end-placeholder="自定义时间" + start-placeholder="自定义时间" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item label="订单来源" prop="terminal"> + <el-select v-model="queryParams.terminal" class="!w-280px" clearable placeholder="全部"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.TERMINAL)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="订单类型" prop="type"> + <el-select v-model="queryParams.type" class="!w-280px" clearable placeholder="全部"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_ORDER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="配送方式" prop="deliveryType"> + <el-select v-model="queryParams.deliveryType" class="!w-280px" clearable placeholder="全部"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_DELIVERY_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="queryParams.deliveryType === DeliveryTypeEnum.EXPRESS.type" + label="快递公司" + prop="logisticsId" + > + <el-select v-model="queryParams.logisticsId" class="!w-280px" clearable placeholder="全部"> + <el-option + v-for="item in deliveryExpressList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="queryParams.deliveryType === DeliveryTypeEnum.PICK_UP.type" + label="自提门店" + prop="pickUpStoreId" + > + <el-select + v-model="queryParams.pickUpStoreId" + class="!w-280px" + clearable + multiple + placeholder="全部" + > + <el-option + v-for="item in pickUpStoreList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="queryParams.deliveryType === DeliveryTypeEnum.PICK_UP.type" + label="核销码" + prop="pickUpVerifyCode" + > + <el-input + v-model="queryParams.pickUpVerifyCode" + class="!w-280px" + clearable + placeholder="请输入自提核销码" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="聚合搜索"> + <el-input + v-show="true" + v-model="queryParams[queryType.queryParam]" + :type="queryType.queryParam === 'userId' ? 'number' : 'text'" + class="!w-280px" + clearable + placeholder="请输入" + > + <template #prepend> + <el-select + v-model="queryType.queryParam" + class="!w-110px" + clearable + placeholder="全部" + @change="inputChangeSelect" + > + <el-option + v-for="dict in dynamicSearchList" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </template> + </el-input> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <!-- 添加 row-key="id" 解决列数据中的 table#header 数据不刷新的问题 --> + <el-table v-loading="loading" :data="list" row-key="id"> + <OrderTableColumn :list="list" :pick-up-store-list="pickUpStoreList"> + <template #default="{ row }"> + <div class="flex items-center justify-center"> + <el-button + v-hasPermi="['trade:order:query']" + link + type="primary" + @click="openDetail(row.id)" + > + <Icon icon="ep:notification" /> + 详情 + </el-button> + <el-dropdown + v-hasPermi="['trade:order:update']" + @command="(command) => handleCommand(command, row)" + > + <el-button link type="primary"> + <Icon icon="ep:d-arrow-right" /> + 更多 + </el-button> + <template #dropdown> + <el-dropdown-menu> + <!-- 如果是【快递】,并且【未发货】,则展示【发货】按钮 --> + <el-dropdown-item + v-if=" + row.deliveryType === DeliveryTypeEnum.EXPRESS.type && + row.status === TradeOrderStatusEnum.UNDELIVERED.status + " + command="delivery" + > + <Icon icon="ep:takeaway-box" /> + 发货 + </el-dropdown-item> + <el-dropdown-item command="remark"> + <Icon icon="ep:chat-line-square" /> + 备注 + </el-dropdown-item> + </el-dropdown-menu> + </template> + </el-dropdown> + </div> + </template> + </OrderTableColumn> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 各种操作的弹窗 --> + <OrderDeliveryForm ref="deliveryFormRef" @success="getList" /> + <OrderUpdateRemarkForm ref="updateRemarkForm" @success="getList" /> +</template> + +<script lang="ts" setup> +import type { FormInstance } from 'element-plus' +import OrderDeliveryForm from '@/views/mall/trade/order/form/OrderDeliveryForm.vue' +import OrderUpdateRemarkForm from '@/views/mall/trade/order/form/OrderUpdateRemarkForm.vue' +import * as TradeOrderApi from '@/api/mall/trade/order' +import * as PickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore' +import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict' +import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express' +import { DeliveryTypeEnum, TradeOrderStatusEnum } from '@/utils/constants' +import { OrderTableColumn } from './components' + +defineOptions({ name: 'TradeOrder' }) + +const { currentRoute, push } = useRouter() // 路由跳转 +const loading = ref(true) // 列表的加载中 +const total = ref(2) // 列表的总页数 +const list = ref<TradeOrderApi.OrderVO[]>([]) // 列表的数据 +const queryFormRef = ref<FormInstance>() // 搜索的表单 +// 表单搜索 +const queryParams = ref({ + pageNo: 1, // 页数 + pageSize: 10, // 每页显示数量 + status: undefined, // 订单状态 + payChannelCode: undefined, // 支付方式 + createTime: undefined, // 创建时间 + terminal: undefined, // 订单来源 + type: undefined, // 订单类型 + deliveryType: undefined, // 配送方式 + logisticsId: undefined, // 快递公司 + pickUpStoreId: undefined, // 自提门店 + pickUpVerifyCode: undefined // 自提核销码 +}) +const queryType = reactive({ queryParam: '' }) // 订单搜索类型 queryParam + +// 订单聚合搜索 select 类型配置(动态搜索) +const dynamicSearchList = ref([ + { value: 'no', label: '订单号' }, + { value: 'userId', label: '用户UID' }, + { value: 'userNickname', label: '用户昵称' }, + { value: 'userMobile', label: '用户电话' } +]) +/** + * 聚合搜索切换查询对象时触发 + * @param val + */ +const inputChangeSelect = (val: string) => { + dynamicSearchList.value + .filter((item) => item.value !== val) + ?.forEach((item1) => { + // 清除集合搜索无用属性 + if (queryParams.value.hasOwnProperty(item1.value)) { + delete queryParams.value[item1.value] + } + }) +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await TradeOrderApi.getOrderPage(unref(queryParams)) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = async () => { + queryParams.value.pageNo = 1 + await getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + queryParams.value = { + pageNo: 1, // 页数 + pageSize: 10, // 每页显示数量 + status: undefined, // 订单状态 + payChannelCode: undefined, // 支付方式 + createTime: undefined, // 创建时间 + terminal: undefined, // 订单来源 + type: undefined, // 订单类型 + deliveryType: undefined, // 配送方式 + logisticsId: undefined, // 快递公司 + pickUpStoreId: undefined, // 自提门店 + pickUpVerifyCode: undefined // 自提核销码 + } + handleQuery() +} + +/** 查看订单详情 */ +const openDetail = (id: number) => { + push({ name: 'TradeOrderDetail', params: { id } }) +} + +/** 操作分发 */ +const deliveryFormRef = ref() +const updateRemarkForm = ref() +const handleCommand = (command: string, row: TradeOrderApi.OrderVO) => { + switch (command) { + case 'remark': + updateRemarkForm.value?.open(row) + break + case 'delivery': + deliveryFormRef.value?.open(row) + break + } +} + +// 监听路由变化更新列表,解决订单保存/更新后,列表不刷新的问题。 +watch( + () => currentRoute.value, + () => { + getList() + } +) + +const pickUpStoreList = ref<PickUpStoreApi.DeliveryPickUpStoreVO[]>([]) // 自提门店精简列表 +const deliveryExpressList = ref<DeliveryExpressApi.DeliveryExpressVO[]>([]) // 物流公司 +/** 初始化 **/ +onMounted(async () => { + await getList() + pickUpStoreList.value = await PickUpStoreApi.getListAllSimple() + deliveryExpressList.value = await DeliveryExpressApi.getSimpleDeliveryExpressList() +}) +</script> diff --git a/src/views/member/config/index.vue b/src/views/member/config/index.vue new file mode 100644 index 0000000..2593509 --- /dev/null +++ b/src/views/member/config/index.vue @@ -0,0 +1,121 @@ +<template> + <doc-alert title="会员手册(功能开启)" url="https://doc.iocoder.cn/member/build/" /> + + <ContentWrap> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label="hideId" v-show="false"> + <el-input v-model="formData.id" /> + </el-form-item> + + <el-tabs> + <el-tab-pane label="积分"> + <el-form-item label="积分抵扣" prop="pointTradeDeductEnable"> + <el-switch v-model="formData.pointTradeDeductEnable" style="user-select: none" /> + <el-text class="w-full" size="small" type="info">下单积分是否抵用订单金额</el-text> + </el-form-item> + <el-form-item label="积分抵扣" prop="pointTradeDeductUnitPrice"> + <el-input-number + v-model="computedPointTradeDeductUnitPrice" + placeholder="请输入积分抵扣金额" + :precision="2" + /> + <el-text class="w-full" size="small" type="info"> + 积分抵用比例(1 积分抵多少金额),单位:元 + </el-text> + </el-form-item> + <el-form-item label="积分抵扣最大值" prop="pointTradeDeductMaxPrice"> + <el-input-number + v-model="formData.pointTradeDeductMaxPrice" + placeholder="请输入积分抵扣最大值" + /> + <el-text class="w-full" size="small" type="info"> + 单次下单积分使用上限,0 不限制 + </el-text> + </el-form-item> + <el-form-item label="1 元赠送多少分" prop="pointTradeGivePoint"> + <el-input-number + v-model="formData.pointTradeGivePoint" + placeholder="请输入 1 元赠送多少积分" + /> + <el-text class="w-full" size="small" type="info"> + 下单支付金额按比例赠送积分(实际支付 1 元赠送多少积分) + </el-text> + </el-form-item> + </el-tab-pane> + </el-tabs> + + <el-form-item> + <el-button type="primary" @click="onSubmit">保存</el-button> + </el-form-item> + </el-form> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as ConfigApi from '@/api/member/config' + +defineOptions({ name: 'MemberConfig' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, + pointTradeDeductEnable: true, + pointTradeDeductUnitPrice: 0, + pointTradeDeductMaxPrice: 0, + pointTradeGivePoint: 0 +}) + +// 创建一个计算属性,用于将 pointTradeDeductUnitPrice 显示为带两位小数的形式 +const computedPointTradeDeductUnitPrice = computed({ + get: () => (formData.value.pointTradeDeductUnitPrice / 100).toFixed(2), + set: (newValue: number) => { + formData.value.pointTradeDeductUnitPrice = Math.round(newValue * 100) + } +}) + +const formRules = reactive({}) +const formRef = ref() // 表单 Ref + +/** 修改积分配置 */ +const onSubmit = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ConfigApi.ConfigVO + await ConfigApi.saveConfig(data) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + } finally { + formLoading.value = false + } +} + +/** 获得积分配置 */ +const getConfig = async () => { + try { + const data = await ConfigApi.getConfig() + if (data === null) { + return + } + formData.value = data + } finally { + } +} + +onMounted(() => { + getConfig() +}) +</script> diff --git a/src/views/member/group/GroupForm.vue b/src/views/member/group/GroupForm.vue new file mode 100644 index 0000000..14510b0 --- /dev/null +++ b/src/views/member/group/GroupForm.vue @@ -0,0 +1,112 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="600"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" type="textarea" placeholder="请输入备注" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as GroupApi from '@/api/member/group' +import { CommonStatusEnum } from '@/utils/constants' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + remark: undefined, + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive({ + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await GroupApi.getGroup(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as GroupApi.GroupVO + if (formType.value === 'create') { + await GroupApi.createGroup(data) + message.success(t('common.createSuccess')) + } else { + await GroupApi.updateGroup(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + remark: undefined, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/member/group/components/MemberGroupSelect.vue b/src/views/member/group/components/MemberGroupSelect.vue new file mode 100644 index 0000000..78a993a --- /dev/null +++ b/src/views/member/group/components/MemberGroupSelect.vue @@ -0,0 +1,45 @@ +<template> + <el-select v-model="groupId" placeholder="请选择用户分组" clearable class="!w-240px"> + <el-option + v-for="group in groupOptions" + :key="group.id" + :label="group.name" + :value="group.id" + /> + </el-select> +</template> +<script lang="ts" setup> +import * as GroupApi from '@/api/member/group' + +/** 会员分组选择框 **/ +defineOptions({ name: 'MemberGroupSelect' }) + +const props = defineProps({ + /** 下拉框选中值 **/ + modelValue: { + type: Number, + default: undefined + } +}) +const emit = defineEmits(['update:modelValue']) + +const groupId = computed({ + get() { + return props.modelValue + }, + set(value: any) { + emit('update:modelValue', value) + } +}) + +const groupOptions = ref<GroupApi.GroupVO[]>([]) + +const getList = async () => { + groupOptions.value = await GroupApi.getSimpleGroupList() +} + +/** 初始化 */ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/member/group/index.vue b/src/views/member/group/index.vue new file mode 100644 index 0000000..ba925d6 --- /dev/null +++ b/src/views/member/group/index.vue @@ -0,0 +1,176 @@ +<template> + <doc-alert title="会员用户、标签、分组" url="https://doc.iocoder.cn/member/user/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="分组名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入分组名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button type="primary" @click="openForm('create')" v-hasPermi="['member:group:create']"> + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" min-width="60" /> + <el-table-column label="名称" align="center" prop="name" min-width="80" /> + <el-table-column label="备注" align="center" prop="remark" min-width="100" /> + <el-table-column label="状态" align="center" prop="status" min-width="70"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + min-width="170" + /> + <el-table-column label="操作" align="center" width="150px"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['member:group:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['member:group:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <GroupForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as GroupApi from '@/api/member/group' +import GroupForm from './GroupForm.vue' + +/** 用户分组管理 **/ +defineOptions({ name: 'MemberGroup' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + status: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await GroupApi.getGroupPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await GroupApi.deleteGroup(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/member/level/LevelForm.vue b/src/views/member/level/LevelForm.vue new file mode 100644 index 0000000..7e6873c --- /dev/null +++ b/src/views/member/level/LevelForm.vue @@ -0,0 +1,175 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="800"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="110px" + v-loading="formLoading" + > + <el-row> + <el-col :span="12"> + <el-form-item label="等级名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入等级名称" class="!w-240px" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="等级" prop="level"> + <el-input-number + v-model="formData.level" + :min="0" + :precision="0" + placeholder="请输入等级" + class="!w-240px" + /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="升级经验" prop="experience"> + <el-input-number + v-model="formData.experience" + :min="0" + :precision="0" + placeholder="请输入升级经验" + class="!w-240px" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="享受折扣(%)" prop="discountPercent"> + <el-input-number + v-model="formData.discountPercent" + :min="0" + :max="100" + :precision="0" + placeholder="请输入享受折扣" + class="!w-240px" + /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="等级图标"> + <UploadImg v-model="formData.icon" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="背景图"> + <UploadImg v-model="formData.backgroundUrl" /> + </el-form-item> + </el-col> + </el-row> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as LevelApi from '@/api/member/level' +import { CommonStatusEnum } from '@/utils/constants' + +/** 会员等级表单 **/ +defineOptions({ name: 'MemberLevelForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + experience: undefined, + level: undefined, + discountPercent: undefined, + icon: undefined, + backgroundUrl: undefined, + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive({ + name: [{ required: true, message: '等级名称不能为空', trigger: 'blur' }], + experience: [{ required: true, message: '升级经验不能为空', trigger: 'blur' }], + level: [{ required: true, message: '等级不能为空', trigger: 'blur' }], + discountPercent: [{ required: true, message: '享受折扣不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'change' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await LevelApi.getLevel(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as LevelApi.LevelVO + if (formType.value === 'create') { + await LevelApi.createLevel(data) + message.success(t('common.createSuccess')) + } else { + await LevelApi.updateLevel(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + experience: undefined, + level: undefined, + discountPercent: undefined, + icon: undefined, + backgroundUrl: undefined, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/member/level/components/MemberLevelSelect.vue b/src/views/member/level/components/MemberLevelSelect.vue new file mode 100644 index 0000000..2a603e6 --- /dev/null +++ b/src/views/member/level/components/MemberLevelSelect.vue @@ -0,0 +1,45 @@ +<template> + <el-select v-model="levelId" placeholder="请选择用户等级" clearable class="!w-240px"> + <el-option v-for="level in levelOptions" :key="level.id" :label="level.name" :value="level.id"> + <span class="flex items-center gap-x-8px"> + <el-avatar :src="level.icon" size="small" /> + {{ level.name }} + </span> + </el-option> + </el-select> +</template> +<script lang="ts" setup> +import * as LevelApi from '@/api/member/level' + +/** 会员等级选择框 **/ +defineOptions({ name: 'MemberLevelSelect' }) + +const props = defineProps({ + /** 下拉框选中值 **/ + modelValue: { + type: Number, + default: undefined + } +}) +const emit = defineEmits(['update:modelValue']) + +const levelId = computed({ + get() { + return props.modelValue + }, + set(value: any) { + emit('update:modelValue', value) + } +}) + +const levelOptions = ref<LevelApi.LevelVO[]>([]) + +const getList = async () => { + levelOptions.value = await LevelApi.getSimpleLevelList() +} + +/** 初始化 */ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/member/level/index.vue b/src/views/member/level/index.vue new file mode 100644 index 0000000..3743eac --- /dev/null +++ b/src/views/member/level/index.vue @@ -0,0 +1,171 @@ +<template> + <doc-alert title="会员等级、积分、签到" url="https://doc.iocoder.cn/member/level/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="等级名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入等级名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button type="primary" @click="openForm('create')" v-hasPermi="['member:level:create']"> + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" min-width="60" /> + <el-table-column label="等级图标" align="center" prop="icon" min-width="80"> + <template #default="scope"> + <el-image + :src="scope.row.icon" + class="h-30px w-30px" + :preview-src-list="[scope.row.icon]" + /> + </template> + </el-table-column> + <el-table-column label="等级背景图" align="center" prop="backgroundUrl" min-width="100"> + <template #default="scope"> + <el-image + :src="scope.row.backgroundUrl" + class="h-30px w-30px" + :preview-src-list="[scope.row.backgroundUrl]" + /> + </template> + </el-table-column> + <el-table-column label="等级名称" align="center" prop="name" min-width="100" /> + <el-table-column label="等级" align="center" prop="level" min-width="60" /> + <el-table-column label="升级经验" align="center" prop="experience" min-width="80" /> + <el-table-column label="享受折扣(%)" align="center" prop="discountPercent" min-width="110" /> + <el-table-column label="状态" align="center" prop="status" min-width="70"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + min-width="170" + /> + <el-table-column label="操作" align="center" min-width="110px" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['member:level:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['member:level:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <LevelForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as LevelApi from '@/api/member/level' +import LevelForm from './LevelForm.vue' + +/** 会员等级管理 **/ +defineOptions({ name: 'MemberLevel' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + name: null, + status: null +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + list.value = await LevelApi.getLevelList(queryParams) + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await LevelApi.deleteLevel(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/member/point/record/index.vue b/src/views/member/point/record/index.vue new file mode 100644 index 0000000..9676c2e --- /dev/null +++ b/src/views/member/point/record/index.vue @@ -0,0 +1,161 @@ +<template> + <doc-alert title="会员等级、积分、签到" url="https://doc.iocoder.cn/member/level/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户" prop="nickname"> + <el-input + v-model="queryParams.nickname" + placeholder="请输入用户昵称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="业务类型" prop="bizType"> + <el-select + v-model="queryParams.bizType" + placeholder="请选择业务类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.MEMBER_POINT_BIZ_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="积分标题" prop="title"> + <el-input + v-model="queryParams.title" + placeholder="请输入积分标题" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="获得时间" prop="createDate"> + <el-date-picker + v-model="queryParams.createDate" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon icon="ep:search" class="mr-5px" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon icon="ep:refresh" class="mr-5px" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" width="180" /> + <el-table-column + label="获得时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column label="用户" align="center" prop="nickname" width="200" /> + <el-table-column label="获得积分" align="center" prop="point" width="100"> + <template #default="scope"> + <el-tag v-if="scope.row.point > 0" class="ml-2" type="success" effect="dark"> + +{{ scope.row.point }} + </el-tag> + <el-tag v-else class="ml-2" type="danger" effect="dark"> {{ scope.row.point }} </el-tag> + </template> + </el-table-column> + <el-table-column label="总积分" align="center" prop="totalPoint" width="100" /> + <el-table-column label="标题" align="center" prop="title" /> + <el-table-column label="描述" align="center" prop="description" /> + <el-table-column label="业务编码" align="center" prop="bizId" /> + <el-table-column label="业务类型" align="center" prop="bizType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.MEMBER_POINT_BIZ_TYPE" :value="scope.row.bizType" /> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <RecordForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as RecordApi from '@/api/member/point/record' + +defineOptions({ name: 'PointRecord' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + nickname: null, + bizType: null, + title: null, + createDate: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await RecordApi.getRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/member/signin/config/SignInConfigForm.vue b/src/views/member/signin/config/SignInConfigForm.vue new file mode 100644 index 0000000..616fd8f --- /dev/null +++ b/src/views/member/signin/config/SignInConfigForm.vue @@ -0,0 +1,132 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="签到天数" prop="day"> + <el-input-number v-model="formData.day" :min="1" :max="7" :precision="0" /> + <el-text class="mx-1" style="margin-left: 10px" type="danger"> + 只允许设置 1-7,默认签到 7 天为一个周期 + </el-text> + </el-form-item> + <el-form-item label="奖励积分" prop="point"> + <el-input-number v-model="formData.point" :min="0" :precision="0" /> + </el-form-item> + <el-form-item label="奖励经验" prop="experience"> + <el-input-number v-model="formData.experience" :min="0" :precision="0" /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as SignInConfigApi from '@/api/member/signin/config' +import { CommonStatusEnum } from '@/utils/constants' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref<SignInConfigApi.SignInConfigVO>({} as SignInConfigApi.SignInConfigVO) +// 奖励校验规则 +const awardValidator = (rule: any, _value: any, callback: any) => { + if (!formData.value.point && !formData.value.experience) { + callback(new Error('奖励积分与奖励经验至少配置一个')) + return + } + + // 清除另一个字段的错误提示 + const otherAwardField = rule?.field === 'point' ? 'experience' : 'point' + formRef.value.validateField(otherAwardField, () => null) + callback() +} +const formRules = reactive({ + day: [{ required: true, message: '签到天数不能空', trigger: 'blur' }], + point: [ + { required: true, message: '奖励积分不能空', trigger: 'blur' }, + { validator: awardValidator, trigger: 'blur' } + ], + experience: [ + { required: true, message: '奖励经验不能空', trigger: 'blur' }, + { validator: awardValidator, trigger: 'blur' } + ] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await SignInConfigApi.getSignInConfig(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + if (formType.value === 'create') { + await SignInConfigApi.createSignInConfig(formData.value) + message.success(t('common.createSuccess')) + } else { + await SignInConfigApi.updateSignInConfig(formData.value) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + day: undefined, + point: 0, + experience: 0, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/member/signin/config/index.vue b/src/views/member/signin/config/index.vue new file mode 100644 index 0000000..14a84cd --- /dev/null +++ b/src/views/member/signin/config/index.vue @@ -0,0 +1,106 @@ +<template> + <doc-alert title="会员等级、积分、签到" url="https://doc.iocoder.cn/member/level/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['point:sign-in-config:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column + label="签到天数" + align="center" + prop="day" + :formatter="(_, __, cellValue) => ['第', cellValue, '天'].join(' ')" + /> + <el-table-column label="奖励积分" align="center" prop="point" /> + <el-table-column label="奖励经验" align="center" prop="experience" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['point:sign-in-config:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['point:sign-in-config:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SignInConfigForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import * as SignInConfigApi from '@/api/member/signin/config' +import SignInConfigForm from './SignInConfigForm.vue' +import { DICT_TYPE } from '@/utils/dict' + +defineOptions({ name: 'SignInConfig' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref([]) // 列表的数据 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SignInConfigApi.getSignInConfigList() + console.log(data) + list.value = data + } finally { + loading.value = false + } +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await SignInConfigApi.deleteSignInConfig(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/member/signin/record/index.vue b/src/views/member/signin/record/index.vue new file mode 100644 index 0000000..e80e854 --- /dev/null +++ b/src/views/member/signin/record/index.vue @@ -0,0 +1,134 @@ +<template> + <doc-alert title="会员等级、积分、签到" url="https://doc.iocoder.cn/member/level/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="签到用户" prop="nickname"> + <el-input + v-model="queryParams.nickname" + placeholder="请输入签到用户" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="签到天数" prop="day"> + <el-input + v-model="queryParams.day" + placeholder="请输入签到天数" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="签到时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="签到用户" align="center" prop="nickname" /> + <el-table-column + label="签到天数" + align="center" + prop="day" + :formatter="(_, __, cellValue) => ['第', cellValue, '天'].join(' ')" + /> + <el-table-column label="获得积分" align="center" prop="point" width="100"> + <template #default="scope"> + <el-tag v-if="scope.row.point > 0" class="ml-2" type="success" effect="dark"> + +{{ scope.row.point }} + </el-tag> + <el-tag v-else class="ml-2" type="danger" effect="dark"> {{ scope.row.point }} </el-tag> + </template> + </el-table-column> + <el-table-column + label="签到时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as SignInRecordApi from '@/api/member/signin/record' + +defineOptions({ name: 'SignInRecord' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + nickname: null, + day: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SignInRecordApi.getSignInRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/member/tag/TagForm.vue b/src/views/member/tag/TagForm.vue new file mode 100644 index 0000000..d45ea58 --- /dev/null +++ b/src/views/member/tag/TagForm.vue @@ -0,0 +1,91 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="标签名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入标签名称" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as TagApi from '@/api/member/tag' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '标签名称不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await TagApi.getMemberTag(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as TagApi.TagVO + if (formType.value === 'create') { + await TagApi.createMemberTag(data) + message.success(t('common.createSuccess')) + } else { + await TagApi.updateMemberTag(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/member/tag/components/MemberTagSelect.vue b/src/views/member/tag/components/MemberTagSelect.vue new file mode 100644 index 0000000..ebff61e --- /dev/null +++ b/src/views/member/tag/components/MemberTagSelect.vue @@ -0,0 +1,68 @@ +<template> + <el-select v-model="tagIds" placeholder="请选择用户标签" clearable multiple class="!w-240px"> + <el-option v-for="tag in tags" :key="tag.id" :label="tag.name" :value="tag.id" /> + </el-select> + <el-button + v-if="showAdd" + type="primary" + class="ml-2" + link + @click="openForm('create')" + v-hasPermi="['member:tag:create']" + > + 新增标签 + </el-button> + + <!-- 表单弹窗:添加 --> + <TagForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import * as TagApi from '@/api/member/tag' +import TagForm from '@/views/member/tag/TagForm.vue' + +defineOptions({ name: 'MemberTagSelect' }) + +const props = defineProps({ + /** 下拉框选中值 **/ + modelValue: { + type: Array, + default: undefined + }, + /** 是否显示“新增标签”按钮 **/ + showAdd: { + type: Boolean, + default: false + } +}) +const emit = defineEmits(['update:modelValue']) +defineExpose({ + showAdd: props.showAdd +}) + +const tagIds = computed({ + get() { + return props.modelValue + }, + set(value: any) { + emit('update:modelValue', value) + } +}) + +const tags = ref<TagApi.TagVO[]>([]) + +const getList = async () => { + tags.value = await TagApi.getSimpleTagList() +} + +/** 添加用户标签表单弹框 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 初始化 */ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/member/tag/index.vue b/src/views/member/tag/index.vue new file mode 100644 index 0000000..59efc5e --- /dev/null +++ b/src/views/member/tag/index.vue @@ -0,0 +1,155 @@ +<template> + <doc-alert title="会员用户、标签、分组" url="https://doc.iocoder.cn/member/user/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="标签名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入标签名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button type="primary" @click="openForm('create')" v-hasPermi="['member:tag:create']"> + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" width="150px" /> + <el-table-column label="标签名称" align="center" prop="name" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="150px"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['member:tag:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['member:tag:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <TagForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts" name="MemberTag"> +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as TagApi from '@/api/member/tag' +import TagForm from './TagForm.vue' +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await TagApi.getMemberTagPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await TagApi.deleteMemberTag(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/member/user/UserForm.vue b/src/views/member/user/UserForm.vue new file mode 100644 index 0000000..0da4ef6 --- /dev/null +++ b/src/views/member/user/UserForm.vue @@ -0,0 +1,179 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="手机号" prop="mobile"> + <el-input v-model="formData.mobile" placeholder="请输入手机号" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="用户昵称" prop="nickname"> + <el-input v-model="formData.nickname" placeholder="请输入用户昵称" /> + </el-form-item> + <el-form-item label="头像" prop="avatar"> + <UploadImg v-model="formData.avatar" :limit="1" :is-show-tip="false" /> + </el-form-item> + <el-form-item label="真实名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入真实名字" /> + </el-form-item> + <el-form-item label="用户性别" prop="sex"> + <el-radio-group v-model="formData.sex"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="出生日期" prop="birthday"> + <el-date-picker + v-model="formData.birthday" + type="date" + value-format="x" + placeholder="选择出生日期" + /> + </el-form-item> + <el-form-item label="所在地" prop="areaId"> + <el-tree-select + v-model="formData.areaId" + :data="areaList" + :props="defaultProps" + :render-after-expand="true" + /> + </el-form-item> + <el-form-item label="用户标签" prop="tagIds"> + <MemberTagSelect v-model="formData.tagIds" show-add /> + </el-form-item> + <el-form-item label="用户分组" prop="groupId"> + <MemberGroupSelect v-model="formData.groupId" /> + </el-form-item> + <el-form-item label="会员备注" prop="mark"> + <el-input type="textarea" v-model="formData.mark" placeholder="请输入会员备注" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as UserApi from '@/api/member/user' +import * as AreaApi from '@/api/system/area' +import { defaultProps } from '@/utils/tree' +import MemberTagSelect from '@/views/member/tag/components/MemberTagSelect.vue' +import MemberGroupSelect from '@/views/member/group/components/MemberGroupSelect.vue' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + mobile: undefined, + password: undefined, + status: undefined, + nickname: undefined, + avatar: undefined, + name: undefined, + sex: undefined, + areaId: undefined, + birthday: undefined, + mark: undefined, + tagIds: [], + groupId: undefined +}) +const formRules = reactive({ + mobile: [{ required: true, message: '手机号不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const areaList = ref([]) // 地区列表 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await UserApi.getUser(id) + } finally { + formLoading.value = false + } + } + // 获得地区列表 + areaList.value = await AreaApi.getAreaTree() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as UserApi.UserVO + if (formType.value === 'create') { + // 说明:目前暂时没有新增操作。如果自己业务需要,可以进行扩展 + // await UserApi.createUser(data) + message.success(t('common.createSuccess')) + } else { + await UserApi.updateUser(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + mobile: undefined, + password: undefined, + status: undefined, + nickname: undefined, + avatar: undefined, + name: undefined, + sex: undefined, + areaId: undefined, + birthday: undefined, + mark: undefined, + tagIds: [], + groupId: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/member/user/UserLevelUpdateForm.vue b/src/views/member/user/UserLevelUpdateForm.vue new file mode 100644 index 0000000..e583f4a --- /dev/null +++ b/src/views/member/user/UserLevelUpdateForm.vue @@ -0,0 +1,101 @@ +<template> + <Dialog title="修改用户等级" v-model="dialogVisible" width="600"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="用户编号" prop="id"> + <el-input v-model="formData.id" placeholder="请输入用户昵称" class="!w-240px" disabled /> + </el-form-item> + <el-form-item label="用户昵称" prop="nickname"> + <el-input + v-model="formData.nickname" + placeholder="请输入用户昵称" + class="!w-240px" + disabled + /> + </el-form-item> + <el-form-item label="用户等级" prop="levelId"> + <MemberLevelSelect v-model="formData.levelId" /> + </el-form-item> + <el-form-item label="修改原因" prop="reason"> + <el-input type="textarea" v-model="formData.reason" placeholder="请输入修改原因" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as UserApi from '@/api/member/user' +import MemberLevelSelect from '@/views/member/level/components/MemberLevelSelect.vue' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, + nickname: undefined, + levelId: undefined, + reason: undefined +}) +const formRules = reactive({ + reason: [{ required: true, message: '修改原因不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (id?: number) => { + dialogVisible.value = true + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await UserApi.getUser(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await UserApi.updateUserLevel(formData.value) + + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + nickname: undefined, + levelId: undefined, + reason: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/member/user/UserPointUpdateForm.vue b/src/views/member/user/UserPointUpdateForm.vue new file mode 100644 index 0000000..967ebe0 --- /dev/null +++ b/src/views/member/user/UserPointUpdateForm.vue @@ -0,0 +1,128 @@ +<template> + <Dialog title="修改用户积分" v-model="dialogVisible" width="600"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="用户编号" prop="id"> + <el-input v-model="formData.id" class="!w-240px" disabled /> + </el-form-item> + <el-form-item label="用户昵称" prop="nickname"> + <el-input v-model="formData.nickname" class="!w-240px" disabled /> + </el-form-item> + <el-form-item label="变动前积分" prop="point"> + <el-input-number v-model="formData.point" class="!w-240px" disabled /> + </el-form-item> + <el-form-item label="变动类型" prop="changeType"> + <el-radio-group v-model="formData.changeType"> + <el-radio :label="1">增加</el-radio> + <el-radio :label="-1">减少</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="变动积分" prop="changePoint"> + <el-input-number v-model="formData.changePoint" class="!w-240px" :min="0" :precision="0" /> + </el-form-item> + <el-form-item label="变动后积分"> + <el-input-number v-model="pointResult" class="!w-240px" disabled /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as UserApi from '@/api/member/user' + +/** 修改用户积分表单 */ +defineOptions({ name: 'UpdatePointForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, + nickname: undefined, + point: 0, + changePoint: 0, + changeType: 1 +}) +const formRules = reactive({ + changePoint: [{ required: true, message: '变动积分不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (id?: number) => { + dialogVisible.value = true + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await UserApi.getUser(id) + formData.value.changeType = 1 // 默认增加积分 + formData.value.changePoint = 0 // 变动积分默认0 + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + + if (formData.value.changePoint < 1) { + message.error('变动积分不能小于 1') + return + } + if (pointResult.value < 0) { + message.error('变动后的积分不能小于 0') + return + } + + // 提交请求 + formLoading.value = true + try { + await UserApi.updateUserPoint({ + id: formData.value.id, + point: formData.value.changePoint * formData.value.changeType + }) + + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + nickname: undefined, + levelId: undefined, + reason: undefined + } + formRef.value?.resetFields() +} + +/** 变动后的积分 */ +const pointResult = computed( + () => formData.value.point + formData.value.changePoint * formData.value.changeType +) +</script> diff --git a/src/views/member/user/components/balance-list.vue b/src/views/member/user/components/balance-list.vue new file mode 100644 index 0000000..3e9d178 --- /dev/null +++ b/src/views/member/user/components/balance-list.vue @@ -0,0 +1,14 @@ +<script lang="ts"> +import { defineComponent } from 'vue' + +export default defineComponent({ + name: 'BalanceList' +}) +</script> + +<!-- TODO @芋艿:未来实现,等周建的 --> +<template> + <div>余额列表</div> +</template> + +<style scoped lang="scss"></style> diff --git a/src/views/member/user/detail/UserAccountInfo.vue b/src/views/member/user/detail/UserAccountInfo.vue new file mode 100644 index 0000000..56a6ab6 --- /dev/null +++ b/src/views/member/user/detail/UserAccountInfo.vue @@ -0,0 +1,87 @@ +<template> + <el-descriptions :column="2"> + <el-descriptions-item> + <template #label> + <descriptions-item-label label=" 等级 " icon="svg-icon:member_level" /> + </template> + {{ user.levelName || '无' }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label=" 成长值 " icon="ep:suitcase" /> + </template> + {{ user.experience || 0 }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label=" 当前积分 " icon="ep:coin" /> + </template> + {{ user.point || 0 }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label=" 总积分 " icon="ep:coin" /> + </template> + {{ user.totalPoint || 0 }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label=" 当前余额 " icon="svg-icon:member_balance" /> + </template> + {{ fenToYuan(wallet.balance || 0) }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label=" 支出金额 " icon="svg-icon:member_expenditure_balance" /> + </template> + {{ fenToYuan(wallet.totalExpense || 0) }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label=" 充值金额 " icon="svg-icon:member_recharge_balance" /> + </template> + {{ fenToYuan(wallet.totalRecharge || 0) }} + </el-descriptions-item> + </el-descriptions> +</template> +<script setup lang="ts"> +import { DescriptionsItemLabel } from '@/components/Descriptions' +import * as UserApi from '@/api/member/user' +import * as WalletApi from '@/api/pay/wallet/balance' +import { UserTypeEnum } from '@/utils/constants' +import { fenToYuan } from '@/utils' + +const props = defineProps<{ user: UserApi.UserVO }>() // 用户信息 +const WALLET_INIT_DATA = { + balance: 0, + totalExpense: 0, + totalRecharge: 0 +} as WalletApi.WalletVO // 钱包初始化数据 +const wallet = ref<WalletApi.WalletVO>(WALLET_INIT_DATA) // 钱包信息 + +/** 查询用户钱包信息 */ +const getUserWallet = async () => { + if (!props.user.id) { + wallet.value = WALLET_INIT_DATA + return + } + const params = { userId: props.user.id } + wallet.value = (await WalletApi.getWallet(params)) || WALLET_INIT_DATA +} + +/** 监听用户编号变化 */ +watch( + () => props.user.id, + () => getUserWallet(), + { immediate: true } +) +</script> +<style scoped lang="scss"> +.cell-item { + display: inline; +} + +.cell-item::after { + content: ':'; +} +</style> diff --git a/src/views/member/user/detail/UserAddressList.vue b/src/views/member/user/detail/UserAddressList.vue new file mode 100644 index 0000000..a37caba --- /dev/null +++ b/src/views/member/user/detail/UserAddressList.vue @@ -0,0 +1,54 @@ +<template> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="地址编号" align="center" prop="id" width="150px" /> + <el-table-column label="收件人名称" align="center" prop="name" width="150px" /> + <el-table-column label="手机号" align="center" prop="mobile" width="150px" /> + <el-table-column label="地区编码" align="center" prop="areaId" width="150px" /> + <el-table-column label="收件详细地址" align="center" prop="detailAddress" /> + <el-table-column label="是否默认" align="center" prop="defaultStatus" width="150px"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="Number(scope.row.defaultStatus)" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + </el-table> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as AddressApi from '@/api/member/address' + +const { userId }: { userId: number } = defineProps({ + userId: { + type: Number, + required: true + } +}) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + list.value = await AddressApi.getAddressList({ userId }) + } finally { + loading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> + +<style scoped lang="scss"></style> diff --git a/src/views/member/user/detail/UserBasicInfo.vue b/src/views/member/user/detail/UserBasicInfo.vue new file mode 100644 index 0000000..075450e --- /dev/null +++ b/src/views/member/user/detail/UserBasicInfo.vue @@ -0,0 +1,85 @@ +<template> + <el-card shadow="never"> + <template #header> + <slot name="header"></slot> + </template> + <el-row> + <el-col :span="4"> + <ElAvatar shape="square" :size="140" :src="user.avatar || undefined" /> + </el-col> + <el-col :span="20"> + <el-descriptions :column="2"> + <el-descriptions-item> + <template #label> + <descriptions-item-label label="用户名" icon="ep:user" /> + </template> + {{ user.name || '空' }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label="昵称" icon="ep:user" /> + </template> + {{ user.nickname }} + </el-descriptions-item> + <el-descriptions-item label="手机号"> + <template #label> + <descriptions-item-label label="手机号" icon="ep:phone" /> + </template> + {{ user.mobile }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label="性别" icon="fa:mars-double" /> + </template> + <dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="user.sex" /> + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label="所在地" icon="ep:location" /> + </template> + {{ user.areaName }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label="注册 IP" icon="ep:position" /> + </template> + {{ user.registerIp }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label="生日" icon="fa:birthday-cake" /> + </template> + {{ user.birthday ? formatDate(user.birthday) : '空' }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label="注册时间" icon="ep:calendar" /> + </template> + {{ user.createTime ? formatDate(user.createTime) : '空' }} + </el-descriptions-item> + <el-descriptions-item> + <template #label> + <descriptions-item-label label="最后登录时间" icon="ep:calendar" /> + </template> + {{ user.loginDate ? formatDate(user.loginDate) : '空' }} + </el-descriptions-item> + </el-descriptions> + </el-col> + </el-row> + </el-card> +</template> +<script setup lang="ts"> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import * as UserApi from '@/api/member/user' +import { DescriptionsItemLabel } from '@/components/Descriptions/index' + +const { user } = defineProps<{ user: UserApi.UserVO }>() +</script> +<style scoped lang="scss"> +.card-header { + display: flex; + justify-content: space-between; + align-items: center; +} +</style> diff --git a/src/views/member/user/detail/UserBrokerageList.vue b/src/views/member/user/detail/UserBrokerageList.vue new file mode 100644 index 0000000..db88787 --- /dev/null +++ b/src/views/member/user/detail/UserBrokerageList.vue @@ -0,0 +1,125 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="85px" + > + <el-form-item label="用户类型" prop="level"> + <el-radio-group v-model="queryParams.level" @change="handleQuery"> + <el-radio-button checked>全部</el-radio-button> + <el-radio-button label="1">一级推广人</el-radio-button> + <el-radio-button label="2">二级推广人</el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item label="绑定时间" prop="bindUserTime"> + <el-date-picker + v-model="queryParams.bindUserTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="用户编号" align="center" prop="id" min-width="80px" /> + <el-table-column label="头像" align="center" prop="avatar" width="70px"> + <template #default="scope"> + <el-avatar :src="scope.row.avatar" /> + </template> + </el-table-column> + <el-table-column label="昵称" align="center" prop="nickname" min-width="80px" /> + <el-table-column label="等级" align="center" prop="level" min-width="80px"> + <template #default="scope"> + <el-tag v-if="scope.row.bindUserId === bindUserId">一级</el-tag> + <el-tag v-else>二级</el-tag> + </template> + </el-table-column> + <el-table-column + label="绑定时间" + align="center" + prop="bindUserTime" + :formatter="dateFormatter" + width="170px" + /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as BrokerageUserApi from '@/api/mall/trade/brokerage/user' + +/** 推广人列表 */ +defineOptions({ name: 'UserBrokerageList' }) + +const { bindUserId }: { bindUserId: number } = defineProps({ + bindUserId: { + type: Number, + required: true + } +}) //用户编号 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + bindUserId: null, + level: '', + bindUserTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + queryParams.bindUserId = bindUserId + const data = await BrokerageUserApi.getBrokerageUserPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + handleQuery() +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/member/user/detail/UserCouponList.vue b/src/views/member/user/detail/UserCouponList.vue new file mode 100644 index 0000000..2279b8a --- /dev/null +++ b/src/views/member/user/detail/UserCouponList.vue @@ -0,0 +1,190 @@ +<template> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> <Icon icon="ep:search" class="mr-5px" />搜索 </el-button> + <el-button @click="resetQuery"> <Icon icon="ep:refresh" class="mr-5px" />重置 </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <!-- Tab 选项:真正的内容在 Lab --> + <el-tabs v-model="activeTab" type="card" @tab-change="onTabChange"> + <el-tab-pane + v-for="tab in statusTabs" + :key="tab.value" + :label="tab.label" + :name="tab.value" + /> + </el-tabs> + + <!-- 列表 --> + <el-table v-loading="loading" :data="list"> + <el-table-column label="优惠劵" align="center" prop="name" /> + <el-table-column label="优惠券类型" align="center" prop="discountType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE" :value="scope.row.discountType" /> + </template> + </el-table-column> + <el-table-column label="领取方式" align="center" prop="takeType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_TAKE_TYPE" :value="scope.row.takeType" /> + </template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="领取时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column + label="使用时间" + align="center" + prop="useTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> + <template #default="scope"> + <el-button + v-hasPermi="['promotion:coupon:delete']" + type="danger" + link + @click="handleDelete(scope.row.id)" + > + 回收 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts" name="UserCouponList"> +import { deleteCoupon, getCouponPage } from '@/api/mall/promotion/coupon/coupon' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' + +defineOptions({ name: 'UserCouponList' }) + +const { userId }: { userId: number } = defineProps({ + userId: { + type: Number, + required: true + } +}) //用户编号 + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 字典表格数据 +// 查询参数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + createTime: [], + status: undefined, + userIds: undefined +}) +const queryFormRef = ref() // 搜索的表单 + +const activeTab = ref('all') // Tab 筛选 +const statusTabs = reactive([ + { + label: '全部', + value: 'all' + } +]) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + // 执行查询 + try { + queryParams.userIds = userId + const data = await getCouponPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + handleQuery() +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 二次确认 + await message.confirm( + '回收将会收回会员领取的待使用的优惠券,已使用的将无法回收,确定要回收所选优惠券吗?' + ) + // 发起删除 + await deleteCoupon(id) + message.notifySuccess('回收成功') + // 重新加载列表 + await getList() + } catch {} +} + +/** tab 切换 */ +const onTabChange = (tabName) => { + queryParams.status = tabName === 'all' ? undefined : tabName + getList() +} + +/** 初始化 **/ +onMounted(() => { + getList() + // 设置 statuses 过滤 + for (const dict of getIntDictOptions(DICT_TYPE.PROMOTION_COUPON_STATUS)) { + statusTabs.push({ + label: dict.label, + value: dict.value as string + }) + } +}) +</script> diff --git a/src/views/member/user/detail/UserExperienceRecordList.vue b/src/views/member/user/detail/UserExperienceRecordList.vue new file mode 100644 index 0000000..64414ad --- /dev/null +++ b/src/views/member/user/detail/UserExperienceRecordList.vue @@ -0,0 +1,158 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="业务类型" prop="bizType"> + <el-select + v-model="queryParams.bizType" + placeholder="请选择业务类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.MEMBER_EXPERIENCE_BIZ_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="标题" prop="title"> + <el-input + v-model="queryParams.title" + placeholder="请输入标题" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" width="150px" /> + <el-table-column + label="获得时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + /> + <el-table-column label="经验" align="center" prop="experience" width="150px"> + <template #default="scope"> + <el-tag v-if="scope.row.experience > 0" class="ml-2" type="success" effect="dark"> + +{{ scope.row.experience }} + </el-tag> + <el-tag v-else class="ml-2" type="danger" effect="dark"> + {{ scope.row.experience }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="总经验" align="center" prop="totalExperience" width="150px"> + <template #default="scope"> + <el-tag class="ml-2" effect="dark"> + {{ scope.row.totalExperience }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="标题" align="center" prop="title" width="150px" /> + <el-table-column label="描述" align="center" prop="description" /> + <el-table-column label="业务编号" align="center" prop="bizId" width="150px" /> + <el-table-column label="业务类型" align="center" prop="bizType" width="150px"> + <!-- TODO 芋艿:此处应创建对应的字典 --> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.MEMBER_EXPERIENCE_BIZ_TYPE" :value="scope.row.bizType" /> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as ExperienceRecordApi from '@/api/member/experience-record/index' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' + +defineOptions({ name: 'UserExperienceRecordList' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: null, + bizId: null, + bizType: null, + title: null, + description: null, + experience: null, + totalExperience: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ExperienceRecordApi.getExperienceRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +const { userId } = defineProps({ + userId: { + type: Number, + required: true + } +}) +/** 初始化 **/ +onMounted(() => { + queryParams.userId = userId + getList() +}) +</script> diff --git a/src/views/member/user/detail/UserFavoriteList.vue b/src/views/member/user/detail/UserFavoriteList.vue new file mode 100644 index 0000000..afab9a0 --- /dev/null +++ b/src/views/member/user/detail/UserFavoriteList.vue @@ -0,0 +1,96 @@ +<template> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column key="id" align="center" label="商品编号" width="180" prop="id" /> + <el-table-column label="商品图" min-width="80"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" /> + <el-table-column align="center" label="商品售价" min-width="90" prop="price"> + <template #default="{ row }"> {{ floatToFixed2(row.price) }}元</template> + </el-table-column> + <el-table-column align="center" label="销量" min-width="90" prop="salesCount" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="收藏时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" label="状态" min-width="80"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PRODUCT_SPU_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as FavoriteApi from '@/api/mall/product/favorite' +import { floatToFixed2 } from '@/utils' + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + createTime: [], + userId: NaN +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await FavoriteApi.getFavoritePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +const { userId } = defineProps({ + userId: { + type: Number, + required: true + } +}) + +/** 初始化 **/ +onMounted(() => { + queryParams.userId = userId + getList() +}) +</script> diff --git a/src/views/member/user/detail/UserOrderList.vue b/src/views/member/user/detail/UserOrderList.vue new file mode 100644 index 0000000..bae0bf0 --- /dev/null +++ b/src/views/member/user/detail/UserOrderList.vue @@ -0,0 +1,279 @@ +<template> + <!-- 搜索 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="订单状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-280px" clearable placeholder="全部"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_ORDER_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="支付方式" prop="payChannelCode"> + <el-select + v-model="queryParams.payChannelCode" + class="!w-280px" + clearable + placeholder="全部" + > + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.PAY_CHANNEL_CODE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-280px" + end-placeholder="自定义时间" + start-placeholder="自定义时间" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item label="订单来源" prop="terminal"> + <el-select v-model="queryParams.terminal" class="!w-280px" clearable placeholder="全部"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.TERMINAL)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="订单类型" prop="type"> + <el-select v-model="queryParams.type" class="!w-280px" clearable placeholder="全部"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_ORDER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="配送方式" prop="deliveryType"> + <el-select v-model="queryParams.deliveryType" class="!w-280px" clearable placeholder="全部"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_DELIVERY_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="queryParams.deliveryType === DeliveryTypeEnum.EXPRESS.type" + label="快递公司" + prop="logisticsId" + > + <el-select v-model="queryParams.logisticsId" class="!w-280px" clearable placeholder="全部"> + <el-option + v-for="item in deliveryExpressList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="queryParams.deliveryType === DeliveryTypeEnum.PICK_UP.type" + label="自提门店" + prop="pickUpStoreId" + > + <el-select + v-model="queryParams.pickUpStoreId" + class="!w-280px" + clearable + multiple + placeholder="全部" + > + <el-option + v-for="item in pickUpStoreList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item + v-if="queryParams.deliveryType === DeliveryTypeEnum.PICK_UP.type" + label="核销码" + prop="pickUpVerifyCode" + > + <el-input + v-model="queryParams.pickUpVerifyCode" + class="!w-280px" + clearable + placeholder="请输入自提核销码" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="聚合搜索"> + <el-input + v-show="true" + v-model="queryParams[queryType.queryParam]" + class="!w-280px" + clearable + placeholder="请输入" + > + <template #prepend> + <el-select + v-model="queryType.queryParam" + class="!w-110px" + clearable + placeholder="全部" + @change="inputChangeSelect" + > + <el-option + v-for="dict in dynamicSearchList" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </template> + </el-input> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <!-- 添加 row-key="id" 解决列数据中的 table#header 数据不刷新的问题 --> + <el-table v-loading="loading" :data="list" row-key="id"> + <OrderTableColumn :list="list" :pick-up-store-list="pickUpStoreList"> + <template #default="{ row }"> + <el-button link type="primary" @click="openDetail(row.id)"> + <Icon icon="ep:notification" /> + 详情 + </el-button> + </template> + </OrderTableColumn> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> +</template> +<script lang="ts" setup> +import * as OrderApi from '@/api/mall/trade/order/index' +import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict' +import * as PickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore' +import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express' +import { FormInstance } from 'element-plus' +import { OrderTableColumn } from '@/views/mall/trade/order/components' +import { DeliveryTypeEnum } from '@/utils/constants' + +const { push } = useRouter() // 路由跳转 + +const { userId } = defineProps<{ + userId: number +}>() + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const pickUpStoreList = ref<PickUpStoreApi.DeliveryPickUpStoreVO[]>([]) // 自提门店精简列表 +const deliveryExpressList = ref<DeliveryExpressApi.DeliveryExpressVO[]>([]) // 物流公司 +const queryFormRef = ref<FormInstance>() // 搜索的表单 +// 表单搜索 +const queryParams = ref({ + pageNo: 1, // 页数 + pageSize: 10, // 每页显示数量 + userId: userId, + status: undefined, // 订单状态 + payChannelCode: undefined, // 支付方式 + createTime: undefined, // 创建时间 + terminal: undefined, // 订单来源 + type: undefined, // 订单类型 + deliveryType: undefined, // 配送方式 + logisticsId: undefined, // 快递公司 + pickUpStoreId: undefined, // 自提门店 + pickUpVerifyCode: undefined // 自提核销码 +}) +const queryType = reactive({ queryParam: '' }) // 订单搜索类型 queryParam + +// 订单聚合搜索 select 类型配置(动态搜索) +const dynamicSearchList = ref([ + { value: 'no', label: '订单号' }, + { value: 'userNickname', label: '用户昵称' }, + { value: 'userMobile', label: '用户电话' } +]) +/** + * 聚合搜索切换查询对象时触发 + * @param val + */ +const inputChangeSelect = (val: string) => { + dynamicSearchList.value + .filter((item) => item.value !== val) + ?.forEach((item1) => { + // 清除集合搜索无用属性 + if (queryParams.value.hasOwnProperty(item1.value)) { + delete queryParams.value[item1.value] + } + }) +} + +/** 搜索按钮操作 */ +const handleQuery = async () => { + queryParams.value.pageNo = 1 + await getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + queryParams.value.userId = userId + handleQuery() +} +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await OrderApi.getOrderPage(queryParams.value) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 查看订单详情 */ +const openDetail = (id: number) => { + push({ name: 'TradeOrderDetail', params: { id } }) +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + pickUpStoreList.value = await PickUpStoreApi.getListAllSimple() + deliveryExpressList.value = await DeliveryExpressApi.getSimpleDeliveryExpressList() +}) +</script> diff --git a/src/views/member/user/detail/UserPointList.vue b/src/views/member/user/detail/UserPointList.vue new file mode 100644 index 0000000..9754b29 --- /dev/null +++ b/src/views/member/user/detail/UserPointList.vue @@ -0,0 +1,152 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="业务类型" prop="bizType"> + <el-select + v-model="queryParams.bizType" + placeholder="请选择业务类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.MEMBER_POINT_BIZ_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="积分标题" prop="title"> + <el-input + v-model="queryParams.title" + placeholder="请输入积分标题" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="获得时间" prop="createDate"> + <el-date-picker + v-model="queryParams.createDate" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon icon="ep:search" class="mr-5px" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon icon="ep:refresh" class="mr-5px" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" width="180" /> + <el-table-column + label="获得时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column label="获得积分" align="center" prop="point" width="100"> + <template #default="scope"> + <el-tag v-if="scope.row.point > 0" class="ml-2" type="success" effect="dark"> + +{{ scope.row.point }} + </el-tag> + <el-tag v-else class="ml-2" type="danger" effect="dark"> {{ scope.row.point }} </el-tag> + </template> + </el-table-column> + <el-table-column label="总积分" align="center" prop="totalPoint" width="100" /> + <el-table-column label="标题" align="center" prop="title" /> + <el-table-column label="描述" align="center" prop="description" /> + <el-table-column label="业务编码" align="center" prop="bizId" /> + <el-table-column label="业务类型" align="center" prop="bizType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.MEMBER_POINT_BIZ_TYPE" :value="scope.row.bizType" /> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as RecordApi from '@/api//member/point/record' + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + bizType: undefined, + title: null, + createDate: [], + userId: NaN +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await RecordApi.getRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +const { userId } = defineProps({ + userId: { + type: Number, + required: true + } +}) + +/** 初始化 **/ +onMounted(() => { + queryParams.userId = userId + getList() +}) +</script> diff --git a/src/views/member/user/detail/UserSignList.vue b/src/views/member/user/detail/UserSignList.vue new file mode 100644 index 0000000..c897274 --- /dev/null +++ b/src/views/member/user/detail/UserSignList.vue @@ -0,0 +1,135 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="签到用户" prop="nickname"> + <el-input + v-model="queryParams.nickname" + placeholder="请输入签到用户" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="签到天数" prop="day"> + <el-input + v-model="queryParams.day" + placeholder="请输入签到天数" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="签到时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column + label="签到天数" + align="center" + prop="day" + :formatter="(_, __, cellValue) => ['第', cellValue, '天'].join(' ')" + /> + <el-table-column label="获得积分" align="center" prop="point" width="100"> + <template #default="scope"> + <el-tag v-if="scope.row.point > 0" class="ml-2" type="success" effect="dark"> + +{{ scope.row.point }} + </el-tag> + <el-tag v-else class="ml-2" type="danger" effect="dark"> {{ scope.row.point }} </el-tag> + </template> + </el-table-column> + <el-table-column + label="签到时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as SignInRecordApi from '@/api/member/signin/record' + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: NaN, + nickname: null, + day: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SignInRecordApi.getSignInRecordPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +const { userId } = defineProps({ + userId: { + type: Number, + required: true + } +}) + +/** 初始化 **/ +onMounted(() => { + queryParams.userId = userId + getList() +}) +</script> diff --git a/src/views/member/user/detail/index.vue b/src/views/member/user/detail/index.vue new file mode 100644 index 0000000..6237cca --- /dev/null +++ b/src/views/member/user/detail/index.vue @@ -0,0 +1,135 @@ +<template> + <div v-loading="loading"> + <el-row :gutter="10"> + <!-- 左上角:基本信息 --> + <el-col :span="14" class="detail-info-item"> + <UserBasicInfo :user="user"> + <template #header> + <div class="card-header"> + <CardTitle title="基本信息" /> + <el-button type="primary" size="small" text @click="openForm('update')"> + 编辑 + </el-button> + </div> + </template> + </UserBasicInfo> + </el-col> + <!-- 右上角:账户信息 --> + <el-col :span="10" class="detail-info-item"> + <el-card shadow="never" class="h-full"> + <template #header> + <CardTitle title="账户信息" /> + </template> + <UserAccountInfo :user="user" /> + </el-card> + </el-col> + <!-- 下边:账户明细 --> + <!-- TODO 芋艿:【订单管理】【售后管理】【收藏记录】--> + <el-card header="账户明细" style="width: 100%; margin-top: 20px" shadow="never"> + <template #header> + <CardTitle title="账户明细" /> + </template> + <el-tabs> + <el-tab-pane label="积分"> + <UserPointList :user-id="id" /> + </el-tab-pane> + <el-tab-pane label="签到" lazy> + <UserSignList :user-id="id" /> + </el-tab-pane> + <el-tab-pane label="成长值" lazy> + <UserExperienceRecordList :user-id="id" /> + </el-tab-pane> + <!-- TODO @jason:增加一个余额变化; --> + <el-tab-pane label="余额" lazy>余额(WIP)</el-tab-pane> + <el-tab-pane label="收货地址" lazy> + <UserAddressList :user-id="id" /> + </el-tab-pane> + <el-tab-pane label="订单管理" lazy> + <UserOrderList :user-id="id" /> + </el-tab-pane> + <el-tab-pane label="售后管理" lazy>售后管理(WIP)</el-tab-pane> + <el-tab-pane label="收藏记录" lazy> + <UserFavoriteList :user-id="id" /> + </el-tab-pane> + <el-tab-pane label="优惠劵" lazy> + <UserCouponList :user-id="id" /> + </el-tab-pane> + <el-tab-pane label="推广用户" lazy> + <UserBrokerageList :bind-user-id="id" /> + </el-tab-pane> + </el-tabs> + </el-card> + </el-row> + </div> + + <!-- 表单弹窗:添加/修改 --> + <UserForm ref="formRef" @success="getUserData(id)" /> +</template> +<script setup lang="ts"> +import * as UserApi from '@/api/member/user' +import { useTagsViewStore } from '@/store/modules/tagsView' +import UserForm from '@/views/member/user/UserForm.vue' +import UserAccountInfo from './UserAccountInfo.vue' +import UserAddressList from './UserAddressList.vue' +import UserBasicInfo from './UserBasicInfo.vue' +import UserBrokerageList from './UserBrokerageList.vue' +import UserCouponList from './UserCouponList.vue' +import UserExperienceRecordList from './UserExperienceRecordList.vue' +import UserOrderList from './UserOrderList.vue' +import UserPointList from './UserPointList.vue' +import UserSignList from './UserSignList.vue' +import UserFavoriteList from './UserFavoriteList.vue' +import { CardTitle } from '@/components/Card/index' +import { ElMessage } from 'element-plus' + +defineOptions({ name: 'MemberDetail' }) + +const loading = ref(true) // 加载中 +const user = ref<UserApi.UserVO>({} as UserApi.UserVO) + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string) => { + formRef.value.open(type, id) +} + +/** 获得用户 */ +const getUserData = async (id: number) => { + loading.value = true + try { + user.value = await UserApi.getUser(id) + } finally { + loading.value = false + } +} + +/** 初始化 */ +const { currentRoute } = useRouter() // 路由 +const { delView } = useTagsViewStore() // 视图操作 +const route = useRoute() +const id = Number(route.params.id) +onMounted(() => { + if (!id) { + ElMessage.warning('参数错误,会员编号不能为空!') + delView(unref(currentRoute)) + return + } + getUserData(id) +}) +</script> +<style scoped lang="css"> +.detail-info-item:first-child { + padding-left: 0 !important; +} + +/* first-child 不生效有没有大佬给看下q.q */ +.detail-info-item:nth-child(2) { + padding-right: 0 !important; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; +} +</style> diff --git a/src/views/member/user/index.vue b/src/views/member/user/index.vue new file mode 100644 index 0000000..69bf6de --- /dev/null +++ b/src/views/member/user/index.vue @@ -0,0 +1,313 @@ +<template> + <doc-alert title="会员用户、标签、分组" url="https://doc.iocoder.cn/member/user/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="用户昵称" prop="nickname"> + <el-input + v-model="queryParams.nickname" + class="!w-240px" + clearable + placeholder="请输入用户昵称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="手机号" prop="mobile"> + <el-input + v-model="queryParams.mobile" + class="!w-240px" + clearable + placeholder="请输入手机号" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="注册时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item label="登录时间" prop="loginDate"> + <el-date-picker + v-model="queryParams.loginDate" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item label="用户标签" prop="tagIds"> + <MemberTagSelect v-model="queryParams.tagIds" /> + </el-form-item> + <el-form-item label="用户等级" prop="levelId"> + <MemberLevelSelect v-model="queryParams.levelId" /> + </el-form-item> + <el-form-item label="用户分组" prop="groupId"> + <MemberGroupSelect v-model="queryParams.groupId" /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button v-hasPermi="['promotion:coupon:send']" @click="openCoupon">发送优惠券</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + :show-overflow-tooltip="true" + :stripe="true" + @selection-change="handleSelectionChange" + > + <el-table-column type="selection" width="55" /> + <el-table-column align="center" label="用户编号" prop="id" width="120px" /> + <el-table-column align="center" label="头像" prop="avatar" width="80px"> + <template #default="scope"> + <img :src="scope.row.avatar" style="width: 40px" /> + </template> + </el-table-column> + <el-table-column align="center" label="手机号" prop="mobile" width="120px" /> + <el-table-column align="center" label="昵称" prop="nickname" width="80px" /> + <el-table-column align="center" label="等级" prop="levelName" width="100px" /> + <el-table-column align="center" label="分组" prop="groupName" width="100px" /> + <el-table-column + :show-overflow-tooltip="false" + align="center" + label="用户标签" + prop="tagNames" + > + <template #default="scope"> + <el-tag v-for="(tagName, index) in scope.row.tagNames" :key="index" class="mr-5px"> + {{ tagName }} + </el-tag> + </template> + </el-table-column> + <el-table-column align="center" label="积分" prop="point" width="100px" /> + <el-table-column align="center" label="状态" prop="status" width="100px"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="登录时间" + prop="loginDate" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="注册时间" + prop="createTime" + width="180px" + /> + <el-table-column + :show-overflow-tooltip="false" + align="center" + fixed="right" + label="操作" + width="100px" + > + <template #default="scope"> + <div class="flex items-center justify-center"> + <el-button link type="primary" @click="openDetail(scope.row.id)">详情</el-button> + <el-dropdown + v-hasPermi="[ + 'member:user:update', + 'member:user:update-level', + 'member:user:update-point', + 'member:user:update-balance' + ]" + @command="(command) => handleCommand(command, scope.row)" + > + <el-button link type="primary"> + <Icon icon="ep:d-arrow-right" /> + 更多 + </el-button> + <template #dropdown> + <el-dropdown-menu> + <el-dropdown-item + v-if="checkPermi(['member:user:update'])" + command="handleUpdate" + > + 编辑 + </el-dropdown-item> + <el-dropdown-item + v-if="checkPermi(['member:user:update-level'])" + command="handleUpdateLevel" + > + 修改等级 + </el-dropdown-item> + <el-dropdown-item + v-if="checkPermi(['member:user:update-point'])" + command="handleUpdatePoint" + > + 修改积分 + </el-dropdown-item> + <el-dropdown-item + v-if="checkPermi(['member:user:update-balance'])" + command="handleUpdateBlance" + > + 修改余额(WIP) + </el-dropdown-item> + </el-dropdown-menu> + </template> + </el-dropdown> + </div> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <UserForm ref="formRef" @success="getList" /> + <!-- 修改用户等级弹窗 --> + <UserLevelUpdateForm ref="updateLevelFormRef" @success="getList" /> + <!-- 修改用户积分弹窗 --> + <UserPointUpdateForm ref="updatePointFormRef" @success="getList" /> + <!-- 发送优惠券弹窗 --> + <CouponSendForm ref="couponSendFormRef" /> +</template> +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as UserApi from '@/api/member/user' +import { DICT_TYPE } from '@/utils/dict' +import UserForm from './UserForm.vue' +import MemberTagSelect from '@/views/member/tag/components/MemberTagSelect.vue' +import MemberLevelSelect from '@/views/member/level/components/MemberLevelSelect.vue' +import MemberGroupSelect from '@/views/member/group/components/MemberGroupSelect.vue' +import UserLevelUpdateForm from './UserLevelUpdateForm.vue' +import UserPointUpdateForm from './UserPointUpdateForm.vue' +import { CouponSendForm } from '@/views/mall/promotion/coupon/components' +import { checkPermi } from '@/utils/permission' + +defineOptions({ name: 'MemberUser' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + nickname: null, + mobile: null, + loginDate: [], + createTime: [], + tagIds: [], + levelId: null, + groupId: null +}) +const queryFormRef = ref() // 搜索的表单 +const updateLevelFormRef = ref() // 修改会员等级表单 +const updatePointFormRef = ref() // 修改会员积分表单 +const selectedIds = ref<number[]>([]) // 表格的选中 ID 数组 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await UserApi.getUserPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 打开会员详情 */ +const { push } = useRouter() +const openDetail = (id: number) => { + push({ name: 'MemberUserDetail', params: { id } }) +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 表格选中事件 */ +const handleSelectionChange = (rows: UserApi.UserVO[]) => { + selectedIds.value = rows.map((row) => row.id) +} + +/** 发送优惠券 */ +const couponSendFormRef = ref() +const openCoupon = () => { + if (selectedIds.value.length === 0) { + message.warning('请选择要发送优惠券的用户') + return + } + couponSendFormRef.value.open(selectedIds.value) +} + +/** 操作分发 */ +const handleCommand = (command: string, row: UserApi.UserVO) => { + switch (command) { + case 'handleUpdate': + openForm('update', row.id) + break + case 'handleUpdateLevel': + updateLevelFormRef.value.open(row.id) + break + case 'handleUpdatePoint': + updatePointFormRef.value.open(row.id) + break + case 'handleUpdateBlance': + // todo @jason:增加一个【修改余额】 + break + default: + break + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mp/account/AccountForm.vue b/src/views/mp/account/AccountForm.vue new file mode 100644 index 0000000..c721013 --- /dev/null +++ b/src/views/mp/account/AccountForm.vue @@ -0,0 +1,160 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="rules" + label-width="120px" + > + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + <el-form-item label="微信号" prop="account"> + <template #label> + <span> + <el-tooltip + content="在微信公众平台(mp.weixin.qq.com)的菜单 [设置与开发 - 公众号设置 - 账号详情] 中能找到「微信号」" + placement="top" + > + <Icon icon="ep:question-filled" style="vertical-align: middle" /> + </el-tooltip> + 微信号 + </span> + </template> + <el-input v-model="formData.account" placeholder="请输入微信号" /> + </el-form-item> + <el-form-item label="appId" prop="appId"> + <template #label> + <span> + <el-tooltip + content="在微信公众平台(mp.weixin.qq.com)的菜单 [设置与开发 - 公众号设置 - 基本设置] 中能找到「开发者ID(AppID)」" + placement="top" + > + <Icon icon="ep:question-filled" style="vertical-align: middle" /> + </el-tooltip> + appId + </span> + </template> + <el-input v-model="formData.appId" placeholder="请输入公众号 appId" /> + </el-form-item> + <el-form-item label="appSecret" prop="appSecret"> + <template #label> + <span> + <el-tooltip + content="在微信公众平台(mp.weixin.qq.com)的菜单 [设置与开发 - 公众号设置 - 基本设置] 中能找到「开发者密码(AppSecret)」" + placement="top" + > + <Icon icon="ep:question-filled" style="vertical-align: middle" /> + </el-tooltip> + appSecret + </span> + </template> + <el-input v-model="formData.appSecret" placeholder="请输入公众号 appSecret" /> + </el-form-item> + <el-form-item label="token" prop="token"> + <el-input v-model="formData.token" placeholder="请输入公众号token" /> + </el-form-item> + <el-form-item label="消息加解密密钥" prop="aesKey"> + <el-input v-model="formData.aesKey" placeholder="请输入消息加解密密钥" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as AccountApi from '@/api/mp/account' + +defineOptions({ name: 'MpAccountForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + account: '', + appId: '', + appSecret: '', + token: '', + aesKey: '', + remark: '' +}) +const rules = reactive({ + name: [{ required: true, message: '名称不能为空', trigger: 'blur' }], + account: [{ required: true, message: '公众号账号不能为空', trigger: 'blur' }], + appId: [{ required: true, message: '公众号 appId 不能为空', trigger: 'blur' }], + appSecret: [{ required: true, message: '公众号密钥不能为空', trigger: 'blur' }], + token: [{ required: true, message: '公众号 token 不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await AccountApi.getAccount(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value + if (formType.value === 'create') { + await AccountApi.createAccount(data) + message.success(t('common.createSuccess')) + } else { + await AccountApi.updateAccount(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 表单重置 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + account: '', + appId: '', + appSecret: '', + token: '', + aesKey: '', + remark: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mp/account/index.vue b/src/views/mp/account/index.vue new file mode 100644 index 0000000..6551707 --- /dev/null +++ b/src/views/mp/account/index.vue @@ -0,0 +1,195 @@ +<template> + <doc-alert title="公众号接入" url="https://doc.iocoder.cn/mp/account/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button> + <el-button type="primary" @click="openForm('create')" v-hasPermi="['mp:account:create']"> + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="名称" align="center" prop="name" /> + <el-table-column label="微信号" align="center" prop="account" width="180" /> + <el-table-column label="appId" align="center" prop="appId" width="180" /> + <el-table-column label="服务器地址(URL)" align="center" prop="appId" width="360"> + <template #default="scope"> + {{ 'http://服务端地址/admin-api/mp/open/' + scope.row.appId }} + </template> + </el-table-column> + <el-table-column label="二维码" align="center" prop="qrCodeUrl"> + <template #default="scope"> + <img + v-if="scope.row.qrCodeUrl" + :src="scope.row.qrCodeUrl" + alt="二维码" + style="display: inline-block; height: 100px" + /> + <el-button + link + type="primary" + @click="handleGenerateQrCode(scope.row)" + v-hasPermi="['mp:account:qr-code']" + > + 生成二维码 + </el-button> + </template> + </el-table-column> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['mp:account:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['mp:account:delete']" + > + 删除 + </el-button> + <el-button + link + type="danger" + @click="handleCleanQuota(scope.row)" + v-hasPermi="['mp:account:clear-quota']" + > + 清空 API 配额 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 对话框(添加 / 修改) --> + <AccountForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import * as AccountApi from '@/api/mp/account' +import AccountForm from './AccountForm.vue' + +defineOptions({ name: 'MpAccount' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + account: null, + appId: null +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await AccountApi.getAccountPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await AccountApi.deleteAccount(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 生成二维码的按钮操作 */ +const handleGenerateQrCode = async (row) => { + try { + // 生成二维码的二次确认 + await message.confirm('是否确认生成公众号账号编号为"' + row.name + '"的二维码?') + // 发起生成二维码 + await AccountApi.generateAccountQrCode(row.id) + message.success('生成二维码成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 清空二维码 API 配额的按钮操作 */ +const handleCleanQuota = async (row) => { + try { + // 清空 API 配额的二次确认 + await message.confirm('是否确认清空生成公众号账号编号为"' + row.name + '"的 API 配额?') + // 发起清空 API 配额 + await AccountApi.clearAccountQuota(row.id) + message.success('清空 API 配额成功') + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mp/autoReply/components/ReplyForm.vue b/src/views/mp/autoReply/components/ReplyForm.vue new file mode 100644 index 0000000..1c9dee4 --- /dev/null +++ b/src/views/mp/autoReply/components/ReplyForm.vue @@ -0,0 +1,80 @@ +<template> + <div> + <el-form ref="formRef" :model="replyForm" :rules="rules" label-width="80px"> + <el-form-item label="消息类型" prop="requestMessageType" v-if="msgType === MsgType.Message"> + <el-select v-model="replyForm.requestMessageType" placeholder="请选择"> + <template v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)" :key="dict.value"> + <el-option + v-if="RequestMessageTypes.includes(dict.value)" + :label="dict.label" + :value="dict.value" + /> + </template> + </el-select> + </el-form-item> + <el-form-item label="匹配类型" prop="requestMatch" v-if="msgType === MsgType.Keyword"> + <el-select v-model="replyForm.requestMatch" placeholder="请选择匹配类型" clearable> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="关键词" prop="requestKeyword" v-if="msgType === MsgType.Keyword"> + <el-input v-model="replyForm.requestKeyword" placeholder="请输入内容" clearable /> + </el-form-item> + <el-form-item label="回复消息"> + <WxReplySelect v-model="reply" /> + </el-form-item> + </el-form> + </div> +</template> + +<script lang="ts" setup> +import WxReplySelect, { type Reply } from '@/views/mp/components/wx-reply' +import type { FormInstance } from 'element-plus' +import { MsgType } from './types' +import { DICT_TYPE, getDictOptions, getIntDictOptions } from '@/utils/dict' + +defineOptions({ name: 'ReplyForm' }) + +const props = defineProps<{ + modelValue: any + reply: Reply + msgType: MsgType +}>() + +const emit = defineEmits<{ + (e: 'update:reply', v: Reply) + (e: 'update:modelValue', v: any) +}>() + +const reply = computed<Reply>({ + get: () => props.reply, + set: (val) => emit('update:reply', val) +}) + +const replyForm = computed<any>({ + get: () => props.modelValue, + set: (val) => emit('update:modelValue', val) +}) + +const formRef = ref<FormInstance | null>(null) // 表单 ref + +const RequestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'location', 'link'] // 允许选择的请求消息类型 + +// 表单校验 +const rules = { + requestKeyword: [{ required: true, message: '请求的关键字不能为空', trigger: 'blur' }], + requestMatch: [{ required: true, message: '请求的关键字的匹配不能为空', trigger: 'blur' }] +} + +defineExpose({ + resetFields: () => formRef.value?.resetFields(), + validate: async () => formRef.value?.validate() +}) +</script> + +<style scoped></style> diff --git a/src/views/mp/autoReply/components/ReplyTable.vue b/src/views/mp/autoReply/components/ReplyTable.vue new file mode 100644 index 0000000..2abe9f2 --- /dev/null +++ b/src/views/mp/autoReply/components/ReplyTable.vue @@ -0,0 +1,115 @@ +<template> + <el-table v-loading="props.loading" :data="props.list"> + <el-table-column + label="请求消息类型" + align="center" + prop="requestMessageType" + v-if="msgType === MsgType.Message" + /> + <el-table-column + label="关键词" + align="center" + prop="requestKeyword" + v-if="msgType === MsgType.Keyword" + /> + <el-table-column + label="匹配类型" + align="center" + prop="requestMatch" + v-if="msgType === MsgType.Keyword" + > + <template #default="scope"> + <dict-tag :type="DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH" :value="scope.row.requestMatch" /> + </template> + </el-table-column> + <el-table-column label="回复消息类型" align="center"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.MP_MESSAGE_TYPE" :value="scope.row.responseMessageType" /> + </template> + </el-table-column> + <el-table-column label="回复内容" align="center"> + <template #default="scope"> + <div v-if="scope.row.responseMessageType === 'text'">{{ scope.row.responseContent }}</div> + <div v-else-if="scope.row.responseMessageType === 'voice'"> + <WxVoicePlayer v-if="scope.row.responseMediaUrl" :url="scope.row.responseMediaUrl" /> + </div> + <div v-else-if="scope.row.responseMessageType === 'image'"> + <a target="_blank" :href="scope.row.responseMediaUrl"> + <img :src="scope.row.responseMediaUrl" style="width: 100px" /> + </a> + </div> + <div + v-else-if=" + scope.row.responseMessageType === 'video' || + scope.row.responseMessageType === 'shortvideo' + " + > + <WxVideoPlayer + v-if="scope.row.responseMediaUrl" + :url="scope.row.responseMediaUrl" + style="margin-top: 10px" + /> + </div> + <div v-else-if="scope.row.responseMessageType === 'news'"> + <WxNews :articles="scope.row.responseArticles" /> + </div> + <div v-else-if="scope.row.responseMessageType === 'music'"> + <WxMusic + :title="scope.row.responseTitle" + :description="scope.row.responseDescription" + :thumb-media-url="scope.row.responseThumbMediaUrl" + :music-url="scope.row.responseMusicUrl" + :hq-music-url="scope.row.responseHqMusicUrl" + /> + </div> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + type="primary" + link + @click="emit('on-update', scope.row.id)" + v-hasPermi="['mp:auto-reply:update']" + > + 修改 + </el-button> + <el-button + type="danger" + link + @click="emit('on-delete', scope.row.id)" + v-hasPermi="['mp:auto-reply:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> +</template> +<script lang="ts" setup> +import WxVideoPlayer from '@/views/mp/components/wx-video-play' +import WxVoicePlayer from '@/views/mp/components/wx-voice-play' +import WxMusic from '@/views/mp/components/wx-music' +import WxNews from '@/views/mp/components/wx-news' +import { dateFormatter } from '@/utils/formatTime' +import { DICT_TYPE } from '@/utils/dict' +import { MsgType } from './types' + +const props = defineProps<{ + loading: boolean + list: any[] + msgType: MsgType +}>() + +const emit = defineEmits<{ + (e: 'on-update', v: number) + (e: 'on-delete', v: number) +}>() +</script> diff --git a/src/views/mp/autoReply/components/types.ts b/src/views/mp/autoReply/components/types.ts new file mode 100644 index 0000000..68bc5c9 --- /dev/null +++ b/src/views/mp/autoReply/components/types.ts @@ -0,0 +1,7 @@ +// 消息类型(Follow: 关注时回复;Message: 消息回复;Keyword: 关键词回复) +// 作为 tab.name,enum 的数字不能随意修改,与 api 参数相关 +export enum MsgType { + Follow = 1, + Message = 2, + Keyword = 3 +} diff --git a/src/views/mp/autoReply/index.vue b/src/views/mp/autoReply/index.vue new file mode 100644 index 0000000..0b00647 --- /dev/null +++ b/src/views/mp/autoReply/index.vue @@ -0,0 +1,241 @@ +<template> + <doc-alert title="自动回复" url="https://doc.iocoder.cn/mp/auto-reply/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form class="-mb-15px" :model="queryParams" :inline="true" label-width="68px"> + <el-form-item label="公众号" prop="accountId"> + <WxAccountSelect @change="onAccountChanged" /> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- tab 切换 --> + <ContentWrap> + <el-tabs v-model="msgType" @tab-change="onTabChange"> + <!-- 操作工具栏 --> + <el-row :gutter="10" class="mb8"> + <el-col :span="1.5"> + <el-button + type="primary" + plain + @click="onCreate" + v-hasPermi="['mp:auto-reply:create']" + v-if="msgType !== MsgType.Follow || list.length <= 0" + > + <Icon icon="ep:plus" />新增 + </el-button> + </el-col> + </el-row> + <!-- tab 项 --> + <el-tab-pane :name="MsgType.Follow"> + <template #label> + <el-row align="middle"><Icon icon="ep:star" class="mr-2px" /> 关注时回复</el-row> + </template> + </el-tab-pane> + <el-tab-pane :name="MsgType.Message"> + <template #label> + <el-row align="middle"><Icon icon="ep:chat-line-round" class="mr-2px" /> 消息回复</el-row> + </template> + </el-tab-pane> + <el-tab-pane :name="MsgType.Keyword"> + <template #label> + <el-row align="middle"><Icon icon="fa:newspaper-o" class="mr-2px" /> 关键词回复</el-row> + </template> + </el-tab-pane> + </el-tabs> + <!-- 列表 --> + <ReplyTable + :loading="loading" + :list="list" + :msg-type="msgType" + @on-update="onUpdate" + @on-delete="onDelete" + /> + + <el-dialog + :title="isCreating ? '新增自动回复' : '修改自动回复'" + v-model="showDialog" + width="800px" + destroy-on-close + > + <ReplyForm v-model="replyForm" v-model:reply="reply" :msg-type="msgType" ref="formRef" /> + <template #footer> + <el-button @click="cancel">取 消</el-button> + <el-button type="primary" @click="onSubmit">确 定</el-button> + </template> + </el-dialog> + </ContentWrap> +</template> +<script lang="ts" setup> +import ReplyForm from '@/views/mp/autoReply/components/ReplyForm.vue' +import { type Reply, ReplyType } from '@/views/mp/components/wx-reply' +import WxAccountSelect from '@/views/mp/components/wx-account-select' +import * as MpAutoReplyApi from '@/api/mp/autoReply' +import { ContentWrap } from '@/components/ContentWrap' +import type { TabPaneName } from 'element-plus' +import ReplyTable from './components/ReplyTable.vue' +import { MsgType } from './components/types' + +defineOptions({ name: 'MpAutoReply' }) + +const message = useMessage() // 消息 + +const accountId = ref(-1) // 公众号ID +const msgType = ref<MsgType>(MsgType.Keyword) // 消息类型 +const loading = ref(true) // 遮罩层 +const total = ref(0) // 总条数 +const list = ref<any[]>([]) // 自动回复列表 +const formRef = ref<InstanceType<typeof ReplyForm> | null>(null) // 表单 ref +// 查询参数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + accountId: accountId +}) + +const isCreating = ref(false) // 是否新建(否则编辑) +const showDialog = ref(false) // 是否显示弹出层 +const replyForm = ref<any>({}) // 表单参数 +// 回复消息 +const reply = ref<Reply>({ + type: ReplyType.Text, + accountId: -1 +}) + +/** 侦听账号变化 */ +const onAccountChanged = (id: number) => { + accountId.value = id + reply.value.accountId = id + queryParams.pageNo = 1 + getList() +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await MpAutoReplyApi.getAutoReplyPage({ + ...queryParams, + type: msgType.value + }) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +const onTabChange = (tabName: TabPaneName) => { + msgType.value = tabName as MsgType + handleQuery() +} + +/** 新增按钮操作 */ +const onCreate = () => { + reset() + // 打开表单,并设置初始化 + reply.value = { + type: ReplyType.Text, + accountId: queryParams.accountId + } + + isCreating.value = true + showDialog.value = true +} + +/** 修改按钮操作 */ +const onUpdate = async (id: number) => { + reset() + + const data = await MpAutoReplyApi.getAutoReply(id) + // 设置属性 + replyForm.value = { ...data } + delete replyForm.value['responseMessageType'] + delete replyForm.value['responseContent'] + delete replyForm.value['responseMediaId'] + delete replyForm.value['responseMediaUrl'] + delete replyForm.value['responseDescription'] + delete replyForm.value['responseArticles'] + reply.value = { + type: data.responseMessageType, + accountId: queryParams.accountId, + content: data.responseContent, + mediaId: data.responseMediaId, + url: data.responseMediaUrl, + title: data.responseTitle, + description: data.responseDescription, + thumbMediaId: data.responseThumbMediaId, + thumbMediaUrl: data.responseThumbMediaUrl, + articles: data.responseArticles, + musicUrl: data.responseMusicUrl, + hqMusicUrl: data.responseHqMusicUrl + } + + // 打开表单 + isCreating.value = false + showDialog.value = true +} + +/** 删除按钮操作 */ +const onDelete = async (id: number) => { + await message.confirm('是否确认删除此数据?') + await MpAutoReplyApi.deleteAutoReply(id) + await getList() + message.success('删除成功') +} + +const onSubmit = async () => { + await formRef.value?.validate() + + // 处理回复消息 + const submitForm: any = { ...replyForm.value } + submitForm.responseMessageType = reply.value.type + submitForm.responseContent = reply.value.content + submitForm.responseMediaId = reply.value.mediaId + submitForm.responseMediaUrl = reply.value.url + submitForm.responseTitle = reply.value.title + submitForm.responseDescription = reply.value.description + submitForm.responseThumbMediaId = reply.value.thumbMediaId + submitForm.responseThumbMediaUrl = reply.value.thumbMediaUrl + submitForm.responseArticles = reply.value.articles + submitForm.responseMusicUrl = reply.value.musicUrl + submitForm.responseHqMusicUrl = reply.value.hqMusicUrl + + if (replyForm.value.id !== undefined) { + await MpAutoReplyApi.updateAutoReply(submitForm) + message.success('修改成功') + } else { + await MpAutoReplyApi.createAutoReply(submitForm) + message.success('新增成功') + } + + showDialog.value = false + await getList() +} + +// 表单重置 +const reset = () => { + replyForm.value = { + id: undefined, + accountId: queryParams.accountId, + type: msgType.value, + requestKeyword: undefined, + requestMatch: msgType.value === MsgType.Keyword ? 1 : undefined, + requestMessageType: undefined + } + formRef.value?.resetFields() +} + +// 取消按钮 +const cancel = () => { + showDialog.value = false + reset() +} +</script> diff --git a/src/views/mp/components/wx-account-select/index.ts b/src/views/mp/components/wx-account-select/index.ts new file mode 100644 index 0000000..97556b2 --- /dev/null +++ b/src/views/mp/components/wx-account-select/index.ts @@ -0,0 +1,3 @@ +import WxAccountSelect from './main.vue' + +export default WxAccountSelect diff --git a/src/views/mp/components/wx-account-select/main.vue b/src/views/mp/components/wx-account-select/main.vue new file mode 100644 index 0000000..2a6ca50 --- /dev/null +++ b/src/views/mp/components/wx-account-select/main.vue @@ -0,0 +1,47 @@ +<template> + <el-select v-model="account.id" placeholder="请选择公众号" class="!w-240px" @change="onChanged"> + <el-option v-for="item in accountList" :key="item.id" :label="item.name" :value="item.id" /> + </el-select> +</template> + +<script lang="ts" setup> +import * as MpAccountApi from '@/api/mp/account' + +defineOptions({ name: 'WxAccountSelect' }) + +const account: MpAccountApi.AccountVO = reactive({ + id: -1, + name: '' +}) + +const accountList = ref<MpAccountApi.AccountVO[]>([]) + +const emit = defineEmits<{ + (e: 'change', id: number, name: string) +}>() + +const handleQuery = async () => { + accountList.value = await MpAccountApi.getSimpleAccountList() + // 默认选中第一个 + if (accountList.value.length > 0) { + account.id = accountList.value[0].id + if (account.id) { + account.name = accountList.value[0].name + emit('change', account.id, account.name) + } + } +} + +const onChanged = (id?: number) => { + const found = accountList.value.find((v) => v.id === id) + if (account.id) { + account.name = found ? found.name : '' + emit('change', account.id, account.name) + } +} + +/** 初始化 */ +onMounted(() => { + handleQuery() +}) +</script> diff --git a/src/views/mp/components/wx-location/index.ts b/src/views/mp/components/wx-location/index.ts new file mode 100644 index 0000000..14ba864 --- /dev/null +++ b/src/views/mp/components/wx-location/index.ts @@ -0,0 +1,3 @@ +import WxLocation from './main.vue' + +export default WxLocation diff --git a/src/views/mp/components/wx-location/main.vue b/src/views/mp/components/wx-location/main.vue new file mode 100644 index 0000000..0b68d49 --- /dev/null +++ b/src/views/mp/components/wx-location/main.vue @@ -0,0 +1,73 @@ +<!-- + 【微信消息 - 定位】TODO @Dhb52 目前未启用 +--> +<template> + <div> + <el-link + type="primary" + target="_blank" + :href=" + 'https://map.qq.com/?type=marker&isopeninfowin=1&markertype=1&pointx=' + + locationY + + '&pointy=' + + locationX + + '&name=' + + label + + '&ref=yudao' + " + > + <el-col> + <el-row> + <img + :src=" + 'https://apis.map.qq.com/ws/staticmap/v2/?zoom=10&markers=color:blue|label:A|' + + locationX + + ',' + + locationY + + '&key=' + + qqMapKey + + '&size=250*180' + " + /> + </el-row> + <el-row> + <Icon icon="ep:location" /> + {{ label }} + </el-row> + </el-col> + </el-link> + </div> +</template> + +<script lang="ts" setup> +defineOptions({ name: 'WxLocation' }) + +const props = defineProps({ + locationX: { + required: true, + type: Number + }, + locationY: { + required: true, + type: Number + }, + label: { + // 地名 + required: true, + type: String + }, + qqMapKey: { + // QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc + required: false, + type: String, + default: 'TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E' // 需要自定义 + } +}) + +defineExpose({ + locationX: props.locationX, + locationY: props.locationY, + label: props.label, + qqMapKey: props.qqMapKey +}) +</script> diff --git a/src/views/mp/components/wx-material-select/index.ts b/src/views/mp/components/wx-material-select/index.ts new file mode 100644 index 0000000..eeda31d --- /dev/null +++ b/src/views/mp/components/wx-material-select/index.ts @@ -0,0 +1,6 @@ +import WxMaterialSelect from './main.vue' +import { NewsType, MaterialType } from './types' + +export { NewsType, MaterialType } + +export default WxMaterialSelect diff --git a/src/views/mp/components/wx-material-select/main.vue b/src/views/mp/components/wx-material-select/main.vue new file mode 100644 index 0000000..aad25ea --- /dev/null +++ b/src/views/mp/components/wx-material-select/main.vue @@ -0,0 +1,279 @@ +<!-- + - Copyright (C) 2018-2019 + - All rights reserved, Designed By www.joolun.com + 芋道源码: + ① 移除 avue 组件,使用 ElementUI 原生组件 +--> +<template> + <div class="pb-30px"> + <!-- 类型:image --> + <div v-if="props.type === 'image'"> + <div class="waterfall" v-loading="loading"> + <div class="waterfall-item" v-for="item in list" :key="item.mediaId"> + <img class="material-img" :src="item.url" /> + <p class="item-name">{{ item.name }}</p> + <el-row class="ope-row"> + <el-button type="success" @click="selectMaterialFun(item)"> + 选择 + <Icon icon="ep:circle-check" /> + </el-button> + </el-row> + </div> + </div> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getMaterialPageFun" + /> + </div> + <!-- 类型:voice --> + <div v-else-if="props.type === 'voice'"> + <!-- 列表 --> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="mediaId" /> + <el-table-column label="文件名" align="center" prop="name" /> + <el-table-column label="语音" align="center"> + <template #default="scope"> + <WxVoicePlayer :url="scope.row.url" /> + </template> + </el-table-column> + <el-table-column + label="上传时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center" fixed="right"> + <template #default="scope"> + <el-button type="primary" link @click="selectMaterialFun(scope.row)" + >选择 + <Icon icon="ep:plus" /> + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getPage" + /> + </div> + <!-- 类型:video --> + <div v-else-if="props.type === 'video'"> + <!-- 列表 --> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="mediaId" /> + <el-table-column label="文件名" align="center" prop="name" /> + <el-table-column label="标题" align="center" prop="title" /> + <el-table-column label="介绍" align="center" prop="introduction" /> + <el-table-column label="视频" align="center"> + <template #default="scope"> + <WxVideoPlayer :url="scope.row.url" /> + </template> + </el-table-column> + <el-table-column + label="上传时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column + label="操作" + align="center" + fixed="right" + class-name="small-padding fixed-width" + > + <template #default="scope"> + <el-button type="primary" link @click="selectMaterialFun(scope.row)" + >选择 + <Icon icon="akar-icons:circle-plus" /> + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getMaterialPageFun" + /> + </div> + <!-- 类型:news --> + <div v-else-if="props.type === 'news'"> + <div class="waterfall" v-loading="loading"> + <div class="waterfall-item" v-for="item in list" :key="item.mediaId"> + <div v-if="item.content && item.content.newsItem"> + <WxNews :articles="item.content.newsItem" /> + <el-row class="ope-row"> + <el-button type="success" @click="selectMaterialFun(item)"> + 选择 + <Icon icon="ep:circle-check" /> + </el-button> + </el-row> + </div> + </div> + </div> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getMaterialPageFun" + /> + </div> + </div> +</template> + +<script lang="ts" setup> +import WxNews from '@/views/mp/components/wx-news' +import WxVoicePlayer from '@/views/mp/components/wx-voice-play' +import WxVideoPlayer from '@/views/mp/components/wx-video-play' +import { NewsType } from './types' +import * as MpMaterialApi from '@/api/mp/material' +import * as MpFreePublishApi from '@/api/mp/freePublish' +import * as MpDraftApi from '@/api/mp/draft' +import { dateFormatter } from '@/utils/formatTime' + +defineOptions({ name: 'WxMaterialSelect' }) + +const props = withDefaults( + defineProps<{ + type: string + accountId: number + newsType?: NewsType + }>(), + { + newsType: NewsType.Published + } +) + +const emit = defineEmits(['select-material']) + +// 遮罩层 +const loading = ref(false) +// 总条数 +const total = ref(0) +// 数据列表 +const list = ref<any[]>([]) +// 查询参数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + accountId: props.accountId +}) + +const selectMaterialFun = (item) => { + emit('select-material', item) +} + +const getPage = async () => { + loading.value = true + try { + if (props.type === 'news' && props.newsType === NewsType.Published) { + // 【图文】+ 【已发布】 + await getFreePublishPageFun() + } else if (props.type === 'news' && props.newsType === NewsType.Draft) { + // 【图文】+ 【草稿】 + await getDraftPageFun() + } else { + // 【素材】 + await getMaterialPageFun() + } + } finally { + loading.value = false + } +} + +const getMaterialPageFun = async () => { + const data = await MpMaterialApi.getMaterialPage({ + ...queryParams, + type: props.type + }) + list.value = data.list + total.value = data.total +} + +const getFreePublishPageFun = async () => { + const data = await MpFreePublishApi.getFreePublishPage(queryParams) + data.list.forEach((item: any) => { + const articles = item.content.newsItem + articles.forEach((article: any) => { + article.picUrl = article.thumbUrl + }) + }) + list.value = data.list + total.value = data.total +} + +const getDraftPageFun = async () => { + const data = await MpDraftApi.getDraftPage(queryParams) + data.list.forEach((draft: any) => { + const articles = draft.content.newsItem + articles.forEach((article: any) => { + article.picUrl = article.thumbUrl + }) + }) + list.value = data.list + total.value = data.total +} + +onMounted(async () => { + getPage() +}) +</script> +<style lang="scss" scoped> +@media (width >= 992px) and (width <= 1300px) { + .waterfall { + column-count: 3; + } + + p { + color: red; + } +} + +@media (width >= 768px) and (width <= 991px) { + .waterfall { + column-count: 2; + } + + p { + color: orange; + } +} + +@media (width <= 767px) { + .waterfall { + column-count: 1; + } +} + +.waterfall { + width: 100%; + column-gap: 10px; + column-count: 5; + margin: 0 auto; +} + +.waterfall-item { + padding: 10px; + margin-bottom: 10px; + break-inside: avoid; + border: 1px solid #eaeaea; +} + +.material-img { + width: 100%; +} + +p { + line-height: 30px; +} +</style> diff --git a/src/views/mp/components/wx-material-select/types.ts b/src/views/mp/components/wx-material-select/types.ts new file mode 100644 index 0000000..d4add1d --- /dev/null +++ b/src/views/mp/components/wx-material-select/types.ts @@ -0,0 +1,11 @@ +export enum NewsType { + Draft = '2', + Published = '1' +} + +export enum MaterialType { + Image = 'image', + Voice = 'voice', + Video = 'video', + News = 'news' +} diff --git a/src/views/mp/components/wx-msg/card.scss b/src/views/mp/components/wx-msg/card.scss new file mode 100644 index 0000000..7fbbe80 --- /dev/null +++ b/src/views/mp/components/wx-msg/card.scss @@ -0,0 +1,116 @@ +.avue-card { + &__item { + margin-bottom: 16px; + border: 1px solid #e8e8e8; + background-color: #fff; + box-sizing: border-box; + color: rgba(0, 0, 0, 0.65); + font-size: 14px; + font-variant: tabular-nums; + line-height: 1.5; + list-style: none; + font-feature-settings: 'tnum'; + cursor: pointer; + height: 200px; + + &:hover { + border-color: rgba(0, 0, 0, 0.09); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09); + } + + &--add { + border: 1px dashed #000; + width: 100%; + color: rgba(0, 0, 0, 0.45); + background-color: #fff; + border-color: #d9d9d9; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + + i { + margin-right: 10px; + } + + &:hover { + color: #40a9ff; + background-color: #fff; + border-color: #40a9ff; + } + } + } + + &__body { + display: flex; + padding: 24px; + } + + &__detail { + flex: 1; + } + + &__avatar { + width: 48px; + height: 48px; + border-radius: 48px; + overflow: hidden; + margin-right: 12px; + + img { + width: 100%; + height: 100%; + } + } + + &__title { + color: rgba(0, 0, 0, 0.85); + margin-bottom: 12px; + font-size: 16px; + + &:hover { + color: #1890ff; + } + } + + &__info { + color: rgba(0, 0, 0, 0.45); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + height: 64px; + } + + &__menu { + display: flex; + justify-content: space-around; + height: 50px; + background: #f7f9fa; + color: rgba(0, 0, 0, 0.45); + text-align: center; + line-height: 50px; + + &:hover { + color: #1890ff; + } + } +} + +/** joolun 额外加的 */ +.avue-comment__main { + flex: unset !important; + border-radius: 5px !important; + margin: 0 8px !important; +} + +.avue-comment__header { + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} + +.avue-comment__body { + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; +} diff --git a/src/views/mp/components/wx-msg/comment.scss b/src/views/mp/components/wx-msg/comment.scss new file mode 100644 index 0000000..7812c2a --- /dev/null +++ b/src/views/mp/components/wx-msg/comment.scss @@ -0,0 +1,126 @@ +/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */ +.avue-comment { + margin-bottom: 30px; + display: flex; + align-items: flex-start; + + &--reverse { + flex-direction: row-reverse; + + .avue-comment__main { + &:before, + &:after { + left: auto; + right: -8px; + border-width: 8px 0 8px 8px; + } + + &:before { + border-left-color: #dedede; + } + + &:after { + border-left-color: #f8f8f8; + margin-right: 1px; + margin-left: auto; + } + } + } + + &__avatar { + width: 48px; + height: 48px; + border-radius: 50%; + border: 1px solid transparent; + box-sizing: border-box; + vertical-align: middle; + } + + &__header { + padding: 5px 15px; + background: #f8f8f8; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + justify-content: space-between; + } + + &__author { + font-weight: 700; + font-size: 14px; + color: #999; + } + + &__main { + flex: 1; + margin: 0 20px; + position: relative; + border: 1px solid #dedede; + border-radius: 2px; + + &:before, + &:after { + position: absolute; + top: 10px; + left: -8px; + right: 100%; + width: 0; + height: 0; + display: block; + content: ' '; + border-color: transparent; + border-style: solid solid outset; + border-width: 8px 8px 8px 0; + pointer-events: none; + } + + &:before { + border-right-color: #dedede; + z-index: 1; + } + + &:after { + border-right-color: #f8f8f8; + margin-left: 1px; + z-index: 2; + } + } + + &__body { + padding: 15px; + overflow: hidden; + background: #fff; + font-family: + Segoe UI, + Lucida Grande, + Helvetica, + Arial, + Microsoft YaHei, + FreeSans, + Arimo, + Droid Sans, + wenquanyi micro hei, + Hiragino Sans GB, + Hiragino Sans GB W3, + FontAwesome, + sans-serif; + color: #333; + font-size: 14px; + } + + blockquote { + margin: 0; + font-family: + Georgia, + Times New Roman, + Times, + Kai, + Kaiti SC, + KaiTi, + BiauKai, + FontAwesome, + serif; + padding: 1px 0 1px 15px; + border-left: 4px solid #ddd; + } +} diff --git a/src/views/mp/components/wx-msg/components/Msg.vue b/src/views/mp/components/wx-msg/components/Msg.vue new file mode 100644 index 0000000..c35e268 --- /dev/null +++ b/src/views/mp/components/wx-msg/components/Msg.vue @@ -0,0 +1,69 @@ +<template> + <div> + <MsgEvent v-if="item.type === MsgType.Event" :item="item" /> + + <div v-else-if="item.type === MsgType.Text">{{ item.content }}</div> + + <div v-else-if="item.type === MsgType.Voice"> + <WxVoicePlayer :url="item.mediaUrl" :content="item.recognition" /> + </div> + + <div v-else-if="item.type === MsgType.Image"> + <a target="_blank" :href="item.mediaUrl"> + <img :src="item.mediaUrl" style="width: 100px" /> + </a> + </div> + + <div + v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'" + style="text-align: center" + > + <WxVideoPlayer :url="item.mediaUrl" /> + </div> + + <div v-else-if="item.type === MsgType.Link" class="avue-card__detail"> + <el-link type="success" :underline="false" target="_blank" :href="item.url"> + <div class="avue-card__title"><i class="el-icon-link"></i>{{ item.title }}</div> + </el-link> + <div class="avue-card__info" style="height: unset">{{ item.description }}</div> + </div> + + <div v-else-if="item.type === MsgType.Location"> + <WxLocation :label="item.label" :location-y="item.locationY" :location-x="item.locationX" /> + </div> + + <div v-else-if="item.type === MsgType.News" style="width: 300px"> + <WxNews :articles="item.articles" /> + </div> + + <div v-else-if="item.type === MsgType.Music"> + <WxMusic + :title="item.title" + :description="item.description" + :thumb-media-url="item.thumbMediaUrl" + :music-url="item.musicUrl" + :hq-music-url="item.hqMusicUrl" + /> + </div> + </div> +</template> + +<script lang="ts" setup> +import WxVideoPlayer from '@/views/mp/components/wx-video-play' +import WxVoicePlayer from '@/views/mp/components/wx-voice-play' +import WxNews from '@/views/mp/components/wx-news' +import WxLocation from '@/views/mp/components/wx-location' +import WxMusic from '@/views/mp/components/wx-music' +import MsgEvent from './MsgEvent.vue' +import { MsgType } from '../types' + +defineOptions({ name: 'Msg' }) + +const props = defineProps<{ + item: any +}>() + +const item = ref<any>(props.item) +</script> + +<style scoped></style> diff --git a/src/views/mp/components/wx-msg/components/MsgEvent.vue b/src/views/mp/components/wx-msg/components/MsgEvent.vue new file mode 100644 index 0000000..77beda4 --- /dev/null +++ b/src/views/mp/components/wx-msg/components/MsgEvent.vue @@ -0,0 +1,49 @@ +<template> + <div> + <div v-if="item.event === 'subscribe'"> + <el-tag type="success">关注</el-tag> + </div> + <div v-else-if="item.event === 'unsubscribe'"> + <el-tag type="danger">取消关注</el-tag> + </div> + <div v-else-if="item.event === 'CLICK'"> + <el-tag>点击菜单</el-tag> + 【{{ item.eventKey }}】 + </div> + <div v-else-if="item.event === 'VIEW'"> + <el-tag>点击菜单链接</el-tag> + 【{{ item.eventKey }}】 + </div> + <div v-else-if="item.event === 'scancode_waitmsg'"> + <el-tag>扫码结果</el-tag> + 【{{ item.eventKey }}】 + </div> + <div v-else-if="item.event === 'scancode_push'"> + <el-tag>扫码结果</el-tag> + 【{{ item.eventKey }}】 + </div> + <div v-else-if="item.event === 'pic_sysphoto'"> + <el-tag>系统拍照发图</el-tag> + </div> + <div v-else-if="item.event === 'pic_photo_or_album'"> + <el-tag>拍照或者相册</el-tag> + </div> + <div v-else-if="item.event === 'pic_weixin'"> + <el-tag>微信相册</el-tag> + </div> + <div v-else-if="item.event === 'location_select'"> + <el-tag>选择地理位置</el-tag> + </div> + <div v-else> + <el-tag type="danger">未知事件类型</el-tag> + </div> + </div> +</template> + +<script lang="ts" setup> +const props = defineProps<{ + item: any +}>() + +const item = ref(props.item) +</script> diff --git a/src/views/mp/components/wx-msg/components/MsgList.vue b/src/views/mp/components/wx-msg/components/MsgList.vue new file mode 100644 index 0000000..ce7063b --- /dev/null +++ b/src/views/mp/components/wx-msg/components/MsgList.vue @@ -0,0 +1,62 @@ +<template> + <div class="execution" v-for="item in props.list" :key="item.id"> + <div + class="avue-comment" + :class="{ 'avue-comment--reverse': item.sendFrom === SendFrom.MpBot }" + > + <div class="avatar-div"> + <img :src="getAvatar(item.sendFrom)" class="avue-comment__avatar" /> + <div class="avue-comment__author"> + {{ getNickname(item.sendFrom) }} + </div> + </div> + <div class="avue-comment__main"> + <div class="avue-comment__header"> + <div class="avue-comment__create_time">{{ formatDate(item.createTime) }}</div> + </div> + <div + class="avue-comment__body" + :style="item.sendFrom === SendFrom.MpBot ? 'background: #6BED72;' : ''" + > + <Msg :item="item" /> + </div> + </div> + </div> + </div> +</template> +<script lang="ts" setup> +import Msg from './Msg.vue' +import { formatDate } from '@/utils/formatTime' +import { User } from '../types' +import avatarWechat from '@/assets/imgs/wechat.png' + +defineOptions({ name: 'MsgList' }) + +const props = defineProps<{ + list: any[] + accountId: number + user: User +}>() + +enum SendFrom { + User = 1, + MpBot = 2 +} + +const getAvatar = (sendFrom: SendFrom) => + sendFrom === SendFrom.User ? props.user.avatar : avatarWechat + +const getNickname = (sendFrom: SendFrom) => + sendFrom === SendFrom.User ? props.user.nickname : '公众号' +</script> + +<style lang="scss" scoped> +/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */ +@import url('../comment.scss'); +@import url('../card.scss'); + +.avatar-div { + width: 80px; + text-align: center; +} +</style> diff --git a/src/views/mp/components/wx-msg/index.ts b/src/views/mp/components/wx-msg/index.ts new file mode 100644 index 0000000..fd9eddd --- /dev/null +++ b/src/views/mp/components/wx-msg/index.ts @@ -0,0 +1,6 @@ +import WxMsg from './main.vue' +import { MsgType } from './types' + +export { MsgType } + +export default WxMsg diff --git a/src/views/mp/components/wx-msg/main.vue b/src/views/mp/components/wx-msg/main.vue new file mode 100644 index 0000000..8b7cc3a --- /dev/null +++ b/src/views/mp/components/wx-msg/main.vue @@ -0,0 +1,192 @@ +<!-- + - Copyright (C) 2018-2019 + - All rights reserved, Designed By www.joolun.com + 芋道源码: + ① 移除暂时用不到的 websocket + ② 代码优化,补充注释,提升阅读性 +--> +<template> + <ContentWrap> + <div class="msg-div" ref="msgDivRef"> + <!-- 加载更多 --> + <div v-loading="loading"></div> + <div v-if="!loading"> + <div class="el-table__empty-block" v-if="hasMore" @click="loadMore" + ><span class="el-table__empty-text">点击加载更多</span></div + > + <div class="el-table__empty-block" v-if="!hasMore" + ><span class="el-table__empty-text">没有更多了</span></div + > + </div> + + <!-- 消息列表 --> + <MsgList :list="list" :account-id="accountId" :user="user" /> + </div> + + <div class="msg-send" v-loading="sendLoading"> + <WxReplySelect ref="replySelectRef" v-model="reply" /> + <el-button type="success" class="send-but" @click="sendMsg">发送(S)</el-button> + </div> + </ContentWrap> +</template> + +<script lang="ts" setup> +import WxReplySelect, { Reply, ReplyType } from '@/views/mp/components/wx-reply' +import MsgList from './components/MsgList.vue' +import { getMessagePage, sendMessage } from '@/api/mp/message' +import { getUser } from '@/api/mp/user' +import profile from '@/assets/imgs/profile.jpg' +import { User } from './types' + +defineOptions({ name: 'WxMsg' }) + +const message = useMessage() // 消息弹窗 + +const props = defineProps({ + userId: { + type: Number, + required: true + } +}) + +const accountId = ref(-1) // 公众号ID,需要通过userId初始化 +const loading = ref(false) // 消息列表是否正在加载中 +const hasMore = ref(true) // 是否可以加载更多 +const list = ref<any[]>([]) // 消息列表 +const queryParams = reactive({ + pageNo: 1, // 当前页数 + pageSize: 14, // 每页显示多少条 + accountId: accountId +}) + +// 由于微信不再提供昵称,直接使用“用户”展示 +const user: User = reactive({ + nickname: '用户', + avatar: profile, + accountId: accountId // 公众号账号编号 +}) + +// ========= 消息发送 ========= +const sendLoading = ref(false) // 发送消息是否加载中 +// 微信发送消息 +const reply = ref<Reply>({ + type: ReplyType.Text, + accountId: -1, + articles: [] +}) + +const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null) // WxReplySelect组件ref,用于消息发送成功后清除内容 +const msgDivRef = ref<HTMLDivElement | null>(null) // 消息显示窗口ref,用于滚动到底部 + +/** 完成加载 */ +onMounted(async () => { + const data = await getUser(props.userId) + user.nickname = data.nickname?.length > 0 ? data.nickname : user.nickname + user.avatar = user.avatar?.length > 0 ? data.avatar : user.avatar + accountId.value = data.accountId + reply.value.accountId = data.accountId + + refreshChange() +}) + +// 执行发送 +const sendMsg = async () => { + if (!unref(reply)) { + return + } + // 公众号限制:客服消息,公众号只允许发送一条 + if ( + reply.value.type === ReplyType.News && + reply.value.articles && + reply.value.articles.length > 1 + ) { + reply.value.articles = [reply.value.articles[0]] + message.success('图文消息条数限制在 1 条以内,已默认发送第一条') + } + + const data = await sendMessage({ userId: props.userId, ...reply.value }) + sendLoading.value = false + + list.value = [...list.value, ...[data]] + await scrollToBottom() + + // 发送后清空数据 + replySelectRef.value?.clear() +} + +const loadMore = () => { + queryParams.pageNo++ + getPage(queryParams, null) +} + +const getPage = async (page: any, params: any = null) => { + loading.value = true + let dataTemp = await getMessagePage( + Object.assign( + { + pageNo: page.pageNo, + pageSize: page.pageSize, + userId: props.userId, + accountId: page.accountId + }, + params + ) + ) + + const scrollHeight = msgDivRef.value?.scrollHeight ?? 0 + // 处理数据 + const data = dataTemp.list.reverse() + list.value = [...data, ...list.value] + loading.value = false + if (data.length < queryParams.pageSize || data.length === 0) { + hasMore.value = false + } + queryParams.pageNo = page.pageNo + queryParams.pageSize = page.pageSize + // 滚动到原来的位置 + if (queryParams.pageNo === 1) { + // 定位到消息底部 + await scrollToBottom() + } else if (data.length !== 0) { + // 定位滚动条 + await nextTick() + if (scrollHeight !== 0) { + if (msgDivRef.value) { + msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight - scrollHeight - 100 + } + } + } +} + +const refreshChange = () => { + getPage(queryParams) +} + +/** 定位到消息底部 */ +const scrollToBottom = async () => { + await nextTick() + if (msgDivRef.value) { + msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight + } +} +</script> + +<style lang="scss" scoped> +.msg-div { + height: 50vh; + margin-right: 10px; + margin-left: 10px; + overflow: auto; + background-color: #eaeaea; +} + +.msg-send { + padding: 10px; +} + +.send-but { + float: right; + margin-top: 8px; + margin-bottom: 8px; +} +</style> diff --git a/src/views/mp/components/wx-msg/types.ts b/src/views/mp/components/wx-msg/types.ts new file mode 100644 index 0000000..38a0ff8 --- /dev/null +++ b/src/views/mp/components/wx-msg/types.ts @@ -0,0 +1,17 @@ +export enum MsgType { + Event = 'event', + Text = 'text', + Voice = 'voice', + Image = 'image', + Video = 'video', + Link = 'link', + Location = 'location', + Music = 'music', + News = 'news' +} + +export interface User { + nickname: string + avatar: string + accountId: number +} diff --git a/src/views/mp/components/wx-music/index.ts b/src/views/mp/components/wx-music/index.ts new file mode 100644 index 0000000..c421126 --- /dev/null +++ b/src/views/mp/components/wx-music/index.ts @@ -0,0 +1,3 @@ +import WxMusic from './main.vue' + +export default WxMusic diff --git a/src/views/mp/components/wx-music/main.vue b/src/views/mp/components/wx-music/main.vue new file mode 100644 index 0000000..6b44f44 --- /dev/null +++ b/src/views/mp/components/wx-music/main.vue @@ -0,0 +1,62 @@ +<!-- + 【微信消息 - 音乐】 +--> +<template> + <div> + <el-link + type="success" + :underline="false" + target="_blank" + :href="hqMusicUrl ? hqMusicUrl : musicUrl" + > + <div + class="avue-card__body" + style="padding: 10px; background-color: #fff; border-radius: 5px" + > + <div class="avue-card__avatar"> + <img :src="thumbMediaUrl" alt="" /> + </div> + <div class="avue-card__detail"> + <div class="avue-card__title" style="margin-bottom: unset">{{ title }}</div> + <div class="avue-card__info" style="height: unset">{{ description }}</div> + </div> + </div> + </el-link> + </div> +</template> + +<script lang="ts" setup> +defineOptions({ name: 'WxMusic' }) + +const props = defineProps({ + title: { + required: false, + type: String + }, + description: { + required: false, + type: String + }, + musicUrl: { + required: false, + type: String + }, + hqMusicUrl: { + required: false, + type: String + }, + thumbMediaUrl: { + required: true, + type: String + } +}) + +defineExpose({ + musicUrl: props.musicUrl +}) +</script> + +<style lang="scss" scoped> +/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scss */ +@import url('../wx-msg/card.scss'); +</style> diff --git a/src/views/mp/components/wx-news/index.ts b/src/views/mp/components/wx-news/index.ts new file mode 100644 index 0000000..e68f4d5 --- /dev/null +++ b/src/views/mp/components/wx-news/index.ts @@ -0,0 +1,3 @@ +import WxNews from './main.vue' + +export default WxNews diff --git a/src/views/mp/components/wx-news/main.vue b/src/views/mp/components/wx-news/main.vue new file mode 100644 index 0000000..154291b --- /dev/null +++ b/src/views/mp/components/wx-news/main.vue @@ -0,0 +1,119 @@ +<!-- + - Copyright (C) 2018-2019 + - All rights reserved, Designed By www.joolun.com + 【微信消息 - 图文】 + 芋道源码: + ① 代码优化,补充注释,提升阅读性 +--> +<template> + <div class="news-home"> + <div v-for="(article, index) in articles" :key="index" class="news-div"> + <!-- 头条 --> + <a v-if="index === 0" :href="article.url" target="_blank"> + <div class="news-main"> + <div class="news-content"> + <el-image + :src="article.picUrl" + class="material-img" + style="width: 100%; height: 120px" + /> + <div class="news-content-title"> + <span>{{ article.title }}</span> + </div> + </div> + </div> + </a> + <!-- 二条/三条等等 --> + <a v-else :href="article.url" target="_blank"> + <div class="news-main-item"> + <div class="news-content-item"> + <div class="news-content-item-title">{{ article.title }}</div> + <div class="news-content-item-img"> + <img :src="article.picUrl" class="material-img" height="100%" /> + </div> + </div> + </div> + </a> + </div> + </div> +</template> + +<script lang="ts" setup> +defineOptions({ name: 'WxNews' }) + +const props = withDefaults( + defineProps<{ + articles: any[] | null + }>(), + { + articles: null + } +) + +defineExpose({ + articles: props.articles +}) +</script> + +<style lang="scss" scoped> +.news-home { + width: 100%; + margin: auto; + background-color: #fff; +} + +.news-main { + width: 100%; + margin: auto; +} + +.news-content { + position: relative; + width: 100%; + background-color: #acadae; +} + +.news-content-title { + position: absolute; + bottom: 0; + left: 0; + display: inline-block; + width: 98%; + padding: 1%; + font-size: 12px; + color: #fff; + white-space: normal; + background-color: black; + opacity: 0.65; + box-sizing: unset !important; +} + +.news-main-item { + padding: 5px 0; + background-color: #fff; + border-top: 1px solid #eaeaea; +} + +.news-content-item { + position: relative; +} + +.news-content-item-title { + display: inline-block; + width: 70%; + margin-left: 1%; + font-size: 10px; + white-space: normal; +} + +.news-content-item-img { + display: inline-block; + width: 25%; + margin-right: 1%; + background-color: #acadae; +} + +.material-img { + width: 100%; +} +</style> diff --git a/src/views/mp/components/wx-reply/components/TabImage.vue b/src/views/mp/components/wx-reply/components/TabImage.vue new file mode 100644 index 0000000..6dbfeed --- /dev/null +++ b/src/views/mp/components/wx-reply/components/TabImage.vue @@ -0,0 +1,171 @@ +<template> + <div> + <!-- 情况一:已经选择好素材、或者上传好图片 --> + <div class="select-item" v-if="reply.url"> + <img class="material-img" :src="reply.url" /> + <p class="item-name" v-if="reply.name">{{ reply.name }}</p> + <el-row class="ope-row" justify="center"> + <el-button type="danger" circle @click="onDelete"> + <Icon icon="ep:delete" /> + </el-button> + </el-row> + </div> + <!-- 情况二:未做完上述操作 --> + <el-row v-else style="text-align: center" align="middle"> + <!-- 选择素材 --> + <el-col :span="12" class="col-select"> + <el-button type="success" @click="showDialog = true"> + 素材库选择 <Icon icon="ep:circle-check" /> + </el-button> + <el-dialog + title="选择图片" + v-model="showDialog" + width="90%" + append-to-body + destroy-on-close + > + <WxMaterialSelect + type="image" + :account-id="reply.accountId" + @select-material="selectMaterial" + /> + </el-dialog> + </el-col> + <!-- 文件上传 --> + <el-col :span="12" class="col-add"> + <el-upload + :action="UPLOAD_URL" + :headers="HEADERS" + multiple + :limit="1" + :file-list="fileList" + :data="uploadData" + :before-upload="beforeImageUpload" + :on-success="onUploadSuccess" + > + <el-button type="primary">上传图片</el-button> + <template #tip> + <span> + <div class="el-upload__tip">支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M</div> + </span> + </template> + </el-upload> + </el-col> + </el-row> + </div> +</template> + +<script lang="ts" setup> +import WxMaterialSelect from '@/views/mp/components/wx-material-select' +import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload' +import type { UploadRawFile } from 'element-plus' +import { getAccessToken } from '@/utils/auth' +import { Reply } from './types' +const message = useMessage() + +const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-temporary' +const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部 + +const props = defineProps<{ + modelValue: Reply +}>() +const emit = defineEmits<{ + (e: 'update:modelValue', v: Reply) +}>() +const reply = computed<Reply>({ + get: () => props.modelValue, + set: (val) => emit('update:modelValue', val) +}) + +const showDialog = ref(false) +const fileList = ref([]) +const uploadData = reactive({ + accountId: reply.value.accountId, + type: 'image', + title: '', + introduction: '' +}) + +const beforeImageUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Image, 2)(rawFile) + +const onUploadSuccess = (res: any) => { + if (res.code !== 0) { + message.error('上传出错:' + res.msg) + return false + } + + // 清空上传时的各种数据 + fileList.value = [] + uploadData.title = '' + uploadData.introduction = '' + + // 上传好的文件,本质是个素材,所以可以进行选中 + selectMaterial(res.data) +} + +const onDelete = () => { + reply.value.mediaId = null + reply.value.url = null + reply.value.name = null +} + +const selectMaterial = (item) => { + showDialog.value = false + + // reply.value.type = 'image' + reply.value.mediaId = item.mediaId + reply.value.url = item.url + reply.value.name = item.name +} +</script> + +<style lang="scss" scoped> +.select-item { + width: 280px; + padding: 10px; + margin: 0 auto 10px; + border: 1px solid #eaeaea; + + .material-img { + width: 100%; + } + + .item-name { + overflow: hidden; + font-size: 12px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + + .item-infos { + width: 30%; + margin: auto; + } + + .ope-row { + padding-top: 10px; + text-align: center; + } + } + + .col-select { + width: 49.5%; + height: 160px; + padding: 50px 0; + border: 1px solid rgb(234 234 234); + } + + .col-add { + float: right; + width: 49.5%; + height: 160px; + padding: 50px 0; + border: 1px solid rgb(234 234 234); + + .el-upload__tip { + line-height: 18px; + text-align: center; + } + } +} +</style> diff --git a/src/views/mp/components/wx-reply/components/TabMusic.vue b/src/views/mp/components/wx-reply/components/TabMusic.vue new file mode 100644 index 0000000..6421d24 --- /dev/null +++ b/src/views/mp/components/wx-reply/components/TabMusic.vue @@ -0,0 +1,116 @@ +<template> + <div> + <el-row align="middle" justify="center"> + <el-col :span="6"> + <el-row align="middle" justify="center" class="thumb-div"> + <el-col :span="24"> + <el-row align="middle" justify="center"> + <img style="width: 100px" v-if="reply.thumbMediaUrl" :src="reply.thumbMediaUrl" /> + <icon v-else icon="ep:plus" /> + </el-row> + <el-row align="middle" justify="center" style="margin-top: 2%"> + <div class="thumb-but"> + <el-upload + :action="UPLOAD_URL" + :headers="HEADERS" + multiple + :limit="1" + :file-list="fileList" + :data="uploadData" + :before-upload="beforeImageUpload" + :on-success="onUploadSuccess" + > + <template #trigger> + <el-button type="primary" link>本地上传</el-button> + </template> + <el-button type="primary" link @click="showDialog = true" style="margin-left: 5px" + >素材库选择 + </el-button> + </el-upload> + </div> + </el-row> + </el-col> + </el-row> + <el-dialog + title="选择图片" + v-model="showDialog" + width="80%" + append-to-body + destroy-on-close + > + <WxMaterialSelect + type="image" + :account-id="reply.accountId" + @select-material="selectMaterial" + /> + </el-dialog> + </el-col> + <el-col :span="18"> + <el-input v-model="reply.title" placeholder="请输入标题" /> + <div style="margin: 20px 0"></div> + <el-input v-model="reply.description" placeholder="请输入描述" /> + </el-col> + </el-row> + <div style="margin: 20px 0"></div> + <el-input v-model="reply.musicUrl" placeholder="请输入音乐链接" /> + <div style="margin: 20px 0"></div> + <el-input v-model="reply.hqMusicUrl" placeholder="请输入高质量音乐链接" /> + </div> +</template> + +<script lang="ts" setup> +import WxMaterialSelect from '@/views/mp/components/wx-material-select' +import type { UploadRawFile } from 'element-plus' +import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload' +import { getAccessToken } from '@/utils/auth' +import { Reply } from './types' + +const message = useMessage() + +const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-temporary' +const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部 + +const props = defineProps<{ + modelValue: Reply +}>() +const emit = defineEmits<{ + (e: 'update:modelValue', v: Reply) +}>() +const reply = computed<Reply>({ + get: () => props.modelValue, + set: (val) => emit('update:modelValue', val) +}) + +const showDialog = ref(false) +const fileList = ref([]) +const uploadData = reactive({ + accountId: reply.value.accountId, + type: 'thumb', // 音乐类型为thumb + title: '', + introduction: '' +}) + +const beforeImageUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Image, 2)(rawFile) + +const onUploadSuccess = (res: any) => { + if (res.code !== 0) { + message.error('上传出错:' + res.msg) + return false + } + + // 清空上传时的各种数据 + fileList.value = [] + uploadData.title = '' + uploadData.introduction = '' + + // 上传好的文件,本质是个素材,所以可以进行选中 + selectMaterial(res.data) +} + +const selectMaterial = (item: any) => { + showDialog.value = false + + reply.value.thumbMediaId = item.mediaId + reply.value.thumbMediaUrl = item.url +} +</script> diff --git a/src/views/mp/components/wx-reply/components/TabNews.vue b/src/views/mp/components/wx-reply/components/TabNews.vue new file mode 100644 index 0000000..565b1fb --- /dev/null +++ b/src/views/mp/components/wx-reply/components/TabNews.vue @@ -0,0 +1,76 @@ +<template> + <div> + <el-row> + <div class="select-item" v-if="reply.articles && reply.articles.length > 0"> + <WxNews :articles="reply.articles" /> + <el-col class="ope-row"> + <el-button type="danger" circle @click="onDelete"> + <Icon icon="ep:delete" /> + </el-button> + </el-col> + </div> + <!-- 选择素材 --> + <el-col :span="24" v-if="!reply.content"> + <el-row style="text-align: center" align="middle"> + <el-col :span="24"> + <el-button type="success" @click="showDialog = true"> + {{ newsType === NewsType.Published ? '选择已发布图文' : '选择草稿箱图文' }} + <Icon icon="ep:circle-check" /> + </el-button> + </el-col> + </el-row> + </el-col> + <el-dialog title="选择图文" v-model="showDialog" width="90%" append-to-body destroy-on-close> + <WxMaterialSelect + type="news" + :account-id="reply.accountId" + :newsType="newsType" + @select-material="selectMaterial" + /> + </el-dialog> + </el-row> + </div> +</template> + +<script lang="ts" setup> +import WxNews from '@/views/mp/components/wx-news' +import WxMaterialSelect from '@/views/mp/components/wx-material-select' +import { Reply, NewsType } from './types' + +const props = defineProps<{ + modelValue: Reply + newsType: NewsType +}>() +const emit = defineEmits<{ + (e: 'update:modelValue', v: Reply) +}>() +const reply = computed<Reply>({ + get: () => props.modelValue, + set: (val) => emit('update:modelValue', val) +}) + +const showDialog = ref(false) + +const selectMaterial = (item: any) => { + showDialog.value = false + reply.value.articles = item.content.newsItem +} + +const onDelete = () => { + reply.value.articles = [] +} +</script> + +<style lang="scss" scoped> +.select-item { + width: 280px; + padding: 10px; + margin: 0 auto 10px; + border: 1px solid #eaeaea; + + .ope-row { + padding-top: 10px; + text-align: center; + } +} +</style> diff --git a/src/views/mp/components/wx-reply/components/TabText.vue b/src/views/mp/components/wx-reply/components/TabText.vue new file mode 100644 index 0000000..307e48f --- /dev/null +++ b/src/views/mp/components/wx-reply/components/TabText.vue @@ -0,0 +1,22 @@ +<template> + <el-input type="textarea" :rows="5" placeholder="请输入内容" v-model="content" /> +</template> + +<script lang="ts" setup> +const props = defineProps<{ + modelValue?: string | null +}>() + +const emit = defineEmits<{ + (e: 'update:modelValue', v: string | null) + (e: 'input', v: string | null) +}>() + +const content = computed<string | null | undefined>({ + get: () => props.modelValue, + set: (val: string | null) => { + emit('update:modelValue', val) + emit('input', val) + } +}) +</script> diff --git a/src/views/mp/components/wx-reply/components/TabVideo.vue b/src/views/mp/components/wx-reply/components/TabVideo.vue new file mode 100644 index 0000000..adb8fa3 --- /dev/null +++ b/src/views/mp/components/wx-reply/components/TabVideo.vue @@ -0,0 +1,128 @@ +<template> + <div> + <el-row> + <el-input v-model="reply.title" class="input-margin-bottom" placeholder="请输入标题" /> + <el-input class="input-margin-bottom" v-model="reply.description" placeholder="请输入描述" /> + <el-row class="ope-row" justify="center"> + <WxVideoPlayer v-if="reply.url" :url="reply.url" /> + </el-row> + <el-col> + <el-row style="text-align: center" align="middle"> + <!-- 选择素材 --> + <el-col :span="12"> + <el-button type="success" @click="showDialog = true"> + 素材库选择 <Icon icon="ep:circle-check" /> + </el-button> + <el-dialog + title="选择视频" + v-model="showDialog" + width="90%" + append-to-body + destroy-on-close + > + <WxMaterialSelect + type="video" + :account-id="reply.accountId" + @select-material="selectMaterial" + /> + </el-dialog> + </el-col> + <!-- 文件上传 --> + <el-col :span="12"> + <el-upload + :action="UPLOAD_URL" + :headers="HEADERS" + multiple + :limit="1" + :file-list="fileList" + :data="uploadData" + :before-upload="beforeVideoUpload" + :on-success="onUploadSuccess" + > + <el-button type="primary">新建视频 <Icon icon="ep:upload" /></el-button> + </el-upload> + </el-col> + </el-row> + </el-col> + </el-row> + </div> +</template> + +<script lang="ts" setup> +import WxVideoPlayer from '@/views/mp/components/wx-video-play' +import WxMaterialSelect from '@/views/mp/components/wx-material-select' +import type { UploadRawFile } from 'element-plus' +import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload' +import { getAccessToken } from '@/utils/auth' +import { Reply } from './types' + +const message = useMessage() + +const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-temporary' +const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } + +const props = defineProps<{ + modelValue: Reply +}>() +const emit = defineEmits<{ + (e: 'update:modelValue', v: Reply) +}>() +const reply = computed<Reply>({ + get: () => props.modelValue, + set: (val) => emit('update:modelValue', val) +}) + +const showDialog = ref(false) +const fileList = ref([]) +const uploadData = reactive({ + accountId: reply.value.accountId, + type: 'video', + title: '', + introduction: '' +}) + +const beforeVideoUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Video, 10)(rawFile) + +const onUploadSuccess = (res: any) => { + if (res.code !== 0) { + message.error('上传出错:' + res.msg) + return false + } + + // 清空上传时的各种数据 + fileList.value = [] + uploadData.title = '' + uploadData.introduction = '' + + selectMaterial(res.data) +} + +/** 选择素材后设置 */ +const selectMaterial = (item: any) => { + showDialog.value = false + + reply.value.mediaId = item.mediaId + reply.value.url = item.url + reply.value.name = item.name + + // title、introduction:从 item 到 tempObjItem,因为素材里有 title、introduction + if (item.title) { + reply.value.title = item.title || '' + } + if (item.introduction) { + reply.value.description = item.introduction || '' + } +} +</script> + +<style lang="scss" scoped> +.input-margin-bottom { + margin-bottom: 2%; +} + +.ope-row { + width: 100%; + padding-top: 10px; + text-align: center; +} +</style> diff --git a/src/views/mp/components/wx-reply/components/TabVoice.vue b/src/views/mp/components/wx-reply/components/TabVoice.vue new file mode 100644 index 0000000..5dbe9a0 --- /dev/null +++ b/src/views/mp/components/wx-reply/components/TabVoice.vue @@ -0,0 +1,160 @@ +<template> + <div> + <div class="select-item2" v-if="reply.url"> + <p class="item-name">{{ reply.name }}</p> + <el-row class="ope-row" justify="center"> + <WxVoicePlayer :url="reply.url" /> + </el-row> + <el-row class="ope-row" justify="center"> + <el-button type="danger" circle @click="onDelete"><Icon icon="ep:delete" /></el-button> + </el-row> + </div> + <el-row v-else style="text-align: center"> + <!-- 选择素材 --> + <el-col :span="12" class="col-select"> + <el-button type="success" @click="showDialog = true"> + 素材库选择<Icon icon="ep:circle-check" /> + </el-button> + <el-dialog + title="选择语音" + v-model="showDialog" + width="90%" + append-to-body + destroy-on-close + > + <WxMaterialSelect + type="voice" + :account-id="reply.accountId" + @select-material="selectMaterial" + /> + </el-dialog> + </el-col> + <!-- 文件上传 --> + <el-col :span="12" class="col-add"> + <el-upload + :action="UPLOAD_URL" + :headers="HEADERS" + multiple + :limit="1" + :file-list="fileList" + :data="uploadData" + :before-upload="beforeVoiceUpload" + :on-success="onUploadSuccess" + > + <el-button type="primary">点击上传</el-button> + <template #tip> + <div class="el-upload__tip"> + 格式支持 mp3/wma/wav/amr,文件大小不超过 2M,播放长度不超过 60s + </div> + </template> + </el-upload> + </el-col> + </el-row> + </div> +</template> +<script lang="ts" setup> +import WxMaterialSelect from '@/views/mp/components/wx-material-select' +import WxVoicePlayer from '@/views/mp/components/wx-voice-play' +import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload' +import type { UploadRawFile } from 'element-plus' +import { getAccessToken } from '@/utils/auth' +import { Reply } from './types' +const message = useMessage() + +const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-temporary' +const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部 + +const props = defineProps<{ + modelValue: Reply +}>() +const emit = defineEmits<{ + (e: 'update:modelValue', v: Reply) +}>() +const reply = computed<Reply>({ + get: () => props.modelValue, + set: (val) => emit('update:modelValue', val) +}) + +const showDialog = ref(false) +const fileList = ref([]) +const uploadData = reactive({ + accountId: reply.value.accountId, + type: 'voice', + title: '', + introduction: '' +}) + +const beforeVoiceUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Voice, 10)(rawFile) + +const onUploadSuccess = (res: any) => { + if (res.code !== 0) { + message.error('上传出错:' + res.msg) + return false + } + + // 清空上传时的各种数据 + fileList.value = [] + uploadData.title = '' + uploadData.introduction = '' + + // 上传好的文件,本质是个素材,所以可以进行选中 + selectMaterial(res.data) +} + +const onDelete = () => { + reply.value.mediaId = null + reply.value.url = null + reply.value.name = null +} + +const selectMaterial = (item: Reply) => { + showDialog.value = false + + // reply.value.type = ReplyType.Voice + reply.value.mediaId = item.mediaId + reply.value.url = item.url + reply.value.name = item.name +} +</script> + +<style lang="scss" scoped> +.select-item2 { + padding: 10px; + margin: 0 auto 10px; + border: 1px solid #eaeaea; + + .item-name { + overflow: hidden; + font-size: 12px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + + .ope-row { + width: 100%; + padding-top: 10px; + text-align: center; + } + } + + .col-select { + width: 49.5%; + height: 160px; + padding: 50px 0; + border: 1px solid rgb(234 234 234); + } + + .col-add { + float: right; + width: 49.5%; + height: 160px; + padding: 50px 0; + border: 1px solid rgb(234 234 234); + + .el-upload__tip { + line-height: 18px; + text-align: center; + } + } +} +</style> diff --git a/src/views/mp/components/wx-reply/components/types.ts b/src/views/mp/components/wx-reply/components/types.ts new file mode 100644 index 0000000..3e07d6e --- /dev/null +++ b/src/views/mp/components/wx-reply/components/types.ts @@ -0,0 +1,54 @@ +enum ReplyType { + News = 'news', + Image = 'image', + Voice = 'voice', + Video = 'video', + Music = 'music', + Text = 'text' +} + +interface _Reply { + accountId: number + type: ReplyType + name?: string | null + content?: string | null + mediaId?: string | null + url?: string | null + title?: string | null + description?: string | null + thumbMediaId?: string | null + thumbMediaUrl?: string | null + musicUrl?: string | null + hqMusicUrl?: string | null + introduction?: string | null + articles?: any[] +} + +type Reply = _Reply //Partial<_Reply> + +enum NewsType { + Published = '1', + Draft = '2' +} + +/** 利用旧的reply[accountId, type]初始化新的Reply */ +const createEmptyReply = (old: Reply | Ref<Reply>): Reply => { + return { + accountId: unref(old).accountId, + type: unref(old).type, + name: null, + content: null, + mediaId: null, + url: null, + title: null, + description: null, + thumbMediaId: null, + thumbMediaUrl: null, + musicUrl: null, + hqMusicUrl: null, + introduction: null, + articles: [] + } +} + +export { Reply, NewsType, ReplyType, createEmptyReply } diff --git a/src/views/mp/components/wx-reply/index.ts b/src/views/mp/components/wx-reply/index.ts new file mode 100644 index 0000000..d1da217 --- /dev/null +++ b/src/views/mp/components/wx-reply/index.ts @@ -0,0 +1,7 @@ +import { Reply, NewsType, ReplyType, createEmptyReply } from './components/types' + +import WxReplySelect from './main.vue' + +export type { Reply } +export { createEmptyReply, NewsType, ReplyType } +export default WxReplySelect diff --git a/src/views/mp/components/wx-reply/main.vue b/src/views/mp/components/wx-reply/main.vue new file mode 100644 index 0000000..2c9d5f2 --- /dev/null +++ b/src/views/mp/components/wx-reply/main.vue @@ -0,0 +1,208 @@ +<!-- + - Copyright (C) 2018-2019 + - All rights reserved, Designed By www.joolun.com + 芋道源码: + ① 移除多余的 rep 为前缀的变量,让 message 消息更简单 + ② 代码优化,补充注释,提升阅读性 + ③ 优化消息的临时缓存策略,发送消息时,只清理被发送消息的 tab,不会强制切回到 text 输入 + ④ 支持发送【视频】消息时,支持新建视频 +--> +<template> + <el-tabs type="border-card" v-model="currentTab"> + <!-- 类型 1:文本 --> + <el-tab-pane :name="ReplyType.Text"> + <template #label> + <el-row align="middle"><Icon icon="ep:document" /> 文本</el-row> + </template> + <TabText v-model="reply.content" /> + </el-tab-pane> + + <!-- 类型 2:图片 --> + <el-tab-pane :name="ReplyType.Image"> + <template #label> + <el-row align="middle"><Icon icon="ep:picture" class="mr-5px" /> 图片</el-row> + </template> + <TabImage v-model="reply" /> + </el-tab-pane> + + <!-- 类型 3:语音 --> + <el-tab-pane :name="ReplyType.Voice"> + <template #label> + <el-row align="middle"><Icon icon="ep:phone" /> 语音</el-row> + </template> + <TabVoice v-model="reply" /> + </el-tab-pane> + + <!-- 类型 4:视频 --> + <el-tab-pane :name="ReplyType.Video"> + <template #label> + <el-row align="middle"><Icon icon="ep:share" /> 视频</el-row> + </template> + <TabVideo v-model="reply" /> + </el-tab-pane> + + <!-- 类型 5:图文 --> + <el-tab-pane :name="ReplyType.News"> + <template #label> + <el-row align="middle"><Icon icon="ep:reading" /> 图文</el-row> + </template> + <TabNews v-model="reply" :news-type="newsType" /> + </el-tab-pane> + + <!-- 类型 6:音乐 --> + <el-tab-pane :name="ReplyType.Music"> + <template #label> + <el-row align="middle"><Icon icon="ep:service" />音乐</el-row> + </template> + <TabMusic v-model="reply" /> + </el-tab-pane> + </el-tabs> +</template> + +<script lang="ts" setup> +import { Reply, NewsType, ReplyType, createEmptyReply } from './components/types' +import TabText from './components/TabText.vue' +import TabImage from './components/TabImage.vue' +import TabVoice from './components/TabVoice.vue' +import TabVideo from './components/TabVideo.vue' +import TabNews from './components/TabNews.vue' +import TabMusic from './components/TabMusic.vue' + +defineOptions({ name: 'WxReplySelect' }) + +interface Props { + modelValue: Reply + newsType?: NewsType +} +const props = withDefaults(defineProps<Props>(), { + newsType: () => NewsType.Published +}) +const emit = defineEmits<{ + (e: 'update:modelValue', v: Reply) +}>() + +const reply = computed<Reply>({ + get: () => props.modelValue, + set: (val) => emit('update:modelValue', val) +}) +// 作为多个标签保存各自Reply的缓存 +const tabCache = new Map<ReplyType, Reply>() +// 采用独立的ref来保存当前tab,避免在watch标签变化,对reply进行赋值会产生了循环调用 +const currentTab = ref<ReplyType>(props.modelValue.type || ReplyType.Text) + +watch( + currentTab, + (newTab, oldTab) => { + // 第一次进入:oldTab 为 undefined + // 判断 newTab 是因为 Reply 为 Partial + if (oldTab === undefined || newTab === undefined) { + return + } + + tabCache.set(oldTab, unref(reply)) + + // 从缓存里面取出新tab内容,有则覆盖Reply,没有则创建空Reply + const temp = tabCache.get(newTab) + if (temp) { + reply.value = temp + } else { + let newData = createEmptyReply(reply) + newData.type = newTab + reply.value = newData + } + }, + { + immediate: true + } +) + +/** 清除除了`type`, `accountId`的字段 */ +const clear = () => { + reply.value = createEmptyReply(reply) +} + +defineExpose({ + clear +}) +</script> + +<style lang="scss" scoped> +.select-item { + width: 280px; + padding: 10px; + margin: 0 auto 10px; + border: 1px solid #eaeaea; +} + +.select-item2 { + padding: 10px; + margin: 0 auto 10px; + border: 1px solid #eaeaea; +} + +.ope-row { + padding-top: 10px; + text-align: center; +} + +.input-margin-bottom { + margin-bottom: 2%; +} + +.item-name { + overflow: hidden; + font-size: 12px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.el-form-item__content { + line-height: unset !important; +} + +.col-select { + width: 49.5%; + height: 160px; + padding: 50px 0; + border: 1px solid rgb(234 234 234); +} + +.col-select2 { + height: 160px; + padding: 50px 0; + border: 1px solid rgb(234 234 234); +} + +.col-add { + float: right; + width: 49.5%; + height: 160px; + padding: 50px 0; + border: 1px solid rgb(234 234 234); +} + +.avatar-uploader-icon { + width: 100px !important; + height: 100px !important; + font-size: 28px; + line-height: 100px !important; + color: #8c939d; + text-align: center; + border: 1px solid #d9d9d9; +} + +.material-img { + width: 100%; +} + +.thumb-div { + display: inline-block; + text-align: center; +} + +.item-infos { + width: 30%; + margin: auto; +} +</style> diff --git a/src/views/mp/components/wx-video-play/index.ts b/src/views/mp/components/wx-video-play/index.ts new file mode 100644 index 0000000..91e00ef --- /dev/null +++ b/src/views/mp/components/wx-video-play/index.ts @@ -0,0 +1,3 @@ +import WxVideoPlayer from './main.vue' + +export default WxVideoPlayer diff --git a/src/views/mp/components/wx-video-play/main.vue b/src/views/mp/components/wx-video-play/main.vue new file mode 100644 index 0000000..d544bbe --- /dev/null +++ b/src/views/mp/components/wx-video-play/main.vue @@ -0,0 +1,73 @@ +<!-- + - Copyright (C) 2018-2019 + - All rights reserved, Designed By www.joolun.com + 【微信消息 - 视频】 + 芋道源码: + ① bug 修复: + 1)joolun 的做法:使用 mediaId 从微信公众号,下载对应的 mp4 素材,从而播放内容; + 存在的问题:mediaId 有效期是 3 天,超过时间后无法播放 + 2)重构后的做法:后端接收到微信公众号的视频消息后,将视频消息的 media_id 的文件内容保存到文件服务器中,这样前端可以直接使用 URL 播放。 + ② 体验优化:弹窗关闭后,自动暂停视频的播放 + +--> +<template> + <div @click="playVideo()"> + <!-- 提示 --> + <div> + <Icon icon="ep:video-play" :size="32" class="mr-5px" /> + <p class="text-sm">点击播放视频</p> + </div> + + <!-- 弹窗播放 --> + <el-dialog v-model="dialogVideo" title="视频播放" append-to-body> + <video-player + v-if="dialogVideo" + class="video-player vjs-big-play-centered" + :src="props.url" + poster="" + crossorigin="anonymous" + controls + playsinline + :volume="0.6" + :width="800" + :playback-rates="[0.7, 1.0, 1.5, 2.0]" + /> + <!-- 事件,暫時沒用 + @mounted="handleMounted"--> + <!-- @ready="handleEvent($event)"--> + <!-- @play="handleEvent($event)"--> + <!-- @pause="handleEvent($event)"--> + <!-- @ended="handleEvent($event)"--> + <!-- @loadeddata="handleEvent($event)"--> + <!-- @waiting="handleEvent($event)"--> + <!-- @playing="handleEvent($event)"--> + <!-- @canplay="handleEvent($event)"--> + <!-- @canplaythrough="handleEvent($event)"--> + <!-- @timeupdate="handleEvent(player?.currentTime())"--> + </el-dialog> + </div> +</template> + +<script lang="ts" setup> +import 'video.js/dist/video-js.css' +import { VideoPlayer } from '@videojs-player/vue' + +defineOptions({ name: 'WxVideoPlayer' }) + +const props = defineProps({ + url: { + type: String, + required: true + } +}) + +const dialogVideo = ref(false) + +// const handleEvent = (log) => { +// console.log('Basic player event', log) +// } + +const playVideo = () => { + dialogVideo.value = true +} +</script> diff --git a/src/views/mp/components/wx-voice-play/index.ts b/src/views/mp/components/wx-voice-play/index.ts new file mode 100644 index 0000000..9eb78e0 --- /dev/null +++ b/src/views/mp/components/wx-voice-play/index.ts @@ -0,0 +1,3 @@ +import WxVoicePlayer from './main.vue' + +export default WxVoicePlayer diff --git a/src/views/mp/components/wx-voice-play/main.vue b/src/views/mp/components/wx-voice-play/main.vue new file mode 100644 index 0000000..fe7f0ca --- /dev/null +++ b/src/views/mp/components/wx-voice-play/main.vue @@ -0,0 +1,105 @@ +<!-- + - Copyright (C) 2018-2019 + - All rights reserved, Designed By www.joolun.com + 【微信消息 - 语音】 + 芋道源码: + ① bug 修复: + 1)joolun 的做法:使用 mediaId 从微信公众号,下载对应的 mp4 素材,从而播放内容; + 存在的问题:mediaId 有效期是 3 天,超过时间后无法播放 + 2)重构后的做法:后端接收到微信公众号的视频消息后,将视频消息的 media_id 的文件内容保存到文件服务器中,这样前端可以直接使用 URL 播放。 + ② 代码优化:将 props 中的 reply 调成为 data 中对应的属性,并补充相关注释 +--> +<template> + <div class="wx-voice-div" @click="playVoice"> + <el-icon> + <Icon v-if="playing !== true" icon="ep:video-play" :size="32" /> + <Icon v-else icon="ep:video-pause" :size="32" /> + <span class="amr-duration" v-if="duration">{{ duration }} 秒</span> + </el-icon> + <div v-if="content"> + <el-tag type="success" size="small">语音识别</el-tag> + {{ content }} + </div> + </div> +</template> + +<script lang="ts" setup> +// 因为微信语音是 amr 格式,所以需要用到 amr 解码器:https://www.npmjs.com/package/benz-amr-recorder +import BenzAMRRecorder from 'benz-amr-recorder' + +defineOptions({ name: 'WxVoicePlayer' }) + +const props = defineProps({ + url: { + type: String, // 语音地址,例如说:https://www.iocoder.cn/xxx.amr + required: true + }, + content: { + type: String, // 语音文本 + required: false + } +}) + +const amr = ref() +const playing = ref(false) +const duration = ref() + +/** 处理点击,播放或暂停 */ +const playVoice = () => { + // 情况一:未初始化,则创建 BenzAMRRecorder + if (amr.value === undefined) { + amrInit() + return + } + // 情况二:已经初始化,则根据情况播放或暂时 + if (amr.value.isPlaying()) { + amrStop() + } else { + amrPlay() + } +} + +/** 音频初始化 */ +const amrInit = () => { + amr.value = new BenzAMRRecorder() + // 设置播放 + amr.value.initWithUrl(props.url).then(function () { + amrPlay() + duration.value = amr.value.getDuration() + }) + // 监听暂停 + amr.value.onEnded(function () { + playing.value = false + }) +} + +/** 音频播放 */ +const amrPlay = () => { + playing.value = true + amr.value.play() +} + +/** 音频暂停 */ +const amrStop = () => { + playing.value = false + amr.value.stop() +} +// TODO 芋艿:下面样式有点问题 +</script> +<style lang="scss" scoped> +.wx-voice-div { + display: flex; + width: 120px; + height: 50px; + padding: 5px; + background-color: #eaeaea; + border-radius: 10px; + justify-content: center; + align-items: center; +} + +.amr-duration { + margin-left: 5px; + font-size: 11px; +} +</style> diff --git a/src/views/mp/draft/components/CoverSelect.vue b/src/views/mp/draft/components/CoverSelect.vue new file mode 100644 index 0000000..499f1a6 --- /dev/null +++ b/src/views/mp/draft/components/CoverSelect.vue @@ -0,0 +1,166 @@ +<template> + <div> + <p>封面:</p> + <div class="thumb-div"> + <el-image + v-if="newsItem.thumbUrl" + style="width: 300px; max-height: 300px" + :src="newsItem.thumbUrl" + fit="contain" + /> + <Icon + v-else + icon="ep:plus" + class="avatar-uploader-icon" + :class="isFirst ? 'avatar' : 'avatar1'" + /> + <div class="thumb-but"> + <el-upload + :action="UPLOAD_URL" + :headers="HEADERS" + multiple + :limit="1" + :file-list="fileList" + :data="uploadData" + :before-upload="onBeforeUpload" + :on-error="onUploadError" + :on-success="onUploadSuccess" + > + <template #trigger> + <el-button size="small" type="primary">本地上传</el-button> + </template> + <el-button + size="small" + type="primary" + @click="showImageDialog = true" + style="margin-left: 5px" + > + 素材库选择 + </el-button> + <template #tip> + <div class="el-upload__tip">支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M</div> + </template> + </el-upload> + </div> + <el-dialog + title="选择图片" + v-model="showImageDialog" + width="80%" + append-to-body + destroy-on-close + > + <WxMaterialSelect + type="image" + :account-id="accountId!" + @select-material="onMaterialSelected" + /> + </el-dialog> + </div> + </div> +</template> + +<script lang="ts" setup> +import WxMaterialSelect from '@/views/mp/components/wx-material-select' +import { getAccessToken } from '@/utils/auth' +import type { UploadFiles, UploadProps, UploadRawFile } from 'element-plus' +import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload' +import { NewsItem } from './types' +const message = useMessage() + +const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传永久素材的地址 +const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部 + +const props = defineProps<{ + modelValue: NewsItem + isFirst: boolean +}>() + +const emit = defineEmits<{ + (e: 'update:modelValue', v: NewsItem) +}>() +const newsItem = computed<NewsItem>({ + get() { + return props.modelValue + }, + set(val) { + emit('update:modelValue', val) + } +}) + +const accountId = inject<number>('accountId') +const showImageDialog = ref(false) + +const fileList = ref<UploadFiles>([]) +interface UploadData { + type: UploadType + accountId: number +} +const uploadData: UploadData = reactive({ + type: UploadType.Image, + accountId: accountId! +}) + +/** 素材选择完成事件*/ +const onMaterialSelected = (item: any) => { + showImageDialog.value = false + newsItem.value.thumbMediaId = item.mediaId + newsItem.value.thumbUrl = item.url +} + +const onBeforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => + useBeforeUpload(UploadType.Image, 2)(rawFile) + +const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => { + if (res.code !== 0) { + message.error('上传出错:' + res.msg) + return false + } + + // 重置上传文件的表单 + fileList.value = [] + + // 设置草稿的封面字段 + newsItem.value.thumbMediaId = res.data.mediaId + newsItem.value.thumbUrl = res.data.url +} + +const onUploadError = (err: Error) => { + message.error('上传失败: ' + err.message) +} +</script> + +<style lang="scss" scoped> +.el-upload__tip { + margin-left: 5px; +} + +.thumb-div { + display: inline-block; + width: 100%; + text-align: center; + + .avatar-uploader-icon { + width: 120px; + height: 120px; + font-size: 28px; + line-height: 120px; + color: #8c939d; + text-align: center; + border: 1px solid #d9d9d9; + } + + .avatar { + width: 230px; + height: 120px; + } + + .avatar1 { + width: 120px; + height: 120px; + } + + .thumb-but { + margin: 5px; + } +} +</style> diff --git a/src/views/mp/draft/components/DraftTable.vue b/src/views/mp/draft/components/DraftTable.vue new file mode 100644 index 0000000..bb512d8 --- /dev/null +++ b/src/views/mp/draft/components/DraftTable.vue @@ -0,0 +1,87 @@ +<template> + <div class="waterfall" v-loading="props.loading"> + <template v-for="item in props.list" :key="item.articleId"> + <div class="waterfall-item" v-if="item.content && item.content.newsItem"> + <WxNews :articles="item.content.newsItem" /> + <!-- 操作按钮 --> + <el-row> + <el-button + type="success" + circle + @click="emit('publish', item)" + v-hasPermi="['mp:free-publish:submit']" + > + <Icon icon="fa:upload" /> + </el-button> + <el-button + type="primary" + circle + @click="emit('update', item)" + v-hasPermi="['mp:draft:update']" + > + <Icon icon="ep:edit" /> + </el-button> + <el-button + type="danger" + circle + @click="emit('delete', item)" + v-hasPermi="['mp:draft:delete']" + > + <Icon icon="ep:delete" /> + </el-button> + </el-row> + </div> + </template> + </div> +</template> + +<script lang="ts" setup> +import WxNews from '@/views/mp/components/wx-news' + +import { Article } from './types' + +const props = defineProps<{ + list: Article[] + loading: boolean +}>() + +const emit = defineEmits<{ + (e: 'publish', v: Article) + (e: 'update', v: Article) + (e: 'delete', v: Article) +}>() +</script> + +<style lang="scss" scoped> +.waterfall { + width: 100%; + column-gap: 10px; + column-count: 5; + margin: 0 auto; + + .waterfall-item { + padding: 10px; + margin-bottom: 10px; + break-inside: avoid; + border: 1px solid #eaeaea; + } +} + +@media (width >= 992px) and (width <= 1300px) { + .waterfall { + column-count: 3; + } +} + +@media (width >= 768px) and (width <= 991px) { + .waterfall { + column-count: 2; + } +} + +@media (width <= 767px) { + .waterfall { + column-count: 1; + } +} +</style> diff --git a/src/views/mp/draft/components/NewsForm.vue b/src/views/mp/draft/components/NewsForm.vue new file mode 100644 index 0000000..9b1e474 --- /dev/null +++ b/src/views/mp/draft/components/NewsForm.vue @@ -0,0 +1,304 @@ +<template> + <el-container> + <el-aside width="40%"> + <div class="select-item"> + <div v-for="(news, index) in newsList" :key="index"> + <div + class="news-main father" + v-if="index === 0" + :class="{ activeAddNews: activeNewsIndex === index }" + @click="activeNewsIndex = index" + > + <div class="news-content"> + <img class="material-img" :src="news.thumbUrl" /> + <div class="news-content-title">{{ news.title }}</div> + </div> + <div class="child" v-if="newsList.length > 1"> + <el-button type="info" circle size="small" @click="() => moveDownNews(index)"> + <Icon icon="ep:arrow-down-bold" /> + </el-button> + <el-button + v-if="isCreating" + type="danger" + circle + size="small" + @click="() => removeNews(index)" + > + <Icon icon="ep:delete" /> + </el-button> + </div> + </div> + <div + class="news-main-item father" + v-if="index > 0" + :class="{ activeAddNews: activeNewsIndex === index }" + @click="activeNewsIndex = index" + > + <div class="news-content-item"> + <div class="news-content-item-title">{{ news.title }}</div> + <div class="news-content-item-img"> + <img class="material-img" :src="news.thumbUrl" width="100%" /> + </div> + </div> + <div class="child"> + <el-button + v-if="newsList.length > index + 1" + circle + type="info" + size="small" + @click="() => moveDownNews(index)" + > + <Icon icon="ep:arrow-down-bold" /> + </el-button> + <el-button + v-if="index > 0" + type="info" + circle + size="small" + @click="() => moveUpNews(index)" + > + <Icon icon="ep:arrow-up-bold" /> + </el-button> + <el-button + v-if="isCreating" + type="danger" + size="small" + circle + @click="() => removeNews(index)" + > + <Icon icon="ep:delete" /> + </el-button> + </div> + </div> + </div> + <el-row justify="center" class="ope-row"> + <el-button + type="primary" + circle + @click="plusNews" + v-if="newsList.length < 8 && isCreating" + > + <Icon icon="ep:plus" /> + </el-button> + </el-row> + </div> + </el-aside> + <el-main> + <div v-if="newsList.length > 0"> + <!-- 标题、作者、原文地址 --> + <el-row :gutter="20"> + <el-input v-model="activeNewsItem.title" placeholder="请输入标题(必填)" /> + <el-input + v-model="activeNewsItem.author" + placeholder="请输入作者" + style="margin-top: 5px" + /> + <el-input + v-model="activeNewsItem.contentSourceUrl" + placeholder="请输入原文地址" + style="margin-top: 5px" + /> + </el-row> + <!-- 封面和摘要 --> + <el-row :gutter="20"> + <el-col :span="12"> + <CoverSelect v-model="activeNewsItem" :is-first="activeNewsIndex === 0" /> + </el-col> + <el-col :span="12"> + <p>摘要:</p> + <el-input + :rows="8" + type="textarea" + v-model="activeNewsItem.digest" + placeholder="请输入摘要" + class="digest" + maxlength="120" + /> + </el-col> + </el-row> + <!--富文本编辑器组件--> + <el-row> + <Editor v-model="activeNewsItem.content" :editor-config="editorConfig" /> + </el-row> + </div> + </el-main> + </el-container> +</template> + +<script lang="ts" setup> +import { Editor } from '@/components/Editor' +import { createEditorConfig } from '../editor-config' +import CoverSelect from './CoverSelect.vue' +import { type NewsItem, createEmptyNewsItem } from './types' + +defineOptions({ name: 'NewsForm' }) + +const message = useMessage() + +const props = defineProps<{ + isCreating: boolean + modelValue: NewsItem[] | null +}>() + +const accountId = inject<number>('accountId') + +// ========== 文件上传 ========== +const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传永久素材的地址 +const editorConfig = createEditorConfig(UPLOAD_URL, accountId) + +// v-model=newsList +const emit = defineEmits<{ + (e: 'update:modelValue', v: NewsItem[]) +}>() +const newsList = computed<NewsItem[]>({ + get() { + return props.modelValue === null ? [createEmptyNewsItem()] : props.modelValue + }, + set(val) { + emit('update:modelValue', val) + } +}) + +const activeNewsIndex = ref(0) +const activeNewsItem = computed<NewsItem>(() => newsList.value[activeNewsIndex.value]) + +// 将图文向下移动 +const moveDownNews = (index: number) => { + const temp = newsList.value[index] + newsList.value[index] = newsList.value[index + 1] + newsList.value[index + 1] = temp + activeNewsIndex.value = index + 1 +} + +// 将图文向上移动 +const moveUpNews = (index: number) => { + const temp = newsList.value[index] + newsList.value[index] = newsList.value[index - 1] + newsList.value[index - 1] = temp + activeNewsIndex.value = index - 1 +} + +// 删除指定 index 的图文 +const removeNews = async (index: number) => { + try { + await message.confirm('确定删除该图文吗?') + newsList.value.splice(index, 1) + if (activeNewsIndex.value === index) { + activeNewsIndex.value = 0 + } + } catch {} +} + +// 添加一个图文 +const plusNews = () => { + newsList.value.push(createEmptyNewsItem()) + activeNewsIndex.value = newsList.value.length - 1 +} +</script> + +<style lang="scss" scoped> +.ope-row { + padding-top: 5px; + margin-top: 5px; + text-align: center; + border-top: 1px solid #eaeaea; +} + +.el-row { + margin-bottom: 20px; +} + +.el-row:last-child { + margin-bottom: 0; +} + +.digest { + display: inline-block; + width: 100%; + vertical-align: top; +} + +/* 新增图文 */ +.news-main { + width: 100%; + height: 120px; + margin: auto; + background-color: #fff; +} + +.news-content { + position: relative; + width: 100%; + height: 120px; + background-color: #acadae; +} + +.news-content-title { + position: absolute; + bottom: 0; + left: 0; + display: inline-block; + width: 98%; + height: 25px; + padding: 1%; + overflow: hidden; + font-size: 15px; + color: #fff; + text-overflow: ellipsis; + white-space: nowrap; + background-color: black; + opacity: 0.65; +} + +.news-main-item { + width: 100%; + padding: 5px 0; + margin: auto; + background-color: #fff; + border-top: 1px solid #eaeaea; +} + +.news-content-item { + position: relative; + margin-left: -3px; +} + +.news-content-item-title { + display: inline-block; + width: 70%; + font-size: 12px; +} + +.news-content-item-img { + display: inline-block; + width: 25%; + background-color: #acadae; +} + +.select-item { + width: 60%; + padding: 10px; + margin: 0 auto 10px; + border: 1px solid #eaeaea; + + .activeAddNews { + border: 5px solid #2bb673; + } +} + +.father .child { + position: relative; + bottom: 25px; + display: none; + text-align: center; +} + +.father:hover .child { + display: block; +} + +.material-img { + width: 100%; + height: 100%; +} +</style> diff --git a/src/views/mp/draft/components/index.ts b/src/views/mp/draft/components/index.ts new file mode 100644 index 0000000..51e843d --- /dev/null +++ b/src/views/mp/draft/components/index.ts @@ -0,0 +1,7 @@ +import type { Article, NewsItem, NewsItemList } from './types' +import { createEmptyNewsItem } from './types' +import DraftTable from './DraftTable.vue' +import NewsForm from './NewsForm.vue' + +export { DraftTable, NewsForm, createEmptyNewsItem } +export type { Article, NewsItem, NewsItemList } diff --git a/src/views/mp/draft/components/types.ts b/src/views/mp/draft/components/types.ts new file mode 100644 index 0000000..a8cf00c --- /dev/null +++ b/src/views/mp/draft/components/types.ts @@ -0,0 +1,40 @@ +interface NewsItem { + title: string + thumbMediaId: string + author: string + digest: string + showCoverPic: string + content: string + contentSourceUrl: string + needOpenComment: string + onlyFansCanComment: string + thumbUrl: string +} + +interface NewsItemList { + newsItem: NewsItem[] +} + +interface Article { + mediaId: string + content: NewsItemList + updateTime: number +} + +const createEmptyNewsItem = (): NewsItem => { + return { + title: '', + thumbMediaId: '', + author: '', + digest: '', + showCoverPic: '', + content: '', + contentSourceUrl: '', + needOpenComment: '', + onlyFansCanComment: '', + thumbUrl: '' + } +} + +export type { Article, NewsItem, NewsItemList } +export { createEmptyNewsItem } diff --git a/src/views/mp/draft/editor-config.ts b/src/views/mp/draft/editor-config.ts new file mode 100644 index 0000000..ee3b95e --- /dev/null +++ b/src/views/mp/draft/editor-config.ts @@ -0,0 +1,75 @@ +import { IEditorConfig } from '@wangeditor/editor' +import { getAccessToken, getTenantId } from '@/utils/auth' + +const message = useMessage() + +type InsertFnType = (url: string, alt: string, href: string) => void + +export const createEditorConfig = ( + server: string, + accountId: number | undefined +): Partial<IEditorConfig> => { + return { + MENU_CONF: { + ['uploadImage']: { + server, + // 单个文件的最大体积限制,默认为 2M + maxFileSize: 5 * 1024 * 1024, + // 最多可上传几个文件,默认为 100 + maxNumberOfFiles: 10, + // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 [] + allowedFileTypes: ['image/*'], + + // 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。 + meta: { + accountId: accountId, + type: 'image' + }, + // 将 meta 拼接到 url 参数中,默认 false + metaWithUrl: true, + + // 自定义增加 http header + headers: { + Accept: '*', + Authorization: 'Bearer ' + getAccessToken(), + 'tenant-id': getTenantId() + }, + + // 跨域是否传递 cookie ,默认为 false + withCredentials: true, + + // 超时时间,默认为 10 秒 + timeout: 5 * 1000, // 5 秒 + + // form-data fieldName,后端接口参数名称,默认值wangeditor-uploaded-image + fieldName: 'file', + + // 上传之前触发 + onBeforeUpload(file: File) { + console.log(file) + return file + }, + // 上传进度的回调函数 + onProgress(progress: number) { + // progress 是 0-100 的数字 + console.log('progress', progress) + }, + onSuccess(file: File, res: any) { + console.log('onSuccess', file, res) + }, + onFailed(file: File, res: any) { + message.alertError(res.message) + console.log('onFailed', file, res) + }, + onError(file: File, err: any, res: any) { + message.alertError(err.message) + console.error('onError', file, err, res) + }, + // 自定义插入图片 + customInsert(res: any, insertFn: InsertFnType) { + insertFn(res.data.url, 'image', res.data.url) + } + } + } + } +} diff --git a/src/views/mp/draft/index.vue b/src/views/mp/draft/index.vue new file mode 100644 index 0000000..db24596 --- /dev/null +++ b/src/views/mp/draft/index.vue @@ -0,0 +1,202 @@ +<template> + <doc-alert title="公众号图文" url="https://doc.iocoder.cn/mp/article/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="公众号" prop="accountId"> + <WxAccountSelect @change="onAccountChanged" /> + </el-form-item> + <el-form-item> + <el-button + type="primary" + plain + @click="handleAdd" + v-hasPermi="['mp:draft:create']" + :disabled="accountId === 0" + > + <Icon icon="ep:plus" />新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <DraftTable + :loading="loading" + :list="list" + @update="onUpdate" + @delete="onDelete" + @publish="onPublish" + /> + <!-- 分页记录 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 添加或修改草稿对话框 --> + <el-dialog + :title="isCreating ? '新建图文' : '修改图文'" + width="80%" + v-model="showDialog" + :before-close="onBeforeDialogClose" + destroy-on-close + > + <NewsForm v-model="newsList" v-loading="isSubmitting" :is-creating="isCreating" /> + <template #footer> + <el-button @click="showDialog = false">取 消</el-button> + <el-button type="primary" @click="onSubmitNewsItem">提 交</el-button> + </template> + </el-dialog> +</template> + +<script lang="ts" setup> +import WxAccountSelect from '@/views/mp/components/wx-account-select' +import * as MpDraftApi from '@/api/mp/draft' +import * as MpFreePublishApi from '@/api/mp/freePublish' +import { + type Article, + type NewsItem, + NewsForm, + DraftTable, + createEmptyNewsItem +} from './components/' +// import drafts from './mock' // 可以用改本地数据模拟,避免API调用超限 + +defineOptions({ name: 'MpDraft' }) + +const message = useMessage() // 消息 + +const accountId = ref(-1) +provide('accountId', accountId) + +const loading = ref(true) // 列表的加载中 +const list = ref<any[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 + +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + accountId: accountId +}) + +// ========== 草稿新建 or 修改 ========== +const showDialog = ref(false) +const newsList = ref<NewsItem[]>([]) +const mediaId = ref('') +const isCreating = ref(true) +const isSubmitting = ref(false) + +/** 侦听公众号变化 **/ +const onAccountChanged = (id: number) => { + accountId.value = id + queryParams.pageNo = 1 + getList() +} + +// 关闭弹窗 +const onBeforeDialogClose = async (onDone: () => {}) => { + try { + await message.confirm('修改内容可能还未保存,确定关闭吗?') + onDone() + } catch {} +} + +// ======================== 列表查询 ======================== +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const drafts = await MpDraftApi.getDraftPage(queryParams) + drafts.list.forEach((draft) => { + const newsList = draft.content.newsItem + // 将 thumbUrl 转成 picUrl,保证 wx-news 组件可以预览封面 + newsList.forEach((item) => { + item.picUrl = item.thumbUrl + }) + }) + list.value = drafts.list + total.value = drafts.total + } finally { + loading.value = false + } +} + +// ======================== 新增/修改草稿 ======================== +/** 新增按钮操作 */ +const handleAdd = () => { + isCreating.value = true + newsList.value = [createEmptyNewsItem()] + showDialog.value = true +} + +/** 更新按钮操作 */ +const onUpdate = (item: Article) => { + mediaId.value = item.mediaId + newsList.value = JSON.parse(JSON.stringify(item.content.newsItem)) + isCreating.value = false + showDialog.value = true +} + +/** 提交按钮 */ +const onSubmitNewsItem = async () => { + isSubmitting.value = true + try { + if (isCreating.value) { + await MpDraftApi.createDraft(accountId.value, newsList.value) + message.notifySuccess('新增成功') + } else { + await MpDraftApi.updateDraft(accountId.value, mediaId.value, newsList.value) + message.notifySuccess('更新成功') + } + } finally { + showDialog.value = false + isSubmitting.value = false + await getList() + } +} + +// ======================== 草稿箱发布 ======================== +const onPublish = async (item: Article) => { + const mediaId = item.mediaId + const content = + '你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。' + + '已发布内容不会推送给用户,也不会展示在公众号主页中。 ' + + '发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。' + try { + await message.confirm(content) + await MpFreePublishApi.submitFreePublish(accountId.value, mediaId) + message.notifySuccess('发布成功') + await getList() + } catch {} +} + +/** 删除按钮操作 */ +const onDelete = async (item: Article) => { + const mediaId = item.mediaId + try { + await message.confirm('此操作将永久删除该草稿, 是否继续?') + await MpDraftApi.deleteDraft(accountId.value, mediaId) + message.notifySuccess('删除成功') + await getList() + } catch {} +} +</script> + +<style lang="scss" scoped> +.pagination { + float: right; + margin-right: 25px; +} +</style> diff --git a/src/views/mp/draft/mock.js b/src/views/mp/draft/mock.js new file mode 100644 index 0000000..e8493f6 --- /dev/null +++ b/src/views/mp/draft/mock.js @@ -0,0 +1,151 @@ +export default { + list: [ + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-q-G9pdsmZw0OYG4FzHQkKfpLfEwIH51wy2bxisx8PvW', + content: { + newsItem: [ + { + title: '我是标题(OOO)', + author: '我是作者', + digest: '我是摘要', + content: '我是内容', + contentSourceUrl: 'https://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9XaFphcmtJVFh3VEc4Q1MxQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN2QxTE56SFBCYXc2RE9NcUxIeS1CQjJuUHhTWjBlN2VOeGRpRi1fZUhwN1FNQjdrQV9yRU9EU0hibHREZmZoVW5acnZrN3ZjaWsxejR3RGpKczBzTHFIM0dFNFZWVkpBc0dWWlAzUEhlVmpnfn4%3D&chksm=1f6354802814dd969ef83c0f3babe555c614270b30bc383beaf7ffd13b0257f0fe5ced9af694#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png' + }, + { + title: '我是标题(XXX)', + author: '我是作者', + digest: '我是摘要', + content: '我是内容', + contentSourceUrl: 'https://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9yTlYwOEs1clpwcE5OUEhCQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN0NSMjFqN3N1aUZMbFNVLTZHN2ZDME9qOGp2THk2RFNlSTlKZ3Y1czFVZDdQQm5IeUg3dEppSUtpQUh5SExOOTRkT3dHNUdBdHdWSWlOendlREV3dS1jUEVQbFpiVTZmVW5iRWhZcGdkNTFRfn4%3D&chksm=1f6354802814dd96a403151cd44c7da4eecf0e475d25423e46ecd795b513bafd829a75daef9b#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png' + } + ] + }, + updateTime: 1673655730 + }, + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-jGpXnO73ihN0lsNXknCRQHapp2xgHMRxHKG50LituFe', + content: { + newsItem: [ + { + title: '我是标题(修改)', + author: '我是作者', + digest: '我是摘要', + content: '我是内容', + contentSourceUrl: 'https://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl95WVFXYndIZnZJd0t5cjgvQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuN1dlNURPbWswbEF4RDd5dVJTdjQ4cm9Cc0Q1TWhpMUh6SE1hVEE3ZHljaHhlZjZYSGF5N2JNSHpDTlh6ajNZbkpGTGpTcUQ4M3NMdW41ZUpXNFZZQ1VKbVlaMVp5ekxEV1czREdsY1dOYTZnfn4%3D&chksm=1f6354be2814dda8e6238037c2ebd52b1c8e80e93249a861ad80e4d40e5ca7207233475ca689#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png' + } + ] + }, + updateTime: 1673655584 + }, + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-v5SrbNCPpD6M_p3TmSrYwTjKogs-0DMJgmjMyNZPeMO', + content: { + newsItem: [ + { + title: '1321', + author: '3232', + digest: '1333', + content: '<p>444</p>', + contentSourceUrl: 'http://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-tlQmcl3RdC-Jcgns6IQtf7zenGy3b86WLT7GzUcrb1T', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9jelJiaDAzbmdpSkJOZ2M2QWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNDNXVVc2ZDRYeTY0Zm1weXR6dE9vQWh1TzEwbEpUVnRfVzJyaGFDNXBkZ0ZXM2JFOTNaRHNhOHRUeFdEanhMeS01X01kMUNWQ1BpRER3cjYwTl9pMnpFLUJhZXFucVVfM1pDUXlTUEl1S25nfn4%3D&chksm=1f6354bc2814ddaa56a90ad5bc3d078601c8d1589ba01827a8170587bc830ff9747b5f59c3a0#rd', + thumbUrl: + 'http://mmbiz.qpic.cn/mmbiz_png/btUmCVHwbJUoicwBiacjVeQbu6QxgBVrukfSJXz509boa21SpH8OVHAqXCJiaiaAaHQJNxwwsa0gHRXVr0G5EZYamw/0?wx_fmt=png' + } + ] + }, + updateTime: 1673628969 + }, + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-vdWrisK5EZbk4Y3tzh8P0PG0eEUbnQrh0BcsEb3WNP0', + content: { + newsItem: [ + { + title: 'tudou', + author: 'haha', + digest: '312', + content: '<p>132312</p>', + contentSourceUrl: 'http://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pgFtUNLu1foMSAMkoOsrQrTZ8EtTMssBLfTtzP0dfjG', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9qdkJ1ZjBoUmg2Uk9TS3RlQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNVg2aTJsaC1fMkU2eXNacUplN3VDTTZFZkhtMjhuTUZvWkxsNDBRSXExY2tiVXRHb09TaHgtREhzY3doZ0JYeC1TSTZ5eWZldXJsOWtfbV8yMi1aYkcyZ2pOY0haM0Ntb3VSWEtxUGVFRlNBfn4%3D&chksm=1f6354ba2814ddacf0184b24d310483641ef190b1faac098c285eb416c70017e2f54decfa1af#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pgFtUNLu1foMSAMkoOsrQrTZ8EtTMssBLfTtzP0dfjG.png' + } + ] + }, + updateTime: 1673628760 + }, + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-u9kTIm1DhWZDdXyxsxUVv2Z5DAB99IPxkIRTUUD206k', + content: { + newsItem: [ + { + title: '12', + author: '333', + digest: '123', + content: '123', + contentSourceUrl: 'https://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-jVixJGgnBnkBPRbuVptOW0CHYuQFyiOVNtamctS8xU8', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9qVVhpSDZUaFJWTzBBWWRVQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNWRnTDJWYmF2NER0clV1bThmQ0xUR3hqQnJkZ3BJSUNmNDJmc0lCZ1dadkVnZ3Z5bkN4YWtVUjhoaWZWYzZURUR4NnpMd0Y4Z3U5aUdib0lkMzI4Rjg3SG9JX2FycTMxbUctOHplaTlQVVhnfn4%3D&chksm=1f6354b62814dda076c778af33f06580165d8aa81f7798d55cfabb1886b5c74d9b2124a3535c#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-jVixJGgnBnkBPRbuVptOW0CHYuQFyiOVNtamctS8xU8.jpg' + } + ] + }, + updateTime: 1673626494 + }, + { + mediaId: 'r6ryvl6LrxBU0miaST4Y-sO24upobaENDmeByfBTfaozB3aOqSMAV0lGy-UkHXE7', + content: { + newsItem: [ + { + title: '我是标题', + author: '我是作者', + digest: '我是摘要', + content: '我是内容', + contentSourceUrl: 'https://www.iocoder.cn', + thumbMediaId: 'r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn', + showCoverPic: 0, + needOpenComment: 0, + onlyFansCanComment: 0, + url: 'http://mp.weixin.qq.com/s?__biz=MzA3NjM4MzQzOQ==&tempkey=MTIxMl9LT2dqRnpMNUpsR0hjYWtBQWwxQ3R5R0JGTXBDM1Q0N2ZFQm8zeUphOFlwNEpXSWxTYm9RQnJ6cHVuNGNmazZTdlE5WkxvU0tfX2V5cjV2WjJiR0xjQUhyREFSZWo2eWNrUW9EYVh6ZkpWRXBLR3FmTEV6YldBMno3Q2ZvVXBSdzlaVDc3aFhndEpQWUwzWmFMUWt0YVVURE1VZ1FsQTdPMlRtc3JBfn4%3D&chksm=1f6354aa2814ddbcc2637382f963a8742993ac38ebcebe6e3411df5ac82ac7bbdb391be6494a#rd', + thumbUrl: + 'http://test.yudao.iocoder.cn/r6ryvl6LrxBU0miaST4Y-pIcmK-zAAId-9TGgy-DrSLhjVuWbuT3ZBjk9K1yQ0Dn.png' + } + ] + }, + updateTime: 1673534279 + } + ], + total: 6 +} diff --git a/src/views/mp/freePublish/index.vue b/src/views/mp/freePublish/index.vue new file mode 100644 index 0000000..2ed8ae7 --- /dev/null +++ b/src/views/mp/freePublish/index.vue @@ -0,0 +1,336 @@ +<template> + <doc-alert title="公众号图文" url="https://doc.iocoder.cn/mp/article/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="公众号" prop="accountId"> + <WxAccountSelect @change="onAccountChanged" /> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <div class="waterfall" v-loading="loading"> + <div + class="waterfall-item" + v-show="item.content && item.content.newsItem" + v-for="item in list" + :key="item.articleId" + > + <wx-news :articles="item.content.newsItem" /> + <el-row justify="center" class="ope-row"> + <el-button + type="danger" + circle + @click="handleDelete(item)" + v-hasPermi="['mp:free-publish:delete']" + > + <Icon icon="ep:delete" /> + </el-button> + </el-row> + </div> + </div> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import * as FreePublishApi from '@/api/mp/freePublish' +import WxNews from '@/views/mp/components/wx-news' +import WxAccountSelect from '@/views/mp/components/wx-account-select' + +defineOptions({ name: 'MpFreePublish' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref<any[]>([]) // 列表的数据 + +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + accountId: -1 +}) + +/** 侦听公众号变化 **/ +const onAccountChanged = (id: number) => { + queryParams.accountId = id + queryParams.pageNo = 1 + getList() +} + +/** 查询列表 */ +const getList = async () => { + try { + loading.value = true + const data = await FreePublishApi.getFreePublishPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 删除按钮操作 */ +const handleDelete = async (item: any) => { + try { + // 删除的二次确认 + await message.delConfirm('删除后用户将无法访问此页面,确定删除?') + // 发起删除 + await FreePublishApi.deleteFreePublish(queryParams.accountId, item.articleId) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} +</script> +<style lang="scss" scoped> +@media (width >= 992px) and (width <= 1300px) { + .waterfall { + column-count: 3; + } + + p { + color: red; + } +} + +@media (width >= 768px) and (width <= 991px) { + .waterfall { + column-count: 2; + } + + p { + color: orange; + } +} + +@media (width <= 767px) { + .waterfall { + column-count: 1; + } +} + +.ope-row { + padding-top: 5px; + margin-top: 5px; + text-align: center; + border-top: 1px solid #eaeaea; +} + +.item-name { + overflow: hidden; + font-size: 12px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.el-upload__tip { + margin-left: 5px; +} + +/* 新增图文 */ +.left { + display: inline-block; + width: 35%; + margin-top: 200px; + vertical-align: top; +} + +.right { + display: inline-block; + width: 60%; + margin-top: -40px; +} + +.avatar-uploader { + display: inline-block; + width: 20%; +} + +.avatar-uploader .el-upload { + position: relative; + overflow: hidden; + text-align: unset !important; + cursor: pointer; + border-radius: 6px; +} + +.avatar-uploader .el-upload:hover { + border-color: #165dff; +} + +.avatar-uploader-icon { + width: 120px; + height: 120px; + font-size: 28px; + line-height: 120px; + color: #8c939d; + text-align: center; + border: 1px solid #d9d9d9; +} + +.avatar { + width: 230px; + height: 120px; +} + +.avatar1 { + width: 120px; + height: 120px; +} + +.digest { + display: inline-block; + width: 60%; + vertical-align: top; +} + +/* 新增图文 */ + +/* 瀑布流样式 */ +.waterfall { + width: 100%; + column-gap: 10px; + column-count: 5; + margin: 0 auto; +} + +.waterfall-item { + padding: 10px; + margin-bottom: 10px; + break-inside: avoid; + border: 1px solid #eaeaea; +} + +p { + line-height: 30px; +} + +/* 瀑布流样式 */ +.news-main { + width: 100%; + height: 120px; + margin: auto; + background-color: #fff; +} + +.news-content { + position: relative; + width: 100%; + height: 120px; + background-color: #acadae; +} + +.news-content-title { + position: absolute; + bottom: 0; + left: 0; + display: inline-block; + width: 98%; + height: 25px; + padding: 1%; + overflow: hidden; + font-size: 15px; + color: #fff; + text-overflow: ellipsis; + white-space: nowrap; + background-color: black; + opacity: 0.65; +} + +.news-main-item { + width: 100%; + padding: 5px 0; + margin: auto; + background-color: #fff; + border-top: 1px solid #eaeaea; +} + +.news-content-item { + position: relative; + margin-left: -3px; +} + +.news-content-item-title { + display: inline-block; + width: 70%; + font-size: 12px; +} + +.news-content-item-img { + display: inline-block; + width: 25%; + background-color: #acadae; +} + +.input-tt { + padding: 5px; +} + +.activeAddNews { + border: 5px solid #2bb673; +} + +.news-main-plus { + width: 280px; + height: 50px; + margin: auto; + text-align: center; +} + +.icon-plus { + margin: 10px; + font-size: 25px; +} + +.select-item { + width: 60%; + padding: 10px; + margin: 0 auto 10px; + border: 1px solid #eaeaea; +} + +.father .child { + position: relative; + bottom: 25px; + display: none; + text-align: center; +} + +.father:hover .child { + display: block; +} + +.thumb-div { + display: inline-block; + width: 30%; + text-align: center; +} + +.thumb-but { + margin: 5px; +} + +.material-img { + width: 100%; + height: 100%; +} +</style> diff --git a/src/views/mp/hooks/useUpload.ts b/src/views/mp/hooks/useUpload.ts new file mode 100644 index 0000000..b0e7053 --- /dev/null +++ b/src/views/mp/hooks/useUpload.ts @@ -0,0 +1,50 @@ +import type { UploadRawFile } from 'element-plus' + +const message = useMessage() // 消息 + +enum UploadType { + Image = 'image', + Voice = 'voice', + Video = 'video' +} + +const useBeforeUpload = (type: UploadType, maxSizeMB: number) => { + const fn = (rawFile: UploadRawFile): boolean => { + let allowTypes: string[] = [] + let name = '' + + switch (type) { + case UploadType.Image: + allowTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/jpg'] + maxSizeMB = 2 + name = '图片' + break + case UploadType.Voice: + allowTypes = ['audio/mp3', 'audio/mpeg', 'audio/wma', 'audio/wav', 'audio/amr'] + maxSizeMB = 2 + name = '语音' + break + case UploadType.Video: + allowTypes = ['video/mp4'] + maxSizeMB = 10 + name = '视频' + break + } + // 格式不正确 + if (!allowTypes.includes(rawFile.type)) { + message.error(`上传${name}格式不对!`) + return false + } + // 大小不正确 + if (rawFile.size / 1024 / 1024 > maxSizeMB) { + message.error(`上传${name}大小不能超过${maxSizeMB}M!`) + return false + } + + return true + } + + return fn +} + +export { UploadType, useBeforeUpload } diff --git a/src/views/mp/material/components/ImageTable.vue b/src/views/mp/material/components/ImageTable.vue new file mode 100644 index 0000000..52c608f --- /dev/null +++ b/src/views/mp/material/components/ImageTable.vue @@ -0,0 +1,83 @@ +<template> + <div class="waterfall" v-loading="props.loading"> + <div class="waterfall-item" v-for="item in props.list" :key="item.id"> + <a target="_blank" :href="item.url"> + <img class="material-img" :src="item.url" /> + <div class="item-name">{{ item.name }}</div> + </a> + <el-row justify="center"> + <el-button + type="danger" + circle + @click="emit('delete', item.id)" + v-hasPermi="['mp:material:delete']" + > + <Icon icon="ep:delete" /> + </el-button> + </el-row> + </div> + </div> +</template> + +<script lang="ts" setup> +const props = defineProps<{ + list: any[] + loading: boolean +}>() + +const emit = defineEmits<{ + (e: 'delete', v: number) +}>() +</script> + +<style lang="scss" scoped> +@media (width >= 992px) and (width <= 1300px) { + .waterfall { + column-count: 3; + } + + p { + color: red; + } +} + +@media (width >= 768px) and (width <= 991px) { + .waterfall { + column-count: 2; + } + + p { + color: orange; + } +} + +@media (width <= 767px) { + .waterfall { + column-count: 1; + } +} + +.waterfall { + width: 100%; + column-gap: 10px; + column-count: 5; + margin-top: 10px; + + /* 芋道源码:增加 10px,避免顶着上面 */ +} + +.waterfall-item { + padding: 10px; + margin-bottom: 10px; + break-inside: avoid; + border: 1px solid #eaeaea; +} + +.material-img { + width: 100%; +} + +p { + line-height: 30px; +} +</style> diff --git a/src/views/mp/material/components/UploadFile.vue b/src/views/mp/material/components/UploadFile.vue new file mode 100644 index 0000000..276a798 --- /dev/null +++ b/src/views/mp/material/components/UploadFile.vue @@ -0,0 +1,77 @@ +<template> + <el-upload + :action="UPLOAD_URL" + :headers="HEADERS" + multiple + :limit="1" + :file-list="fileList" + :data="uploadData" + :on-error="onUploadError" + :before-upload="onBeforeUpload" + :on-success="onUploadSuccess" + > + <el-button type="primary" plain> 点击上传 </el-button> + <template #tip> + <span class="el-upload__tip" style="margin-left: 5px"> + <slot></slot> + </span> + </template> + </el-upload> +</template> +<script lang="ts" setup> +import type { UploadProps, UploadUserFile } from 'element-plus' +import { + HEADERS, + UPLOAD_URL, + UploadData, + UploadType, + beforeImageUpload, + beforeVoiceUpload +} from './upload' + +const message = useMessage() + +const props = defineProps<{ type: UploadType }>() + +const accountId = inject<number>('accountId') + +const fileList = ref<UploadUserFile[]>([]) +const emit = defineEmits<{ + (e: 'uploaded', v: void) +}>() + +const uploadData: UploadData = reactive({ + type: UploadType.Image, + title: '', + introduction: '', + accountId: accountId! +}) + +/** 上传前检查 */ +const onBeforeUpload = props.type === UploadType.Image ? beforeImageUpload : beforeVoiceUpload + +/** 上传成功处理 */ +const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => { + if (res.code !== 0) { + message.alertError('上传出错:' + res.msg) + return false + } + + // 清空上传时的各种数据 + fileList.value = [] + uploadData.title = '' + uploadData.introduction = '' + + message.notifySuccess('上传成功') + emit('uploaded') +} + +/** 上传失败处理 */ +const onUploadError = (err: Error) => message.error('上传失败: ' + err.message) +</script> + +<style lang="scss" scoped> +.el-upload__tip { + margin-left: 5px; +} +</style> diff --git a/src/views/mp/material/components/UploadVideo.vue b/src/views/mp/material/components/UploadVideo.vue new file mode 100644 index 0000000..0eda1ce --- /dev/null +++ b/src/views/mp/material/components/UploadVideo.vue @@ -0,0 +1,129 @@ +<template> + <el-dialog title="新建视频" v-model="showDialog" width="600px"> + <el-upload + :action="UPLOAD_URL" + :headers="HEADERS" + multiple + :limit="1" + :file-list="fileList" + :data="uploadData" + :before-upload="beforeVideoUpload" + :on-error="onUploadError" + :on-success="onUploadSuccess" + ref="uploadVideoRef" + :auto-upload="false" + class="mb-5" + > + <template #trigger> + <el-button type="primary" plain>选择视频</el-button> + </template> + <template #tip> + <span class="el-upload__tip" style="margin-left: 10px" + >格式支持 MP4,文件大小不超过 10MB</span + > + </template> + </el-upload> + <el-divider /> + <el-form :model="uploadData" :rules="uploadRules" ref="uploadFormRef"> + <el-form-item label="标题" prop="title"> + <el-input + v-model="uploadData.title" + placeholder="标题将展示在相关播放页面,建议填写清晰、准确、生动的标题" + /> + </el-form-item> + <el-form-item label="描述" prop="introduction"> + <el-input + :rows="3" + type="textarea" + v-model="uploadData.introduction" + placeholder="介绍语将展示在相关播放页面,建议填写简洁明确、有信息量的内容" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="showDialog = false">取 消</el-button> + <el-button type="primary" @click="submitVideo">提 交</el-button> + </template> + </el-dialog> +</template> + +<script lang="ts" setup> +import type { + FormInstance, + FormRules, + UploadInstance, + UploadProps, + UploadUserFile +} from 'element-plus' +import { HEADERS, UploadData, UPLOAD_URL, UploadType, beforeVideoUpload } from './upload' + +const message = useMessage() + +const accountId = inject<number>('accountId') + +const uploadRules: FormRules = { + title: [{ required: true, message: '请输入标题', trigger: 'blur' }], + introduction: [{ required: true, message: '请输入描述', trigger: 'blur' }] +} + +const props = defineProps({ + modelValue: { + type: Boolean, + default: false + } +}) +const emit = defineEmits<{ + (e: 'update:modelValue', v: boolean) + (e: 'uploaded', v: void) +}>() + +const showDialog = computed<boolean>({ + get() { + return props.modelValue + }, + set(val) { + emit('update:modelValue', val) + } +}) + +const fileList = ref<UploadUserFile[]>([]) + +const uploadData: UploadData = reactive({ + type: UploadType.Video, + title: '', + introduction: '', + accountId: accountId! +}) + +const uploadFormRef = ref<FormInstance | null>(null) +const uploadVideoRef = ref<UploadInstance | null>(null) + +const submitVideo = () => { + uploadFormRef.value?.validate((valid) => { + if (!valid) { + return false + } + uploadVideoRef.value?.submit() + }) +} + +/** 上传成功处理 */ +const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => { + if (res.code !== 0) { + message.error('上传出错:' + res.msg) + return false + } + + // 清空上传时的各种数据 + fileList.value = [] + uploadData.title = '' + uploadData.introduction = '' + + showDialog.value = false + message.notifySuccess('上传成功') + emit('uploaded') +} + +/** 上传失败处理 */ +const onUploadError = (err: Error) => message.error(`上传失败: ${err.message}`) +</script> diff --git a/src/views/mp/material/components/VideoTable.vue b/src/views/mp/material/components/VideoTable.vue new file mode 100644 index 0000000..cbaa902 --- /dev/null +++ b/src/views/mp/material/components/VideoTable.vue @@ -0,0 +1,59 @@ +<template> + <el-table :data="props.list" stripe border v-loading="props.loading" style="margin-top: 10px"> + <el-table-column label="编号" align="center" prop="mediaId" /> + <el-table-column label="文件名" align="center" prop="name" /> + <el-table-column label="标题" align="center" prop="title" /> + <el-table-column label="介绍" align="center" prop="introduction" /> + <el-table-column label="视频" align="center"> + <template #default="scope"> + <WxVideoPlayer v-if="scope.row.url" :url="scope.row.url" /> + </template> + </el-table-column> + <el-table-column + label="上传时间" + align="center" + :formatter="dateFormatter" + prop="createTime" + width="180" + > + <template #default="scope"> + <span>{{ scope.row.createTime }}</span> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right"> + <template #default="scope"> + <el-button type="primary" link @click="handleDownload(scope.row.url)"> + <Icon icon="ep:download" />下载 + </el-button> + <el-button + type="primary" + link + @click="emit('delete', scope.row.id)" + v-hasPermi="['mp:material:delete']" + > + <Icon icon="ep:delete" />删除 + </el-button> + </template> + </el-table-column> + </el-table> +</template> + +<script lang="ts" setup> +import WxVideoPlayer from '@/views/mp/components/wx-video-play' +import { dateFormatter } from '@/utils/formatTime' + +const props = defineProps<{ + list: any[] + loading: boolean +}>() + +const emit = defineEmits<{ + (e: 'delete', v: number) + (e: 'download', v: string) +}>() + +// 下载文件 +const handleDownload = (url: string) => { + window.open(url, '_blank') +} +</script> diff --git a/src/views/mp/material/components/VoiceTable.vue b/src/views/mp/material/components/VoiceTable.vue new file mode 100644 index 0000000..76fab7a --- /dev/null +++ b/src/views/mp/material/components/VoiceTable.vue @@ -0,0 +1,51 @@ +<template> + <el-table :data="props.list" stripe border v-loading="props.loading" style="margin-top: 10px"> + <el-table-column label="编号" align="center" prop="mediaId" /> + <el-table-column label="文件名" align="center" prop="name" /> + <el-table-column label="语音" align="center"> + <template #default="scope"> + <WxVoicePlayer v-if="scope.row.url" :url="scope.row.url" /> + </template> + </el-table-column> + <el-table-column + label="上传时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180" + > + <template #default="scope"> + <span>{{ scope.row.createTime }}</span> + </template> + </el-table-column> + <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> + <template #default="scope"> + <el-button type="primary" link @click="emit('delete', scope.row.id)"> + <Icon icon="ep:download" />下载 + </el-button> + <el-button + type="primary" + link + @click="emit('delete', scope.row.id)" + v-hasPermi="['mp:material:delete']" + > + <Icon icon="ep:delete" />删除 + </el-button> + </template> + </el-table-column> + </el-table> +</template> + +<script lang="ts" setup> +import WxVoicePlayer from '@/views/mp/components/wx-voice-play' +import { dateFormatter } from '@/utils/formatTime' + +const props = defineProps<{ + list: any[] + loading: boolean +}>() + +const emit = defineEmits<{ + (e: 'delete', v: number) +}>() +</script> diff --git a/src/views/mp/material/components/upload.ts b/src/views/mp/material/components/upload.ts new file mode 100644 index 0000000..e732fe7 --- /dev/null +++ b/src/views/mp/material/components/upload.ts @@ -0,0 +1,32 @@ +import type { UploadProps, UploadRawFile } from 'element-plus' +import { getAccessToken } from '@/utils/auth' +import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload' + +const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 请求头 +const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传地址 + +interface UploadData { + type: UploadType + title: string + introduction: string + accountId: number +} + +const beforeImageUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => + useBeforeUpload(UploadType.Image, 2)(rawFile) + +const beforeVoiceUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => + useBeforeUpload(UploadType.Voice, 2)(rawFile) + +const beforeVideoUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => + useBeforeUpload(UploadType.Video, 10)(rawFile) + +export { + HEADERS, + UPLOAD_URL, + UploadType, + UploadData, + beforeImageUpload, + beforeVoiceUpload, + beforeVideoUpload +} diff --git a/src/views/mp/material/index.vue b/src/views/mp/material/index.vue new file mode 100644 index 0000000..de06042 --- /dev/null +++ b/src/views/mp/material/index.vue @@ -0,0 +1,159 @@ +<template> + <doc-alert title="公众号素材" url="https://doc.iocoder.cn/mp/material/" /> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form class="-mb-15px" :inline="true" label-width="68px"> + <el-form-item label="公众号" prop="accountId"> + <WxAccountSelect @change="onAccountChanged" /> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <el-tabs v-model="type" @tab-change="onTabChange"> + <!-- tab 1:图片 --> + <el-tab-pane :name="UploadType.Image"> + <template #label> + <el-row align="middle"> <Icon icon="ep:picture" />图片 </el-row> + </template> + <UploadFile + v-hasPermi="['mp:material:upload-permanent']" + :type="UploadType.Image" + @uploaded="getList" + > + 支持 bmp/png/jpeg/jpg/gif 格式,大小不超过 2M + </UploadFile> + <!-- 列表 --> + <ImageTable :loading="loading" :list="list" @delete="handleDelete" /> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </el-tab-pane> + + <!-- tab 2:语音 --> + <el-tab-pane :name="UploadType.Voice"> + <template #label> + <el-row align="middle"> <Icon icon="ep:microphone" />语音 </el-row> + </template> + <UploadFile + v-hasPermi="['mp:material:upload-permanent']" + :type="UploadType.Voice" + @uploaded="getList" + > + 格式支持 mp3/wma/wav/amr,文件大小不超过 2M,播放长度不超过 60s + </UploadFile> + <!-- 列表 --> + <VoiceTable :list="list" :loading="loading" @delete="handleDelete" /> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </el-tab-pane> + + <!-- tab 3:视频 --> + <el-tab-pane :name="UploadType.Video"> + <template #label> + <el-row align="middle"> <Icon icon="ep:video-play" /> 视频 </el-row> + </template> + <el-button + v-hasPermi="['mp:material:upload-permanent']" + type="primary" + plain + @click="showCreateVideo = true" + >新建视频</el-button + > + <!-- 新建视频的弹窗 --> + <UploadVideo v-model="showCreateVideo" /> + <!-- 列表 --> + <VideoTable :list="list" :loading="loading" @delete="handleDelete" /> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </el-tab-pane> + </el-tabs> + </ContentWrap> +</template> +<script lang="ts" setup name="MpMaterial"> +import WxAccountSelect from '@/views/mp/components/wx-account-select' +import ImageTable from './components/ImageTable.vue' +import VoiceTable from './components/VoiceTable.vue' +import VideoTable from './components/VideoTable.vue' +import UploadFile from './components/UploadFile.vue' +import UploadVideo from './components/UploadVideo.vue' +import { UploadType } from './components/upload' +import * as MpMaterialApi from '@/api/mp/material' +const message = useMessage() // 消息 + +const type = ref<UploadType>(UploadType.Image) // 素材类型 +const loading = ref(false) // 遮罩层 +const list = ref<any[]>([]) // 总条数 +const total = ref(0) // 数据列表 + +const accountId = ref(-1) +provide('accountId', accountId) + +// 查询参数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + accountId: accountId, + permanent: true +}) +const showCreateVideo = ref(false) // 是否新建视频的弹窗 + +/** 侦听公众号变化 **/ +const onAccountChanged = (id: number) => { + accountId.value = id + queryParams.accountId = id + queryParams.pageNo = 1 + getList() +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await MpMaterialApi.getMaterialPage({ + ...queryParams, + type: type.value + }) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 处理 table 切换 */ +const onTabChange = () => { + // 提前情况数据,避免 tab 切换后显示垃圾数据 + list.value = [] + total.value = 0 + // 从第一页开始查询 + handleQuery() +} + +/** 处理删除操作 */ +const handleDelete = async (id: number) => { + await message.confirm('此操作将永久删除该文件, 是否继续?') + await MpMaterialApi.deletePermanentMaterial(id) + message.alertSuccess('删除成功') +} +</script> diff --git a/src/views/mp/menu/assets/iphone_backImg.png b/src/views/mp/menu/assets/iphone_backImg.png new file mode 100644 index 0000000..bb09591 Binary files /dev/null and b/src/views/mp/menu/assets/iphone_backImg.png differ diff --git a/src/views/mp/menu/assets/menu_foot.png b/src/views/mp/menu/assets/menu_foot.png new file mode 100644 index 0000000..4a89d4b Binary files /dev/null and b/src/views/mp/menu/assets/menu_foot.png differ diff --git a/src/views/mp/menu/assets/menu_head.png b/src/views/mp/menu/assets/menu_head.png new file mode 100644 index 0000000..248cfb7 Binary files /dev/null and b/src/views/mp/menu/assets/menu_head.png differ diff --git a/src/views/mp/menu/components/MenuEditor.vue b/src/views/mp/menu/components/MenuEditor.vue new file mode 100644 index 0000000..5df1785 --- /dev/null +++ b/src/views/mp/menu/components/MenuEditor.vue @@ -0,0 +1,244 @@ +<template> + <div> + <div class="configure_page"> + <div class="delete_btn"> + <el-button type="danger" @click="emit('delete')"> + <Icon icon="ep:delete" /> + 删除当前菜单 + </el-button> + </div> + <div> + <span>菜单名称:</span> + <el-input + class="input_width" + v-model="menu.name" + placeholder="请输入菜单名称" + :maxlength="isParent ? 4 : 7" + clearable + /> + </div> + <div v-if="isLeave"> + <div class="menu_content"> + <span>菜单标识:</span> + <el-input + class="input_width" + v-model="menu.menuKey" + placeholder="请输入菜单 KEY" + clearable + /> + </div> + <div class="menu_content"> + <span>菜单内容:</span> + <el-select v-model="menu.type" clearable placeholder="请选择" class="menu_option"> + <el-option + v-for="item in menuOptions" + :label="item.label" + :value="item.value" + :key="item.value" + /> + </el-select> + </div> + <div class="configur_content" v-if="menu.type === 'view'"> + <span>跳转链接:</span> + <el-input class="input_width" v-model="menu.url" placeholder="请输入链接" clearable /> + </div> + <div class="configur_content" v-if="menu.type === 'miniprogram'"> + <div class="applet"> + <span>小程序的 appid :</span> + <el-input + class="input_width" + v-model="menu.miniProgramAppId" + placeholder="请输入小程序的appid" + clearable + /> + </div> + <div class="applet"> + <span>小程序的页面路径:</span> + <el-input + class="input_width" + v-model="menu.miniProgramPagePath" + placeholder="请输入小程序的页面路径,如:pages/index" + clearable + /> + </div> + <div class="applet"> + <span>小程序的备用网页:</span> + <el-input + class="input_width" + v-model="menu.url" + placeholder="不支持小程序的老版本客户端将打开本网页" + clearable + /> + </div> + <p class="blue">tips:需要和公众号进行关联才可以把小程序绑定带微信菜单上哟!</p> + </div> + <div class="configur_content" v-if="menu.type === 'article_view_limited'"> + <el-row> + <div class="select-item" v-if="menu && menu.replyArticles"> + <WxNews :articles="menu.replyArticles" /> + <el-row class="ope-row" justify="center" align="middle"> + <el-button type="danger" circle @click="deleteMaterial"> + <icon icon="ep:delete" /> + </el-button> + </el-row> + </div> + <div v-else> + <el-row justify="center"> + <el-col :span="24" style="text-align: center"> + <el-button type="success" @click="showNewsDialog = true"> + 素材库选择 + <Icon icon="ep:circle-check" /> + </el-button> + </el-col> + </el-row> + </div> + <el-dialog title="选择图文" v-model="showNewsDialog" width="80%" destroy-on-close> + <WxMaterialSelect + type="news" + :account-id="props.accountId" + @select-material="selectMaterial" + /> + </el-dialog> + </el-row> + </div> + <div + class="configur_content" + v-if="menu.type === 'click' || menu.type === 'scancode_waitmsg'" + > + <WxReplySelect v-if="hackResetWxReplySelect" v-model="menu.reply" /> + </div> + </div> + </div> + </div> +</template> + +<script lang="ts" setup> +import WxReplySelect from '@/views/mp/components/wx-reply' +import WxNews from '@/views/mp/components/wx-news' +import WxMaterialSelect from '@/views/mp/components/wx-material-select' +import menuOptions from './menuOptions' + +const message = useMessage() + +const props = defineProps<{ + accountId: number + modelValue: any + isParent: boolean +}>() + +const emit = defineEmits<{ + (e: 'delete', v: void) + (e: 'update:modelValue', v: any) +}>() + +const menu = computed({ + get() { + return props.modelValue + }, + set(val) { + emit('update:modelValue', val) + } +}) +const showNewsDialog = ref(false) +const hackResetWxReplySelect = ref(false) +const isLeave = computed<boolean>(() => !(menu.value.children?.length > 0)) + +watch(menu, () => { + hackResetWxReplySelect.value = false // 销毁组件 + nextTick(() => { + hackResetWxReplySelect.value = true // 重建组件 + }) +}) + +// ======================== 菜单编辑(素材选择) ======================== +const selectMaterial = (item: any) => { + const articleId = item.articleId + const articles = item.content.newsItem + // 提示,针对多图文 + if (articles.length > 1) { + message.alertWarning('您选择的是多图文,将默认跳转第一篇') + } + showNewsDialog.value = false + + // 设置菜单的回复 + menu.value.articleId = articleId + menu.value.replyArticles = [] + articles.forEach((article) => { + menu.value.replyArticles.push({ + title: article.title, + description: article.digest, + picUrl: article.picUrl, + url: article.url + }) + }) +} + +const deleteMaterial = () => { + delete menu.value['articleId'] + delete menu.value['replyArticles'] +} +</script> + +<style lang="scss" scoped> +.el-input { + width: 70%; + margin-right: 2%; +} + +.configure_page { + .delete_btn { + margin-bottom: 15px; + text-align: right; + } + + .menu_content { + margin-top: 20px; + } + + .configur_content { + padding: 20px 10px; + margin-top: 20px; + background-color: #fff; + border-radius: 5px; + + .select-item { + width: 280px; + padding: 10px; + margin: 0 auto 10px; + border: 1px solid #eaeaea; + + .ope-row { + padding-top: 10px; + text-align: center; + } + } + } + + .blue { + margin-top: 10px; + color: #29b6f6; + } + + .applet { + margin-bottom: 20px; + + span { + width: 20%; + } + } + + .input_width { + width: 40%; + } + + .material { + .input_width { + width: 30%; + } + + .el-textarea { + width: 80%; + } + } +} +</style> diff --git a/src/views/mp/menu/components/MenuPreviewer.vue b/src/views/mp/menu/components/MenuPreviewer.vue new file mode 100644 index 0000000..93a1980 --- /dev/null +++ b/src/views/mp/menu/components/MenuPreviewer.vue @@ -0,0 +1,226 @@ +<template> + <draggable + v-model="menuList" + item-key="id" + ghost-class="draggable-ghost" + :animation="400" + @end="onParentDragEnd" + > + <template #item="{ element: parent, index: x }"> + <div class="menu_bottom"> + <!-- 一级菜单 --> + <div + @click="menuClicked(parent, x)" + class="menu_item" + :class="{ active: props.activeIndex === `${x}` }" + > + <Icon icon="ep:fold" color="black" />{{ parent.name }} + </div> + <!-- 以下为二级菜单--> + <div class="submenu" v-if="props.parentIndex === x && parent.children"> + <draggable + v-model="parent.children" + item-key="id" + ghost-class="draggable-ghost" + :animation="400" + @end="onChildDragEnd" + > + <template #item="{ element: child, index: y }"> + <div class="menu_bottom subtitle"> + <div + class="menu_subItem" + v-if="parent.children" + :class="{ active: props.activeIndex === `${x}-${y}` }" + @click="subMenuClicked(child, x, y)" + > + {{ child.name }} + </div> + </div> + </template> + </draggable> + <!-- 二级菜单加号, 当长度 小于 5 才显示二级菜单的加号 --> + <div + class="menu_bottom menu_addicon" + v-if="!parent.children || parent.children.length < 5" + @click="addSubMenu(x, parent)" + > + <Icon icon="ep:plus" class="plus" /> + </div> + </div> + </div> + </template> + </draggable> + + <!-- 一级菜单加号 --> + <div class="menu_bottom menu_addicon" v-if="menuList.length < 3" @click="addMenu"> + <Icon icon="ep:plus" class="plus" /> + </div> +</template> + +<script lang="ts" setup> +import { Menu } from './types' +import draggable from 'vuedraggable' + +const props = defineProps<{ + modelValue: Menu[] + activeIndex: string + parentIndex: number + accountId: number +}>() + +const emit = defineEmits<{ + (e: 'update:modelValue', v: Menu[]) + (e: 'menu-clicked', parent: Menu, x: number) + (e: 'submenu-clicked', child: Menu, x: number, y: number) +}>() + +const menuList = computed<Menu[]>({ + get: () => props.modelValue, + set: (val) => emit('update:modelValue', val) +}) + +// 添加横向一级菜单 +const addMenu = () => { + const index = menuList.value.length + const menu = { + name: '菜单名称', + children: [], + reply: { + // 用于存储回复内容 + type: 'text', + accountId: props.accountId // 保证组件里,可以使用到对应的公众号 + } + } + menuList.value[index] = menu + menuClicked(menu, index - 1) +} + +// 添加横向二级菜单;parent 表示要操作的父菜单 +const addSubMenu = (i: number, parent: any) => { + const subMenuKeyLength = parent.children.length // 获取二级菜单key长度 + const addButton = { + name: '子菜单名称', + reply: { + // 用于存储回复内容 + type: 'text', + accountId: props.accountId // 保证组件里,可以使用到对应的公众号 + } + } + parent.children[subMenuKeyLength] = addButton + subMenuClicked(parent.children[subMenuKeyLength], i, subMenuKeyLength) +} + +const menuClicked = (parent: Menu, x: number) => { + emit('menu-clicked', parent, x) +} + +const subMenuClicked = (child: Menu, x: number, y: number) => { + emit('submenu-clicked', child, x, y) +} + +/** + * 处理一级菜单展开后被拖动,激活(展开)原来活动的一级菜单 + * + * @param oldIndex: 一级菜单拖动前的位置 + * @param newIndex: 一级菜单拖动后的位置 + */ +const onParentDragEnd = ({ oldIndex, newIndex }) => { + // 二级菜单没有展开,直接返回 + if (props.activeIndex === '__MENU_NOT_SELECTED__') { + return + } + + // 使用一个辅助数组来模拟菜单移动,然后找到展开的二级菜单的新下标`newParent` + let positions = new Array<boolean>(menuList.value.length).fill(false) + positions[props.parentIndex] = true + const [out] = positions.splice(oldIndex, 1) // 移出菜单,保存到变量out + positions.splice(newIndex, 0, out) // 把out变量插入被移出的菜单 + const newParentIndex = positions.indexOf(true) + + // 找到菜单元素,触发一级菜单点击 + const parent = menuList.value[newParentIndex] + emit('menu-clicked', parent, newParentIndex) +} + +/** + * 处理二级菜单展开后被拖动,激活被拖动的菜单 + * + * @param newIndex 二级菜单拖动后的位置 + */ +const onChildDragEnd = ({ newIndex }) => { + const x = props.parentIndex + const y = newIndex + const children = menuList.value[x]?.children + if (children && children?.length > 0) { + const child = children[y] + emit('submenu-clicked', child, x, y) + } +} +</script> + +<style lang="scss" scoped> +.menu_bottom { + position: relative; + display: block; + float: left; + width: 85.5px; + text-align: center; + cursor: pointer; + background-color: #fff; + border: 1px solid #ebedee; + box-sizing: border-box; + + &.menu_addicon { + height: 46px; + line-height: 46px; + + .plus { + color: #2bb673; + } + } + + .menu_item { + display: flex; + width: 100%; + height: 44px; + line-height: 44px; + // text-align: center; + box-sizing: border-box; + align-items: center; + justify-content: center; + + &.active { + border: 1px solid #2bb673; + } + } + + .menu_subItem { + height: 44px; + line-height: 44px; + text-align: center; + box-sizing: border-box; + + &.active { + border: 1px solid #2bb673; + } + } +} + +/* 第二级菜单 */ +.submenu { + position: absolute; + bottom: 45px; + width: 85.5px; + + .subtitle { + background-color: #fff; + box-sizing: border-box; + } +} + +.draggable-ghost { + background: #f7fafc; + border: 1px solid #4299e1; + opacity: 0.5; +} +</style> diff --git a/src/views/mp/menu/components/menuOptions.ts b/src/views/mp/menu/components/menuOptions.ts new file mode 100644 index 0000000..d86dd78 --- /dev/null +++ b/src/views/mp/menu/components/menuOptions.ts @@ -0,0 +1,42 @@ +export default [ + { + value: 'view', + label: '跳转网页' + }, + { + value: 'miniprogram', + label: '跳转小程序' + }, + { + value: 'click', + label: '点击回复' + }, + { + value: 'article_view_limited', + label: '跳转图文消息' + }, + { + value: 'scancode_push', + label: '扫码直接返回结果' + }, + { + value: 'scancode_waitmsg', + label: '扫码回复' + }, + { + value: 'pic_sysphoto', + label: '系统拍照发图' + }, + { + value: 'pic_photo_or_album', + label: '拍照或者相册' + }, + { + value: 'pic_weixin', + label: '微信相册' + }, + { + value: 'location_select', + label: '选择地理位置' + } +] diff --git a/src/views/mp/menu/components/types.ts b/src/views/mp/menu/components/types.ts new file mode 100644 index 0000000..b9f7659 --- /dev/null +++ b/src/views/mp/menu/components/types.ts @@ -0,0 +1,73 @@ +export interface Replay { + title: string + description: string + picUrl: string + url: string +} + +export type MenuType = + | '' + | 'click' + | 'view' + | 'scancode_waitmsg' + | 'scancode_push' + | 'pic_sysphoto' + | 'pic_photo_or_album' + | 'pic_weixin' + | 'location_select' + | 'article_view_limited' + +interface _RawMenu { + // db + id: number + parentId: number + accountId: number + appId: string + createTime: number + + // mp-native + name: string + menuKey: string + type: MenuType + url: string + miniProgramAppId: string + miniProgramPagePath: string + articleId: string + replyMessageType: string + replyContent: string + replyMediaId: string + replyMediaUrl: string + replyThumbMediaId: string + replyThumbMediaUrl: string + replyTitle: string + replyDescription: string + replyArticles: Replay + replyMusicUrl: string + replyHqMusicUrl: string +} + +export type RawMenu = Partial<_RawMenu> + +interface _Reply { + type: string + accountId: number + content: string + mediaId: string + url: string + thumbMediaId: string + thumbMediaUrl: string + title: string + description: string + articles: null | Replay[] + musicUrl: string + hqMusicUrl: string +} + +export type Reply = Partial<_Reply> + +interface _Menu extends RawMenu { + children: _Menu[] + reply: Reply +} + +export type Menu = Partial<_Menu> diff --git a/src/views/mp/menu/index.vue b/src/views/mp/menu/index.vue new file mode 100644 index 0000000..8cc8f58 --- /dev/null +++ b/src/views/mp/menu/index.vue @@ -0,0 +1,401 @@ +<template> + <doc-alert title="公众号菜单" url="https://doc.iocoder.cn/mp/menu/" /> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form class="-mb-15px" ref="queryFormRef" :inline="true" label-width="68px"> + <el-form-item label="公众号" prop="accountId"> + <WxAccountSelect @change="onAccountChanged" /> + </el-form-item> + </el-form> + </ContentWrap> + + <ContentWrap> + <div class="clearfix public-account-management" v-loading="loading"> + <!--左边配置菜单--> + <div class="left"> + <div class="weixin-hd"> + <div class="weixin-title">{{ accountName }}</div> + </div> + <div class="clearfix weixin-menu"> + <MenuPreviewer + v-model="menuList" + :account-id="accountId" + :active-index="activeIndex" + :parent-index="parentIndex" + @menu-clicked="(parent, x) => menuClicked(parent, x)" + @submenu-clicked="(child, x, y) => subMenuClicked(child, x, y)" + /> + </div> + <div class="save_div"> + <el-button class="save_btn" type="success" @click="onSave" v-hasPermi="['mp:menu:save']" + >保存并发布菜单</el-button + > + <el-button class="save_btn" type="danger" @click="onClear" v-hasPermi="['mp:menu:delete']" + >清空菜单</el-button + > + </div> + </div> + <!--右边配置--> + <div class="right" v-if="showRightPanel"> + <MenuEditor + :account-id="accountId" + :is-parent="isParent" + v-model="activeMenu" + @delete="onDeleteMenu" + /> + </div> + <!-- 一进页面就显示的默认页面,当点击左边按钮的时候,就不显示了--> + <div v-else class="right"> + <p>请选择菜单配置</p> + </div> + </div> + </ContentWrap> +</template> + +<script lang="ts" setup> +import WxAccountSelect from '@/views/mp/components/wx-account-select' +import MenuEditor from './components/MenuEditor.vue' +import MenuPreviewer from './components/MenuPreviewer.vue' +import * as MpMenuApi from '@/api/mp/menu' +import * as UtilsTree from '@/utils/tree' +import { RawMenu, Menu } from './components/types' + +defineOptions({ name: 'MpMenu' }) + +const message = useMessage() // 消息 +const MENU_NOT_SELECTED = '__MENU_NOT_SELECTED__' + +// ======================== 列表查询 ======================== +const loading = ref(false) // 遮罩层 +const accountId = ref(-1) +const accountName = ref<string>('') +const menuList = ref<Menu[]>([]) + +// ======================== 菜单操作 ======================== +// 当前选中菜单编码: +// * 一级('x') +// * 二级('x-y') +// * 未选中(MENU_NOT_SELECTED) +const activeIndex = ref<string>(MENU_NOT_SELECTED) +// 二级菜单显示标志: 归属的一级菜单index +// * 未初始化:-1 +// * 初始化:x +const parentIndex = ref(-1) + +// ======================== 菜单编辑 ======================== +const showRightPanel = ref(false) // 右边配置显示默认详情还是配置详情 +const isParent = ref<boolean>(true) // 是否一级菜单,控制MenuEditor中name字段长度 +const activeMenu = ref<Menu>({}) // 选中菜单,MenuEditor的modelValue + +// 一些临时值放在这里进行判断,如果放在 activeMenu,由于引用关系,menu 也会多了多余的参数 +enum Level { + Undefined = '0', + Parent = '1', + Child = '2' +} +const tempSelfObj = ref<{ + grand: Level + x: number + y: number +}>({ + grand: Level.Undefined, + x: 0, + y: 0 +}) +const dialogNewsVisible = ref(false) // 跳转图文时的素材选择弹窗 + +/** 侦听公众号变化 **/ +const onAccountChanged = (id: number, name: string) => { + accountId.value = id + accountName.value = name + getList() +} + +/** 查询并转换菜单 **/ +const getList = async () => { + loading.value = false + try { + const data = await MpMenuApi.getMenuList(accountId.value) + const menuData = menuListToFrontend(data) + menuList.value = UtilsTree.handleTree(menuData, 'id') + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + resetForm() + getList() +} + +// 将后端返回的 menuList,转换成前端的 menuList +const menuListToFrontend = (list: any[]) => { + if (!list) return [] + + const result: RawMenu[] = [] + list.forEach((item: RawMenu) => { + const menu: any = { + ...item + } + menu.reply = { + type: item.replyMessageType, + accountId: item.accountId, + content: item.replyContent, + mediaId: item.replyMediaId, + url: item.replyMediaUrl, + title: item.replyTitle, + description: item.replyDescription, + thumbMediaId: item.replyThumbMediaId, + thumbMediaUrl: item.replyThumbMediaUrl, + articles: item.replyArticles, + musicUrl: item.replyMusicUrl, + hqMusicUrl: item.replyHqMusicUrl + } + result.push(menu as RawMenu) + }) + return result +} + +// 重置表单,清空表单数据 +const resetForm = () => { + // 菜单操作 + activeIndex.value = MENU_NOT_SELECTED + parentIndex.value = -1 + + // 菜单编辑 + showRightPanel.value = false + activeMenu.value = {} + tempSelfObj.value = { grand: Level.Undefined, x: 0, y: 0 } + dialogNewsVisible.value = false +} + +// ======================== 菜单操作 ======================== +// 一级菜单点击事件 +const menuClicked = (parent: Menu, x: number) => { + // 右侧的表单相关 + showRightPanel.value = true // 右边菜单 + activeMenu.value = parent // 这个如果放在顶部,flag 会没有。因为重新赋值了。 + tempSelfObj.value.grand = Level.Parent // 表示一级菜单 + tempSelfObj.value.x = x // 表示一级菜单索引 + isParent.value = true + + // 左侧的选中 + activeIndex.value = `${x}` // 菜单选中样式 + parentIndex.value = x // 二级菜单显示标志 +} + +// 二级菜单点击事件 +const subMenuClicked = (child: Menu, x: number, y: number) => { + // 右侧的表单相关 + showRightPanel.value = true // 右边菜单 + activeMenu.value = child // 将点击的数据放到临时变量,对象有引用作用 + tempSelfObj.value.grand = Level.Child // 表示二级菜单 + tempSelfObj.value.x = x // 表示一级菜单索引 + tempSelfObj.value.y = y // 表示二级菜单索引 + isParent.value = false + + // 左侧的选中 + activeIndex.value = `${x}-${y}` +} + +// 删除当前菜单 +const onDeleteMenu = async () => { + try { + await message.confirm('确定要删除吗?') + if (tempSelfObj.value.grand === Level.Parent) { + // 一级菜单的删除方法 + menuList.value.splice(tempSelfObj.value.x, 1) + } else if (tempSelfObj.value.grand === Level.Child) { + // 二级菜单的删除方法 + menuList.value[tempSelfObj.value.x].children?.splice(tempSelfObj.value.y, 1) + } + // 提示 + message.notifySuccess('删除成功') + + // 处理菜单的选中 + activeMenu.value = {} + showRightPanel.value = false + activeIndex.value = MENU_NOT_SELECTED + } catch {} +} + +// ======================== 菜单编辑 ======================== +const onSave = async () => { + try { + await message.confirm('确定要保存吗?') + loading.value = true + await MpMenuApi.saveMenu(accountId.value, menuListToBackend()) + getList() + message.notifySuccess('发布成功') + } finally { + loading.value = false + } +} + +const onClear = async () => { + try { + await message.confirm('确定要删除吗?') + loading.value = true + await MpMenuApi.deleteMenu(accountId.value) + handleQuery() + message.notifySuccess('清空成功') + } finally { + loading.value = false + } +} + +// 将前端的 menuList,转换成后端接收的 menuList +const menuListToBackend = () => { + const result: any[] = [] + menuList.value.forEach((item) => { + const menu = menuToBackend(item) + result.push(menu) + + // 处理子菜单 + if (!item.children || item.children.length <= 0) { + return + } + menu.children = [] + item.children.forEach((subItem) => { + menu.children.push(menuToBackend(subItem)) + }) + }) + return result +} + +// 将前端的 menu,转换成后端接收的 menu +// TODO: @芋艿,需要根据后台API删除不需要的字段 +const menuToBackend = (menu: any) => { + let result = { + ...menu, + children: undefined, // 不处理子节点 + reply: undefined // 稍后复制 + } + result.replyMessageType = menu.reply.type + result.replyContent = menu.reply.content + result.replyMediaId = menu.reply.mediaId + result.replyMediaUrl = menu.reply.url + result.replyTitle = menu.reply.title + result.replyDescription = menu.reply.description + result.replyThumbMediaId = menu.reply.thumbMediaId + result.replyThumbMediaUrl = menu.reply.thumbMediaUrl + result.replyArticles = menu.reply.articles + result.replyMusicUrl = menu.reply.musicUrl + result.replyHqMusicUrl = menu.reply.hqMusicUrl + + return result +} +</script> + +<!--本组件样式--> +<style lang="scss" scoped="scoped"> +/* 公共颜色变量 */ +.clearfix { + *zoom: 1; +} + +.clearfix::after { + display: table; + clear: both; + content: ''; +} + +div { + text-align: left; +} + +.weixin-hd { + position: relative; + bottom: 426px; + left: 0; + width: 300px; + height: 64px; + color: #fff; + text-align: center; + background: transparent url('./assets/menu_head.png') no-repeat 0 0; + background-position: 0 0; + background-size: 100%; +} + +.weixin-title { + position: absolute; + top: 33px; + left: 0; + width: 100%; + font-size: 14px; + color: #fff; + text-align: center; +} + +.weixin-menu { + padding-left: 43px; + font-size: 12px; + background: transparent url('./assets/menu_foot.png') no-repeat 0 0; +} + +.public-account-management { + width: 1200px; + // min-width: 1200px; + margin: 0 auto; + + .left { + position: relative; + display: block; + float: left; + width: 350px; + height: 715px; + padding: 518px 25px 88px; + background: url('./assets/iphone_backImg.png') no-repeat; + background-size: 100% auto; + box-sizing: border-box; + + .save_div { + margin-top: 15px; + text-align: center; + + .save_btn { + bottom: 20px; + left: 100px; + } + } + } + + /* 右边菜单内容 */ + .right { + float: left; + width: 63%; + padding: 20px; + margin-left: 20px; + background-color: #e8e7e7; + box-sizing: border-box; + } +} +</style> +<!--素材样式--> +<style lang="scss" scoped> +.pagination { + margin-right: 25px; + text-align: right; +} + +.select-item { + width: 280px; + padding: 10px; + margin: 0 auto 10px; + border: 1px solid #eaeaea; +} + +.ope-row { + padding-top: 10px; + text-align: center; +} + +.item-name { + overflow: hidden; + font-size: 12px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} +</style> diff --git a/src/views/mp/message/MessageTable.vue b/src/views/mp/message/MessageTable.vue new file mode 100644 index 0000000..ebc3d74 --- /dev/null +++ b/src/views/mp/message/MessageTable.vue @@ -0,0 +1,145 @@ +<template> + <div> + <el-table v-loading="props.loading" :data="props.list"> + <el-table-column + label="发送时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="消息类型" align="center" prop="type" width="80" /> + <el-table-column label="发送方" align="center" prop="sendFrom" width="80"> + <template #default="scope"> + <el-tag v-if="scope.row.sendFrom === 1" type="success">粉丝</el-tag> + <el-tag v-else type="info">公众号</el-tag> + </template> + </el-table-column> + <el-table-column label="用户标识" align="center" prop="openid" width="300" /> + <el-table-column label="内容" prop="content"> + <template #default="scope"> + <!-- 【事件】区域 --> + <div v-if="scope.row.type === MsgType.Event && scope.row.event === 'subscribe'"> + <el-tag type="success">关注</el-tag> + </div> + <div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'unsubscribe'"> + <el-tag type="danger">取消关注</el-tag> + </div> + <div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'CLICK'"> + <el-tag>点击菜单</el-tag> + 【{{ scope.row.eventKey }}】 + </div> + <div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'VIEW'"> + <el-tag>点击菜单链接</el-tag> + 【{{ scope.row.eventKey }}】 + </div> + <div + v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'scancode_waitmsg'" + > + <el-tag>扫码结果</el-tag> + 【{{ scope.row.eventKey }}】 + </div> + <div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'scancode_push'"> + <el-tag>扫码结果</el-tag> + 【{{ scope.row.eventKey }}】 + </div> + <div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'pic_sysphoto'"> + <el-tag>系统拍照发图</el-tag> + </div> + <div + v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'pic_photo_or_album'" + > + <el-tag>拍照或者相册</el-tag> + </div> + <div v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'pic_weixin'"> + <el-tag>微信相册</el-tag> + </div> + <div + v-else-if="scope.row.type === MsgType.Event && scope.row.event === 'location_select'" + > + <el-tag>选择地理位置</el-tag> + </div> + <div v-else-if="scope.row.type === MsgType.Event"> + <el-tag type="danger">未知事件类型</el-tag> + </div> + <!-- 【消息】区域 --> + <div v-else-if="scope.row.type === MsgType.Text">{{ scope.row.content }}</div> + <div v-else-if="scope.row.type === MsgType.Voice"> + <wx-voice-player :url="scope.row.mediaUrl" :content="scope.row.recognition" /> + </div> + <div v-else-if="scope.row.type === MsgType.Image"> + <a target="_blank" :href="scope.row.mediaUrl"> + <img :src="scope.row.mediaUrl" style="width: 100px" /> + </a> + </div> + <div v-else-if="scope.row.type === MsgType.Video || scope.row.type === 'shortvideo'"> + <wx-video-player :url="scope.row.mediaUrl" style="margin-top: 10px" /> + </div> + <div v-else-if="scope.row.type === MsgType.Link"> + <el-tag>链接</el-tag> + : + <a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a> + </div> + <div v-else-if="scope.row.type === MsgType.Location"> + <WxLocation + :label="scope.row.label" + :location-y="scope.row.locationY" + :location-x="scope.row.locationX" + /> + </div> + <div v-else-if="scope.row.type === MsgType.Music"> + <WxMusic + :title="scope.row.title" + :description="scope.row.description" + :thumb-media-url="scope.row.thumbMediaUrl" + :music-url="scope.row.musicUrl" + :hq-music-url="scope.row.hqMusicUrl" + /> + </div> + <div v-else-if="scope.row.type === MsgType.News"> + <WxNews :articles="scope.row.articles" /> + </div> + <div v-else> + <el-tag type="danger">未知消息类型</el-tag> + </div> + </template> + </el-table-column> + <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> + <template #default="scope"> + <el-button + link + type="primary" + @click="emit('send', scope.row.userId)" + v-hasPermi="['mp:message:send']" + > + 消息 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + </div> +</template> + +<script lang="ts" setup> +import WxVideoPlayer from '@/views/mp/components/wx-video-play' +import WxVoicePlayer from '@/views/mp/components/wx-voice-play' +import WxLocation from '@/views/mp/components/wx-location' +import WxMusic from '@/views/mp/components/wx-music' +import WxNews from '@/views/mp/components/wx-news' +import { dateFormatter } from '@/utils/formatTime' +import { MsgType } from '@/views/mp/components/wx-msg/types' + +const props = defineProps({ + list: { + type: Array, + required: true + }, + loading: { + type: Boolean, + required: true + } +}) + +const emit = defineEmits<{ (e: 'send', v: number) }>() +</script> diff --git a/src/views/mp/message/index.vue b/src/views/mp/message/index.vue new file mode 100644 index 0000000..adceec5 --- /dev/null +++ b/src/views/mp/message/index.vue @@ -0,0 +1,152 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="公众号" prop="accountId"> + <WxAccountSelect @change="onAccountChanged" /> + </el-form-item> + <el-form-item label="消息类型" prop="type"> + <el-select v-model="queryParams.type" placeholder="请选择消息类型" class="!w-240px"> + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="用户标识" prop="openid"> + <el-input + v-model="queryParams.openid" + placeholder="请输入用户标识" + clearable + :v-on="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + style="width: 240px" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + range-separator="-" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="['00:00:00', '23:59:59']" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon icon="ep:search" class="mr-5px" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon icon="ep:refresh" class="mr-5px" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <MessageTable :list="list" :loading="loading" @send="handleSend" /> + <Pagination + v-show="total > 0" + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 发送消息的弹窗 --> + <el-dialog + title="粉丝消息列表" + v-model="messageBox.show" + @click="messageBox.show = true" + width="50%" + destroy-on-close + > + <WxMsg :user-id="messageBox.userId" /> + </el-dialog> +</template> +<script lang="ts" setup> +import * as MpMessageApi from '@/api/mp/message' +import WxMsg from '@/views/mp/components/wx-msg' +import WxAccountSelect from '@/views/mp/components/wx-account-select' +import MessageTable from './MessageTable.vue' +import { DICT_TYPE, getStrDictOptions } from '@/utils/dict' +import { MsgType } from '@/views/mp/components/wx-msg/types' +import type { FormInstance } from 'element-plus' + +defineOptions({ name: 'MpMessage' }) + +const loading = ref(false) +const total = ref(0) // 数据的总页数 +const list = ref<any[]>([]) // 当前页的列表数据 + +// 搜索参数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + openid: '', + accountId: -1, + type: MsgType.Text, + createTime: [] +}) +const queryFormRef = ref<FormInstance | null>(null) // 搜索的表单 + +// 消息对话框 +const messageBox = reactive({ + show: false, + userId: 0 +}) + +/** 侦听accountId */ +const onAccountChanged = (id: number) => { + queryParams.accountId = id + queryParams.pageNo = 1 + handleQuery() +} + +/** 查询列表 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +const getList = async () => { + try { + loading.value = true + const data = await MpMessageApi.getMessagePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = async () => { + // 暂存 accountId,并在 reset 后恢复 + const accountId = queryParams.accountId + queryFormRef.value?.resetFields() + queryParams.accountId = accountId + handleQuery() +} + +/** 打开消息发送窗口 */ +const handleSend = async (userId: number) => { + messageBox.userId = userId + messageBox.show = true +} +</script> diff --git a/src/views/mp/statistics/index.vue b/src/views/mp/statistics/index.vue new file mode 100644 index 0000000..37ca2a0 --- /dev/null +++ b/src/views/mp/statistics/index.vue @@ -0,0 +1,368 @@ +<template> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form class="-mb-15px" ref="queryForm" :inline="true" label-width="68px"> + <el-form-item label="公众号" prop="accountId"> + <el-select v-model="accountId" @change="getSummary" class="!w-240px"> + <el-option + v-for="item in accountList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="时间范围" prop="dateRange"> + <el-date-picker + v-model="dateRange" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + @change="getSummary" + class="!w-240px" + /> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 图表 --> + <ContentWrap> + <el-row> + <el-col :span="12" class="card-box"> + <el-card> + <template #header> + <div> + <span>用户增减数据</span> + </div> + </template> + <Echart :options="userSummaryOption" :height="420" /> + </el-card> + </el-col> + <el-col :span="12" class="card-box"> + <el-card> + <template #header> + <div> + <span>累计用户数据</span> + </div> + </template> + <Echart :options="userCumulateOption" :height="420" /> + </el-card> + </el-col> + <el-col :span="12" class="card-box"> + <el-card> + <template #header> + <div> + <span>消息概况数据</span> + </div> + </template> + <Echart :options="upstreamMessageOption" :height="420" /> + </el-card> + </el-col> + <el-col :span="12" class="card-box"> + <el-card> + <template #header> + <div> + <span>接口分析数据</span> + </div> + </template> + <Echart :options="interfaceSummaryOption" :height="420" /> + </el-card> + </el-col> + </el-row> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { formatDate, addTime, betweenDay, beginOfDay, endOfDay } from '@/utils/formatTime' +import * as StatisticsApi from '@/api/mp/statistics' +import * as MpAccountApi from '@/api/mp/account' + +defineOptions({ name: 'MpStatistics' }) + +const message = useMessage() // 消息弹窗 + +// 默认开始时间是当前日期-7,结束时间是当前日期-1 +const dateRange = ref([ + beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7)), + endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24)) +]) +const accountId = ref(-1) // 选中的公众号编号 +const accountList = ref<MpAccountApi.AccountVO[]>([]) // 公众号账号列表 + +const xAxisDate = ref([] as any[]) // X 轴的日期范围 +// 用户增减数据图表配置项 +const userSummaryOption = reactive({ + color: ['#67C23A', '#E5323E'], + legend: { + data: ['新增用户', '取消关注的用户'] + }, + tooltip: {}, + xAxis: { + data: [] as any[] // X 轴的日期范围 + }, + yAxis: { + minInterval: 1 + }, + series: [ + { + name: '新增用户', + type: 'bar', + label: { + show: true + }, + barGap: 0, + data: [] as any[] // 新增用户的数据 + }, + { + name: '取消关注的用户', + type: 'bar', + label: { + show: true + }, + data: [] as any[] // 取消关注的用户的数据 + } + ] +}) +// 累计用户数据图表配置项 +const userCumulateOption = reactive({ + legend: { + data: ['累计用户量'] + }, + xAxis: { + type: 'category', + data: [] as any[] + }, + yAxis: { + minInterval: 1 + }, + series: [ + { + name: '累计用户量', + data: [] as any[], // 累计用户量的数据 + type: 'line', + smooth: true, + label: { + show: true + } + } + ] +}) +// 消息发送概况数据图表配置项 +const upstreamMessageOption = reactive({ + color: ['#67C23A', '#E5323E'], + legend: { + data: ['用户发送人数', '用户发送条数'] + }, + tooltip: {}, + xAxis: { + data: [] as any[] // X 轴的日期范围 + }, + yAxis: { + minInterval: 1 + }, + series: [ + { + name: '用户发送人数', + type: 'line', + smooth: true, + label: { + show: true + }, + data: [] as any[] // 用户发送人数的数据 + }, + { + name: '用户发送条数', + type: 'line', + smooth: true, + label: { + show: true + }, + data: [] as any[] // 用户发送条数的数据 + } + ] +}) +// 接口分析况数据图表配置项 +const interfaceSummaryOption = reactive({ + color: ['#67C23A', '#E5323E', '#E6A23C', '#409EFF'], + legend: { + data: ['被动回复用户消息的次数', '失败次数', '最大耗时', '总耗时'] + }, + tooltip: {}, + xAxis: { + data: [] as any[] // X 轴的日期范围 + }, + yAxis: {}, + series: [ + { + name: '被动回复用户消息的次数', + type: 'bar', + label: { + show: true + }, + barGap: 0, + data: [] as any[] // 被动回复用户消息的次数的数据 + }, + { + name: '失败次数', + type: 'bar', + label: { + show: true + }, + data: [] as any[] // 失败次数的数据 + }, + { + name: '最大耗时', + type: 'bar', + label: { + show: true + }, + data: [] as any[] // 最大耗时的数据 + }, + { + name: '总耗时', + type: 'bar', + label: { + show: true + }, + data: [] as any[] // 总耗时的数据 + } + ] +}) + +/** 加载公众号账号的列表 */ +const getAccountList = async () => { + accountList.value = await MpAccountApi.getSimpleAccountList() + // 默认选中第一个 + if (accountList.value.length > 0) { + accountId.value = accountList.value[0].id! + } +} + +/** 加载数据 */ +const getSummary = () => { + // 如果没有选中公众号账号,则进行提示。 + if (!accountId) { + message.error('未选中公众号,无法统计数据') + return false + } + // 必须选择 7 天内,因为公众号有时间跨度限制为 7 + if (betweenDay(dateRange.value[0], dateRange.value[1]) >= 7) { + message.error('时间间隔 7 天以内,请重新选择') + return false + } + // 清空横坐标日期 + xAxisDate.value = [] + // 横坐标加载日期数据 + const days = betweenDay(dateRange.value[0], dateRange.value[1]) // 相差天数 + for (let i = 0; i <= days; i++) { + xAxisDate.value.push( + formatDate(addTime(dateRange.value[0], 3600 * 1000 * 24 * i), 'YYYY-MM-DD') + ) + } + // 初始化图表 + initUserSummaryChart() + initUserCumulateChart() + initUpstreamMessageChart() + interfaceSummaryChart() +} + +/** 用户增减数据 */ +const initUserSummaryChart = async () => { + userSummaryOption.xAxis.data = [] + userSummaryOption.series[0].data = [] + userSummaryOption.series[1].data = [] + try { + // 用户增减数据 + const data = await StatisticsApi.getUserSummary({ + accountId: accountId.value, + date: [formatDate(dateRange.value[0]), formatDate(dateRange.value[1])] + }) + // 横坐标 + userSummaryOption.xAxis.data = xAxisDate.value + // 处理数据 + xAxisDate.value.forEach((date, index) => { + data.forEach((item) => { + // 匹配日期 + const refDate = formatDate(new Date(item.refDate), 'YYYY-MM-DD') + if (refDate.indexOf(date) === -1) { + return + } + // 设置数据到对应的位置 + userSummaryOption.series[0].data[index] = item.newUser + userSummaryOption.series[1].data[index] = item.cancelUser + }) + }) + } catch {} +} + +/** 累计用户数据 */ +const initUserCumulateChart = async () => { + userCumulateOption.xAxis.data = [] + userCumulateOption.series[0].data = [] + // 发起请求 + try { + const data = await StatisticsApi.getUserCumulate({ + accountId: accountId.value, + date: [formatDate(dateRange.value[0]), formatDate(dateRange.value[1])] + }) + userCumulateOption.xAxis.data = xAxisDate.value + // 处理数据 + data.forEach((item, index) => { + userCumulateOption.series[0].data[index] = item.cumulateUser + }) + } catch {} +} + +/** 消息概况数据 */ +const initUpstreamMessageChart = async () => { + upstreamMessageOption.xAxis.data = [] + upstreamMessageOption.series[0].data = [] + upstreamMessageOption.series[1].data = [] + // 发起请求 + try { + const data = await StatisticsApi.getUpstreamMessage({ + accountId: accountId.value, + date: [formatDate(dateRange.value[0]), formatDate(dateRange.value[1])] + }) + upstreamMessageOption.xAxis.data = xAxisDate.value + // 处理数据 + data.forEach((item, index) => { + upstreamMessageOption.series[0].data[index] = item.messageUser + upstreamMessageOption.series[1].data[index] = item.messageCount + }) + } catch {} +} + +/** 接口分析数据 */ +const interfaceSummaryChart = async () => { + interfaceSummaryOption.xAxis.data = [] + interfaceSummaryOption.series[0].data = [] + interfaceSummaryOption.series[1].data = [] + interfaceSummaryOption.series[2].data = [] + interfaceSummaryOption.series[3].data = [] + // 发起请求 + try { + const data = await StatisticsApi.getInterfaceSummary({ + accountId: accountId.value, + date: [formatDate(dateRange.value[0]), formatDate(dateRange.value[1])] + }) + interfaceSummaryOption.xAxis.data = xAxisDate.value + // 处理数据 + data.forEach((item, index) => { + interfaceSummaryOption.series[0].data[index] = item.callbackCount + interfaceSummaryOption.series[1].data[index] = item.failCount + interfaceSummaryOption.series[2].data[index] = item.maxTimeCost + interfaceSummaryOption.series[3].data[index] = item.totalTimeCost + }) + } catch {} +} + +/** 初始化 */ +onMounted(async () => { + // 获取公众号下拉列表 + await getAccountList() + // 加载数据 + getSummary() +}) +</script> diff --git a/src/views/mp/tag/TagForm.vue b/src/views/mp/tag/TagForm.vue new file mode 100644 index 0000000..9a85bec --- /dev/null +++ b/src/views/mp/tag/TagForm.vue @@ -0,0 +1,98 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="标签名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入标签名称" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as MpTagApi from '@/api/mp/tag' +import type { FormInstance, FormRules } from 'element-plus' + +defineOptions({ name: 'MpTagForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref<'create' | 'update' | ''>('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + accountId: -1, + name: '' +}) +const formRules: FormRules = { + name: [{ required: true, message: '请输入标签名称', trigger: 'blur' }] +} +const formRef = ref<FormInstance | null>(null) // 表单 Ref + +const emit = defineEmits<{ + (e: 'success'): void +}>() + +/** 打开弹窗 */ +const open = async (type: 'create' | 'update', accountId: number, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + formData.value.accountId = accountId + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await MpTagApi.getTag(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value?.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as MpTagApi.TagVO + if (formType.value === 'create') { + await MpTagApi.createTag(data) + message.success(t('common.createSuccess')) + } else { + await MpTagApi.updateTag(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + accountId: -1, + name: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mp/tag/index.vue b/src/views/mp/tag/index.vue new file mode 100644 index 0000000..df76ce9 --- /dev/null +++ b/src/views/mp/tag/index.vue @@ -0,0 +1,154 @@ +<template> + <doc-alert title="公众号标签" url="https://doc.iocoder.cn/mp/tag/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="公众号" prop="accountId"> + <WxAccountSelect @change="onAccountChanged" /> + </el-form-item> + <el-form-item> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['mp:tag:create']" + :disabled="queryParams.accountId === 0" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleSync" + v-hasPermi="['mp:tag:sync']" + :disabled="queryParams.accountId === 0" + > + <Icon icon="ep:refresh" class="mr-5px" /> 同步 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="标签名称" align="center" prop="name" /> + <el-table-column label="粉丝数" align="center" prop="count" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['mp:tag:update']" + > + 修改 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['mp:tag:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <TagForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as MpTagApi from '@/api/mp/tag' +import TagForm from './TagForm.vue' +import WxAccountSelect from '@/views/mp/components/wx-account-select' + +defineOptions({ name: 'MpTag' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref<any[]>([]) // 列表的数据 + +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + accountId: -1 +}) + +const formRef = ref<InstanceType<typeof TagForm> | null>(null) + +/** 侦听公众号变化 **/ +const onAccountChanged = (id: number) => { + queryParams.accountId = id + queryParams.pageNo = 1 + getList() +} + +/** 查询列表 */ +const getList = async () => { + try { + loading.value = true + const data = await MpTagApi.getTagPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 添加/修改操作 */ +const openForm = (type: 'create' | 'update', id?: number) => { + formRef.value?.open(type, queryParams.accountId, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await MpTagApi.deleteTag(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 同步操作 */ +const handleSync = async () => { + try { + await message.confirm('是否确认同步标签?') + await MpTagApi.syncTag(queryParams.accountId as number) + message.success('同步标签成功') + await getList() + } catch {} +} +</script> diff --git a/src/views/mp/user/UserForm.vue b/src/views/mp/user/UserForm.vue new file mode 100644 index 0000000..818fdd8 --- /dev/null +++ b/src/views/mp/user/UserForm.vue @@ -0,0 +1,102 @@ +<template> + <Dialog v-model="dialogVisible" title="修改"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="昵称" prop="nickname"> + <el-input v-model="formData.nickname" placeholder="请输入昵称" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + <el-form-item label="标签" prop="tagIds"> + <el-select v-model="formData.tagIds" clearable multiple placeholder="请选择标签"> + <el-option + v-for="item in tagList" + :key="item.tagId" + :label="item.name" + :value="item.tagId" + /> + </el-select> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as MpTagApi from '@/api/mp/tag' +import * as MpUserApi from '@/api/mp/user' + +defineOptions({ name: 'MpUserForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const formData = ref({ + id: undefined, + nickname: undefined, + remark: undefined, + tagIds: [] +}) +const formRules = reactive({}) // 表单的校验 +const formRef = ref() // 表单 Ref +const tagList = ref([]) // 公众号标签列表 + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await MpUserApi.getUser(id) + } finally { + formLoading.value = false + } + } + // 加载标签 + tagList.value = await MpTagApi.getSimpleTagList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await MpUserApi.updateUser(formData.value) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + nickname: undefined, + remark: undefined, + tagIds: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mp/user/index.vue b/src/views/mp/user/index.vue new file mode 100644 index 0000000..6147351 --- /dev/null +++ b/src/views/mp/user/index.vue @@ -0,0 +1,181 @@ +<template> + <doc-alert title="公众号粉丝" url="https://doc.iocoder.cn/mp/user/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="公众号" prop="accountId"> + <WxAccountSelect @change="onAccountChanged" /> + </el-form-item> + <el-form-item label="用户标识" prop="openid"> + <el-input + v-model="queryParams.openid" + placeholder="请输入用户标识" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="昵称" prop="nickname"> + <el-input + v-model="queryParams.nickname" + placeholder="请输入昵称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> <Icon icon="ep:search" />搜索 </el-button> + <el-button @click="resetQuery"> <Icon icon="ep:refresh" />重置 </el-button> + <el-button + type="success" + plain + @click="handleSync" + v-hasPermi="['mp:user:sync']" + :disabled="queryParams.accountId === 0" + > + <Icon icon="ep:refresh" class="mr-5px" /> 同步 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="用户标识" align="center" prop="openid" width="260" /> + <el-table-column label="昵称" align="center" prop="nickname" /> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column label="标签" align="center" prop="tagIds" width="200"> + <template #default="scope"> + <span v-for="(tagId, index) in scope.row.tagIds" :key="index"> + <el-tag>{{ tagList.find((tag) => tag.tagId === tagId)?.name }} </el-tag> + </span> + </template> + </el-table-column> + <el-table-column label="订阅状态" align="center" prop="subscribeStatus"> + <template #default="scope"> + <el-tag v-if="scope.row.subscribeStatus === 0" type="success">已订阅</el-tag> + <el-tag v-else type="danger">未订阅</el-tag> + </template> + </el-table-column> + <el-table-column + label="订阅时间" + align="center" + prop="subscribeTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + type="primary" + link + @click="openForm(scope.row.id)" + v-hasPermi="['mp:user:update']" + > + 修改 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:修改 --> + <UserForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as MpUserApi from '@/api/mp/user' +import * as MpTagApi from '@/api/mp/tag' +import WxAccountSelect from '@/views/mp/components/wx-account-select' +import type { FormInstance } from 'element-plus' +import UserForm from './UserForm.vue' + +defineOptions({ name: 'MpUser' }) + +const message = useMessage() // 消息 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref<any[]>([]) // 列表的数据 + +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + accountId: -1, + openid: '', + nickname: '' +}) +const queryFormRef = ref<FormInstance | null>(null) // 搜索的表单 +const tagList = ref<any[]>([]) // 公众号标签列表 + +/** 侦听公众号变化 **/ +const onAccountChanged = (id: number) => { + queryParams.accountId = id + queryParams.pageNo = 1 + getList() +} + +/** 查询列表 */ +const getList = async () => { + try { + loading.value = true + const data = await MpUserApi.getUserPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + const accountId = queryParams.accountId + queryFormRef.value?.resetFields() + queryParams.accountId = accountId + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref<InstanceType<typeof UserForm> | null>(null) +const openForm = (id: number) => { + formRef.value?.open(id) +} + +/** 同步标签 */ +const handleSync = async () => { + try { + await message.confirm('是否确认同步粉丝?') + await MpUserApi.syncUser(queryParams.accountId) + message.success('开始从微信公众号同步粉丝信息,同步需要一段时间,建议稍后再查询') + await getList() + } catch {} +} + +/** 初始化 */ +onMounted(async () => { + tagList.value = await MpTagApi.getSimpleTagList() +}) +</script> diff --git a/src/views/pay/app/components/AppForm.vue b/src/views/pay/app/components/AppForm.vue new file mode 100644 index 0000000..b99766c --- /dev/null +++ b/src/views/pay/app/components/AppForm.vue @@ -0,0 +1,130 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="160px" + > + <el-form-item label="应用名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入应用名" /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="支付结果的回调地址" prop="orderNotifyUrl"> + <el-input v-model="formData.orderNotifyUrl" placeholder="请输入支付结果的回调地址" /> + </el-form-item> + <el-form-item label="退款结果的回调地址" prop="refundNotifyUrl"> + <el-input v-model="formData.refundNotifyUrl" placeholder="请输入退款结果的回调地址" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as AppApi from '@/api/pay/app' +import { CommonStatusEnum } from '@/utils/constants' + +defineOptions({ name: 'PayAppForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + packageId: undefined, + contactName: undefined, + contactMobile: undefined, + accountCount: undefined, + expireTime: undefined, + domain: undefined, + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive({ + name: [{ required: true, message: '应用名不能为空', trigger: 'blur' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }], + orderNotifyUrl: [{ required: true, message: '支付结果的回调地址不能为空', trigger: 'blur' }], + refundNotifyUrl: [{ required: true, message: '退款结果的回调地址不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await AppApi.getApp(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as AppApi.AppVO + if (formType.value === 'create') { + await AppApi.createApp(data) + message.success(t('common.createSuccess')) + } else { + await AppApi.updateApp(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + status: CommonStatusEnum.ENABLE, + remark: undefined, + orderNotifyUrl: undefined, + refundNotifyUrl: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/pay/app/components/channel/AlipayChannelForm.vue b/src/views/pay/app/components/channel/AlipayChannelForm.vue new file mode 100644 index 0000000..169ef8e --- /dev/null +++ b/src/views/pay/app/components/channel/AlipayChannelForm.vue @@ -0,0 +1,326 @@ +<template> + <div> + <Dialog v-model="dialogVisible" :title="dialogTitle" @closed="close" width="830px"> + <el-form + ref="formRef" + :model="formData" + :formRules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label-width="180px" label="渠道费率" prop="feeRate"> + <el-input v-model="formData.feeRate" placeholder="请输入渠道费率" clearable> + <template #append>%</template> + </el-input> + </el-form-item> + <el-form-item label-width="180px" label="开放平台 APPID" prop="config.appId"> + <el-input v-model="formData.config.appId" placeholder="请输入开放平台 APPID" clearable /> + </el-form-item> + <el-form-item label-width="180px" label="渠道状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="parseInt(dict.value)" + :label="parseInt(dict.value)" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label-width="180px" label="网关地址" prop="config.serverUrl"> + <el-radio-group v-model="formData.config.serverUrl"> + <el-radio label="https://openapi.alipay.com/gateway.do">线上环境</el-radio> + <el-radio label="https://openapi-sandbox.dl.alipaydev.com/gateway.do"> + 沙箱环境 + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label-width="180px" label="算法类型" prop="config.signType"> + <el-radio-group v-model="formData.config.signType"> + <el-radio key="RSA2" label="RSA2">RSA2</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label-width="180px" label="公钥类型" prop="config.mode"> + <el-radio-group v-model="formData.config.mode"> + <el-radio key="公钥模式" :label="1">公钥模式</el-radio> + <el-radio key="证书模式" :label="2">证书模式</el-radio> + </el-radio-group> + </el-form-item> + <div v-if="formData.config.mode === 1"> + <el-form-item label-width="180px" label="应用私钥" prop="config.privateKey"> + <el-input + type="textarea" + :autosize="{ minRows: 8, maxRows: 8 }" + v-model="formData.config.privateKey" + placeholder="请输入应用私钥" + clearable + :style="{ width: '100%' }" + /> + </el-form-item> + <el-form-item label-width="180px" label="支付宝公钥" prop="config.alipayPublicKey"> + <el-input + type="textarea" + :autosize="{ minRows: 8, maxRows: 8 }" + v-model="formData.config.alipayPublicKey" + placeholder="请输入支付宝公钥" + clearable + :style="{ width: '100%' }" + /> + </el-form-item> + </div> + <div v-if="formData.config.mode === 2"> + <el-form-item label-width="180px" label="应用私钥" prop="config.privateKey"> + <el-input + type="textarea" + :autosize="{ minRows: 8, maxRows: 8 }" + v-model="formData.config.privateKey" + placeholder="请输入应用私钥" + clearable + :style="{ width: '100%' }" + /> + </el-form-item> + <el-form-item label-width="180px" label="商户公钥应用证书" prop="config.appCertContent"> + <el-input + v-model="formData.config.appCertContent" + type="textarea" + placeholder="请上传商户公钥应用证书" + readonly + :autosize="{ minRows: 8, maxRows: 8 }" + :style="{ width: '100%' }" + /> + </el-form-item> + <el-form-item label-width="180px" label=""> + <el-upload + action="" + ref="privateKeyContentFile" + :limit="1" + :accept="fileAccept" + :http-request="appCertUpload" + :before-upload="fileBeforeUpload" + > + <el-button type="primary"> + <Icon icon="ep:upload" class="mr-5px" /> 点击上传 + </el-button> + </el-upload> + </el-form-item> + <el-form-item + label-width="180px" + label="支付宝公钥证书" + prop="config.alipayPublicCertContent" + > + <el-input + v-model="formData.config.alipayPublicCertContent" + type="textarea" + placeholder="请上传支付宝公钥证书" + readonly + :autosize="{ minRows: 8, maxRows: 8 }" + :style="{ width: '100%' }" + /> + </el-form-item> + <el-form-item label-width="180px" label=""> + <el-upload + ref="privateCertContentFile" + action="" + :limit="1" + :accept="fileAccept" + :before-upload="fileBeforeUpload" + :http-request="alipayPublicCertUpload" + > + <el-button type="primary"> + <Icon icon="ep:upload" class="mr-5px" /> 点击上传 + </el-button> + </el-upload> + </el-form-item> + <el-form-item label-width="180px" label="根证书" prop="config.rootCertContent"> + <el-input + v-model="formData.config.rootCertContent" + type="textarea" + placeholder="请上传根证书" + readonly + :autosize="{ minRows: 8, maxRows: 8 }" + :style="{ width: '100%' }" + /> + </el-form-item> + <el-form-item label-width="180px" label=""> + <el-upload + ref="privateCertContentFile" + :limit="1" + :accept="fileAccept" + action="" + :before-upload="fileBeforeUpload" + :http-request="rootCertUpload" + > + <el-button type="primary"> + <Icon icon="ep:upload" class="mr-5px" /> 点击上传 + </el-button> + </el-upload> + </el-form-item> + </div> + <el-form-item label-width="180px" label="备注" prop="remark"> + <el-input v-model="formData.remark" :style="{ width: '100%' }" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + </div> +</template> +<script lang="ts" setup> +import { CommonStatusEnum } from '@/utils/constants' +import { DICT_TYPE, getDictOptions } from '@/utils/dict' +import * as ChannelApi from '@/api/pay/channel' + +defineOptions({ name: 'AlipayChannelForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref<any>({ + appId: '', + code: '', + status: undefined, + feeRate: undefined, + remark: '', + config: { + appId: '', + serverUrl: null, + signType: '', + mode: null, + privateKey: '', + alipayPublicKey: '', + appCertContent: '', + alipayPublicCertContent: '', + rootCertContent: '' + } +}) +const formRules = { + feeRate: [{ required: true, message: '请输入渠道费率', trigger: 'blur' }], + status: [{ required: true, message: '渠道状态不能为空', trigger: 'blur' }], + 'config.appId': [{ required: true, message: '请输入开放平台上创建的应用的 ID', trigger: 'blur' }], + 'config.serverUrl': [{ required: true, message: '请传入网关地址', trigger: 'blur' }], + 'config.signType': [{ required: true, message: '请传入签名算法类型', trigger: 'blur' }], + 'config.mode': [{ required: true, message: '公钥类型不能为空', trigger: 'blur' }], + 'config.privateKey': [{ required: true, message: '请输入商户私钥', trigger: 'blur' }], + 'config.alipayPublicKey': [ + { required: true, message: '请输入支付宝公钥字符串', trigger: 'blur' } + ], + 'config.appCertContent': [{ required: true, message: '请上传商户公钥应用证书', trigger: 'blur' }], + 'config.alipayPublicCertContent': [ + { required: true, message: '请上传支付宝公钥证书', trigger: 'blur' } + ], + 'config.rootCertContent': [{ required: true, message: '请上传指定根证书', trigger: 'blur' }] +} +const fileAccept = '.crt' +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (appId, code) => { + dialogVisible.value = true + formLoading.value = true + resetForm(appId, code) + // 加载数据 + try { + const data = await ChannelApi.getChannel(appId, code) + if (data && data.id) { + formData.value = data + formData.value.config = JSON.parse(data.config) + } + dialogTitle.value = !formData.value.id ? '创建支付渠道' : '编辑支付渠道' + } finally { + formLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = { ...formData.value } as unknown as ChannelApi.ChannelVO + data.config = JSON.stringify(formData.value.config) + if (!data.id) { + await ChannelApi.createChannel(data) + message.success(t('common.createSuccess')) + } else { + await ChannelApi.updateChannel(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = (appId, code) => { + formData.value = { + appId: appId, + code: code, + status: CommonStatusEnum.ENABLE, + remark: '', + feeRate: null, + config: { + appId: '', + serverUrl: null, + signType: 'RSA2', + mode: null, + privateKey: '', + alipayPublicKey: '', + appCertContent: '', + alipayPublicCertContent: '', + rootCertContent: '' + } + } + formRef.value?.resetFields() +} + +const fileBeforeUpload = (file) => { + let format = '.' + file.name.split('.')[1] + if (format !== fileAccept) { + message.error(`请上传指定格式"${fileAccept}"文件`) + return false + } + let isRightSize = file.size / 1024 / 1024 < 2 + if (!isRightSize) { + message.error('文件大小超过 2MB') + } + return isRightSize +} + +const appCertUpload = (event) => { + const readFile = new FileReader() + readFile.onload = (e: any) => { + formData.value.config.appCertContent = e.target.result + } + readFile.readAsText(event.file) +} + +const alipayPublicCertUpload = (event) => { + const readFile = new FileReader() + readFile.onload = (e: any) => { + formData.value.config.alipayPublicCertContent = e.target.result + } + readFile.readAsText(event.file) +} + +const rootCertUpload = (event) => { + const readFile = new FileReader() + readFile.onload = (e: any) => { + formData.value.config.rootCertContent = e.target.result + } + readFile.readAsText(event.file) +} +</script> diff --git a/src/views/pay/app/components/channel/MockChannelForm.vue b/src/views/pay/app/components/channel/MockChannelForm.vue new file mode 100644 index 0000000..49cb3ab --- /dev/null +++ b/src/views/pay/app/components/channel/MockChannelForm.vue @@ -0,0 +1,122 @@ +<template> + <div> + <Dialog v-model="dialogVisible" :title="dialogTitle" @closed="close" width="800px"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label-width="180px" label="渠道状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="parseInt(dict.value)" + :label="parseInt(dict.value)" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label-width="180px" label="备注" prop="remark"> + <el-input v-model="formData.remark" :style="{ width: '100%' }" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + </div> +</template> +<script lang="ts" setup> +import { CommonStatusEnum } from '@/utils/constants' +import { DICT_TYPE, getDictOptions } from '@/utils/dict' +import * as ChannelApi from '@/api/pay/channel' + +defineOptions({ name: 'MockChannelForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref<any>({ + appId: '', + code: '', + status: undefined, + feeRate: 0, + remark: '', + config: { + name: 'mock-conf' + } +}) +const formRules = { + status: [{ required: true, message: '渠道状态不能为空', trigger: 'blur' }] +} +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (appId, code) => { + dialogVisible.value = true + formLoading.value = true + resetForm(appId, code) + // 加载数据 + try { + const data = await ChannelApi.getChannel(appId, code) + + if (data && data.id) { + formData.value = data + formData.value.config = JSON.parse(data.config) + } + dialogTitle.value = !formData.value.id ? '创建支付渠道' : '编辑支付渠道' + } finally { + formLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = { ...formData.value } as unknown as ChannelApi.ChannelVO + data.config = JSON.stringify(formData.value.config) + if (!data.id) { + await ChannelApi.createChannel(data) + message.success(t('common.createSuccess')) + } else { + await ChannelApi.updateChannel(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = (appId, code) => { + formData.value = { + appId: appId, + code: code, + status: CommonStatusEnum.ENABLE, + remark: '', + feeRate: 0, + config: { + name: 'mock-conf' + } + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/pay/app/components/channel/WalletChannelForm.vue b/src/views/pay/app/components/channel/WalletChannelForm.vue new file mode 100644 index 0000000..cbdb542 --- /dev/null +++ b/src/views/pay/app/components/channel/WalletChannelForm.vue @@ -0,0 +1,122 @@ +<template> + <div> + <Dialog v-model="dialogVisible" :title="dialogTitle" @closed="close" width="800px"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label-width="180px" label="渠道状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="parseInt(dict.value)" + :label="parseInt(dict.value)" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label-width="180px" label="备注" prop="remark"> + <el-input v-model="formData.remark" :style="{ width: '100%' }" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + </div> +</template> +<script lang="ts" setup> +import { CommonStatusEnum } from '@/utils/constants' +import { DICT_TYPE, getDictOptions } from '@/utils/dict' +import * as ChannelApi from '@/api/pay/channel' + +defineOptions({ name: 'WalletChannelForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref<any>({ + appId: '', + code: '', + status: undefined, + feeRate: 0, + remark: '', + config: { + name: 'mock-conf' + } +}) +const formRules = { + status: [{ required: true, message: '渠道状态不能为空', trigger: 'blur' }] +} +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (appId, code) => { + dialogVisible.value = true + formLoading.value = true + resetForm(appId, code) + // 加载数据 + try { + const data = await ChannelApi.getChannel(appId, code) + + if (data && data.id) { + formData.value = data + formData.value.config = JSON.parse(data.config) + } + dialogTitle.value = !formData.value.id ? '创建支付渠道' : '编辑支付渠道' + } finally { + formLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = { ...formData.value } as unknown as ChannelApi.ChannelVO + data.config = JSON.stringify(formData.value.config) + if (!data.id) { + await ChannelApi.createChannel(data) + message.success(t('common.createSuccess')) + } else { + await ChannelApi.updateChannel(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = (appId, code) => { + formData.value = { + appId: appId, + code: code, + status: CommonStatusEnum.ENABLE, + remark: '', + feeRate: 0, + config: { + name: 'mock-conf' + } + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/pay/app/components/channel/WeixinChannelForm.vue b/src/views/pay/app/components/channel/WeixinChannelForm.vue new file mode 100644 index 0000000..3ab53e8 --- /dev/null +++ b/src/views/pay/app/components/channel/WeixinChannelForm.vue @@ -0,0 +1,306 @@ +<template> + <div> + <Dialog v-model="dialogVisible" :title="dialogTitle" @close="close" width="800px"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label-width="180px" label="渠道费率" prop="feeRate"> + <el-input + v-model="formData.feeRate" + placeholder="请输入渠道费率" + clearable + :style="{ width: '100%' }" + > + <template #append>%</template> + </el-input> + </el-form-item> + <el-form-item label-width="180px" label="微信 APPID" prop="config.appId"> + <el-input + v-model="formData.config.appId" + placeholder="请输入微信 APPID" + clearable + :style="{ width: '100%' }" + /> + </el-form-item> + <el-form-item label-width="180px" label="商户号" prop="config.mchId"> + <el-input v-model="formData.config.mchId" :style="{ width: '100%' }" /> + </el-form-item> + <el-form-item label-width="180px" label="渠道状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="parseInt(dict.value)" + :label="parseInt(dict.value)" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label-width="180px" label="API 版本" prop="config.apiVersion"> + <el-radio-group v-model="formData.config.apiVersion"> + <el-radio label="v2">v2</el-radio> + <el-radio label="v3">v3</el-radio> + </el-radio-group> + </el-form-item> + <div v-if="formData.config.apiVersion === 'v2'"> + <el-form-item label-width="180px" label="商户密钥" prop="config.mchKey"> + <el-input + v-model="formData.config.mchKey" + placeholder="请输入商户密钥" + clearable + /> + </el-form-item> + <el-form-item + label-width="180px" + label="apiclient_cert.p12 证书" + prop="config.keyContent" + > + <el-input + v-model="formData.config.keyContent" + type="textarea" + placeholder="请上传 apiclient_cert.p12 证书" + readonly + :autosize="{ minRows: 8, maxRows: 8 }" + :style="{ width: '100%' }" + /> + </el-form-item> + <el-form-item label-width="180px" label=""> + <el-upload + :limit="1" + accept=".p12" + action="" + :before-upload="p12FileBeforeUpload" + :http-request="keyContentUpload" + > + <el-button type="primary"> + <Icon icon="ep:upload" class="mr-5px" /> + 点击上传 + </el-button> + </el-upload> + </el-form-item> + </div> + <div v-if="formData.config.apiVersion === 'v3'"> + <el-form-item label-width="180px" label="API V3 密钥" prop="config.apiV3Key"> + <el-input + v-model="formData.config.apiV3Key" + placeholder="请输入 API V3 密钥" + clearable + /> + </el-form-item> + <el-form-item + label-width="180px" + label="apiclient_key.pem 证书" + prop="config.privateKeyContent" + > + <el-input + v-model="formData.config.privateKeyContent" + type="textarea" + placeholder="请上传 apiclient_key.pem 证书" + readonly + :autosize="{ minRows: 8, maxRows: 8 }" + :style="{ width: '100%' }" + /> + </el-form-item> + <el-form-item label-width="180px" label="" prop="privateKeyContentFile"> + <el-upload + ref="privateKeyContentFile" + :limit="1" + accept=".pem" + action="" + :before-upload="pemFileBeforeUpload" + :http-request="privateKeyContentUpload" + > + <el-button type="primary"> + <Icon icon="ep:upload" class="mr-5px" /> + 点击上传 + </el-button> + </el-upload> + </el-form-item> + <el-form-item label-width="180px" label="证书序列号" prop="config.certSerialNo"> + <el-input + v-model="formData.config.certSerialNo" + placeholder="请输入证书序列号" + clearable + /> + </el-form-item> + </div> + <el-form-item label-width="180px" label="备注" prop="remark"> + <el-input v-model="formData.remark" :style="{ width: '100%' }" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + </div> +</template> +<script lang="ts" setup> +import { CommonStatusEnum } from '@/utils/constants' +import { DICT_TYPE, getDictOptions } from '@/utils/dict' +import * as ChannelApi from '@/api/pay/channel' + +defineOptions({ name: 'WeixinChannelForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref<any>({ + appId: '', + code: '', + status: undefined, + feeRate: undefined, + remark: '', + config: { + appId: '', + mchId: '', + apiVersion: '', + mchKey: '', + keyContent: '', + privateKeyContent: '', + certSerialNo: '', + apiV3Key: '' + } +}) +const formRules = { + feeRate: [{ required: true, message: '请输入渠道费率', trigger: 'blur' }], + status: [{ required: true, message: '渠道状态不能为空', trigger: 'blur' }], + 'config.mchId': [{ required: true, message: '请传入商户号', trigger: 'blur' }], + 'config.appId': [{ required: true, message: '请输入公众号APPID', trigger: 'blur' }], + 'config.apiVersion': [{ required: true, message: 'API版本不能为空', trigger: 'blur' }], + 'config.mchKey': [{ required: true, message: '请输入商户密钥', trigger: 'blur' }], + 'config.keyContent': [ + { required: true, message: '请上传 apiclient_cert.p12 证书', trigger: 'blur' } + ], + 'config.privateKeyContent': [ + { required: true, message: '请上传 apiclient_key.pem 证书', trigger: 'blur' } + ], + 'config.certSerialNo': [ + { required: true, message: '请输入证书序列号', trigger: 'blur' } + ], + 'config.apiV3Key': [{ required: true, message: '请上传 api V3 密钥值', trigger: 'blur' }] +} +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (appId, code) => { + dialogVisible.value = true + formLoading.value = true + resetForm(appId, code) + // 加载数据 + try { + const data = await ChannelApi.getChannel(appId, code) + if (data && data.id) { + formData.value = data + formData.value.config = JSON.parse(data.config) + } + dialogTitle.value = !formData.value.id ? '创建支付渠道' : '编辑支付渠道' + } finally { + formLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = { ...formData.value } as unknown as ChannelApi.ChannelVO + data.config = JSON.stringify(formData.value.config) + if (!data.id) { + await ChannelApi.createChannel(data) + message.success(t('common.createSuccess')) + } else { + await ChannelApi.updateChannel(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = (appId, code) => { + formData.value = { + appId: appId, + code: code, + status: CommonStatusEnum.ENABLE, + feeRate: undefined, + remark: '', + config: { + appId: '', + mchId: '', + apiVersion: '', + mchKey: '', + keyContent: '', + privateKeyContent: '', + certSerialNo: '', + apiV3Key: '' + } + } + formRef.value?.resetFields() +} + +/** + * apiclient_cert.p12、apiclient_key.pem 上传前的校验 + */ +const fileBeforeUpload = (file, fileAccept) => { + let format = '.' + file.name.split('.')[1] + if (format !== fileAccept) { + debugger + message.error('请上传指定格式"' + fileAccept + '"文件') + return false + } + let isRightSize = file.size / 1024 / 1024 < 2 + if (!isRightSize) { + message.error('文件大小超过 2MB') + } + return isRightSize +} + +const p12FileBeforeUpload = (file) => { + fileBeforeUpload(file, '.p12') +} + +const pemFileBeforeUpload = (file) => { + fileBeforeUpload(file, '.pem') +} + +/** + * 读取 apiclient_key.pem 到 privateKeyContent 字段 + */ +const privateKeyContentUpload = async (event) => { + const readFile = new FileReader() + readFile.onload = (e: any) => { + formData.value.config.privateKeyContent = e.target.result + } + readFile.readAsText(event.file) +} + +/** + * 读取 apiclient_cert.p12 到 keyContent 字段 + */ +const keyContentUpload = async (event) => { + const readFile = new FileReader() + readFile.onload = (e: any) => { + formData.value.config.keyContent = e.target.result.split(',')[1] + } + readFile.readAsDataURL(event.file) // 读成 base64 +} +</script> diff --git a/src/views/pay/app/index.vue b/src/views/pay/app/index.vue new file mode 100644 index 0000000..6b60d9b --- /dev/null +++ b/src/views/pay/app/index.vue @@ -0,0 +1,364 @@ +<template> + <doc-alert title="支付功能开启" url="https://doc.iocoder.cn/pay/build/" /> + <!-- 搜索 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="应用名" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入应用名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择开启状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon icon="ep:search" class="mr-5px" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon icon="ep:refresh" class="mr-5px" /> + 重置 + </el-button> + <el-button type="primary" plain @click="openForm('create')" v-hasPermi="['pay:app:create']"> + <Icon icon="ep:plus" class="mr-5px" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="应用编号" align="center" prop="id" /> + <el-table-column label="应用名" align="center" prop="name" /> + <el-table-column label="开启状态" align="center" prop="status"> + <template #default="scope"> + <el-switch + v-model="scope.row.status" + :active-value="0" + :inactive-value="1" + @change="handleStatusChange(scope.row)" + /> + </template> + </el-table-column> + <el-table-column label="支付宝配置" align="center"> + <el-table-column + :label="channel.name" + align="center" + v-for="channel in alipayChannels" + :key="channel.code" + > + <template #default="scope"> + <el-button + type="success" + v-if="isChannelExists(scope.row.channelCodes, channel.code)" + @click="openChannelForm(scope.row, channel.code)" + circle + > + <Icon icon="ep:check" /> + </el-button> + <el-button + v-else + type="danger" + circle + @click="openChannelForm(scope.row, channel.code)" + > + <Icon icon="ep:close" /> + </el-button> + </template> + </el-table-column> + </el-table-column> + <el-table-column label="微信配置" align="center"> + <el-table-column + :label="channel.name" + align="center" + v-for="channel in wxChannels" + :key="channel.code" + > + <template #default="scope"> + <el-button + type="success" + v-if="isChannelExists(scope.row.channelCodes, channel.code)" + @click="openChannelForm(scope.row, channel.code)" + circle + > + <Icon icon="ep:check" /> + </el-button> + <el-button + v-else + type="danger" + circle + @click="openChannelForm(scope.row, channel.code)" + > + <Icon icon="ep:close" /> + </el-button> + </template> + </el-table-column> + </el-table-column> + <el-table-column label="钱包支付配置" align="center"> + <el-table-column :label="PayChannelEnum.WALLET.name" align="center"> + <template #default="scope"> + <el-button + type="success" + circle + v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.WALLET.code)" + @click="openChannelForm(scope.row, PayChannelEnum.WALLET.code)" + > + <Icon icon="ep:check" /> + </el-button> + <el-button + v-else + type="danger" + circle + @click="openChannelForm(scope.row, PayChannelEnum.WALLET.code)" + > + <Icon icon="ep:close" /> + </el-button> + </template> + </el-table-column> + </el-table-column> + <el-table-column label="模拟支付配置" align="center"> + <el-table-column :label="PayChannelEnum.MOCK.name" align="center"> + <template #default="scope"> + <el-button + type="success" + circle + v-if="isChannelExists(scope.row.channelCodes, PayChannelEnum.MOCK.code)" + @click="openChannelForm(scope.row, PayChannelEnum.MOCK.code)" + > + <Icon icon="ep:check" /> + </el-button> + <el-button + v-else + type="danger" + circle + @click="openChannelForm(scope.row, PayChannelEnum.MOCK.code)" + > + <Icon icon="ep:close" /> + </el-button> + </template> + </el-table-column> + </el-table-column> + <el-table-column label="操作" align="center" min-width="110" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['pay:app:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['pay:app:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <AppForm ref="formRef" @success="getList" /> + <AlipayChannelForm ref="alipayFormRef" @success="getList" /> + <WeixinChannelForm ref="weixinFormRef" @success="getList" /> + <MockChannelForm ref="mockFormRef" @success="getList" /> + <WalletChannelForm ref="walletFormRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as AppApi from '@/api/pay/app' +import AppForm from './components/AppForm.vue' +import { CommonStatusEnum, PayChannelEnum } from '@/utils/constants' +import AlipayChannelForm from './components/channel/AlipayChannelForm.vue' +import WeixinChannelForm from './components/channel/WeixinChannelForm.vue' +import MockChannelForm from './components/channel/MockChannelForm.vue' +import WalletChannelForm from './components/channel/WalletChannelForm.vue' + +defineOptions({ name: 'PayApp' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined, + remark: undefined, + payNotifyUrl: undefined, + refundNotifyUrl: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +const alipayChannels = [ + PayChannelEnum.ALIPAY_APP, + PayChannelEnum.ALIPAY_PC, + PayChannelEnum.ALIPAY_WAP, + PayChannelEnum.ALIPAY_QR, + PayChannelEnum.ALIPAY_BAR +] + +const wxChannels = [ + PayChannelEnum.WX_LITE, + PayChannelEnum.WX_PUB, + PayChannelEnum.WX_APP, + PayChannelEnum.WX_NATIVE, + PayChannelEnum.WX_WAP, + PayChannelEnum.WX_BAR, +] + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await AppApi.getAppPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 应用状态修改 */ +const handleStatusChange = async (row: any) => { + let text = row.status === CommonStatusEnum.ENABLE ? '启用' : '停用' + try { + await message.confirm('确认要"' + text + '""' + row.name + '"应用吗?') + await AppApi.changeAppStatus({ id: row.id, status: row.status }) + message.success(text + '成功') + } catch { + row.status = + row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE + } +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await AppApi.deleteApp(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** + * 根据渠道编码判断渠道列表中是否存在 + * + * @param channels 渠道列表 + * @param channelCode 渠道编码 + */ +const isChannelExists = (channels, channelCode) => { + if (!channels) { + return false + } + return channels.indexOf(channelCode) !== -1 +} + +/** + * 新增支付渠道信息 + */ +const alipayFormRef = ref() +const weixinFormRef = ref() +const mockFormRef = ref() +const walletFormRef = ref() +const channelParam = reactive({ + appId: null, // 应用 ID + payCode: null // 渠道编码 +}) +const openChannelForm = async (row, payCode) => { + channelParam.appId = row.id + channelParam.payCode = payCode + if (payCode.indexOf('alipay_') === 0) { + alipayFormRef.value.open(row.id, payCode) + return + } + if (payCode.indexOf('wx_') === 0) { + weixinFormRef.value.open(row.id, payCode) + return + } + if (payCode.indexOf('mock') === 0) { + mockFormRef.value.open(row.id, payCode) + } + if (payCode.indexOf('wallet') === 0) { + mockFormRef.value.open(row.id, payCode) + } +} + +/** 初始化 **/ +onMounted(async () => { + await getList() +}) +</script> diff --git a/src/views/pay/cashier/index.vue b/src/views/pay/cashier/index.vue new file mode 100644 index 0000000..12723db --- /dev/null +++ b/src/views/pay/cashier/index.vue @@ -0,0 +1,482 @@ +<template> + <!-- 支付信息 --> + <el-card v-loading="loading"> + <el-descriptions title="支付信息" :column="3" border> + <el-descriptions-item label="支付单号">{{ payOrder.id }}</el-descriptions-item> + <el-descriptions-item label="商品标题">{{ payOrder.subject }}</el-descriptions-item> + <el-descriptions-item label="商品内容">{{ payOrder.body }}</el-descriptions-item> + <el-descriptions-item label="支付金额"> + ¥{{ (payOrder.price / 100.0).toFixed(2) }} + </el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(payOrder.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="过期时间"> + {{ formatDate(payOrder.expireTime) }} + </el-descriptions-item> + </el-descriptions> + </el-card> + + <!-- 支付选择框 --> + <el-card style="margin-top: 10px" v-loading="submitLoading" element-loading-text="提交支付中..."> + <!-- 支付宝 --> + <el-descriptions title="选择支付宝支付" /> + <div class="pay-channel-container"> + <div + class="box" + v-for="channel in channelsAlipay" + :key="channel.code" + @click="submit(channel.code)" + > + <img :src="channel.icon" /> + <div class="title">{{ channel.name }}</div> + </div> + </div> + <!-- 微信支付 --> + <el-descriptions title="选择微信支付" style="margin-top: 20px" /> + <div class="pay-channel-container"> + <div + class="box" + v-for="channel in channelsWechat" + :key="channel.code" + @click="submit(channel.code)" + > + <img :src="channel.icon" /> + <div class="title">{{ channel.name }}</div> + </div> + </div> + <!-- 其它支付 --> + <el-descriptions title="选择其它支付" style="margin-top: 20px" /> + <div class="pay-channel-container"> + <div + class="box" + v-for="channel in channelsMock" + :key="channel.code" + @click="submit(channel.code)" + > + <img :src="channel.icon" /> + <div class="title">{{ channel.name }}</div> + </div> + </div> + </el-card> + + <!-- 展示形式:二维码 URL --> + <Dialog + :title="qrCode.title" + v-model="qrCode.visible" + width="350px" + append-to-body + :close-on-press-escape="false" + > + <Qrcode :text="qrCode.url" :width="310" /> + </Dialog> + + <!-- 展示形式:BarCode 条形码 --> + <Dialog + :title="barCode.title" + v-model="barCode.visible" + width="500px" + append-to-body + :close-on-press-escape="false" + > + <el-form ref="form" label-width="80px"> + <el-row> + <el-col :span="24"> + <el-form-item label="条形码" prop="name"> + <el-input v-model="barCode.value" placeholder="请输入条形码" required /> + </el-form-item> + </el-col> + <el-col :span="24"> + <div style="text-align: right"> + 或使用 + <el-link + type="danger" + target="_blank" + href="https://baike.baidu.com/item/条码支付/10711903" + > + (扫码枪/扫码盒) + </el-link> + 扫码 + </div> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button + type="primary" + @click="submit0(barCode.channelCode)" + :disabled="barCode.value.length === 0" + > + 确认支付 + </el-button> + <el-button @click="barCode.visible = false">取 消</el-button> + </template> + </Dialog> +</template> + +<script lang="ts" setup> +import { Qrcode } from '@/components/Qrcode' +import * as PayOrderApi from '@/api/pay/order' +import { PayChannelEnum, PayDisplayModeEnum, PayOrderStatusEnum } from '@/utils/constants' +import { formatDate } from '@/utils/formatTime' +import { useTagsViewStore } from '@/store/modules/tagsView' + +// 导入图标 +import svg_alipay_pc from '@/assets/svgs/pay/icon/alipay_pc.svg' +import svg_alipay_wap from '@/assets/svgs/pay/icon/alipay_wap.svg' +import svg_alipay_app from '@/assets/svgs/pay/icon/alipay_app.svg' +import svg_alipay_qr from '@/assets/svgs/pay/icon/alipay_qr.svg' +import svg_alipay_bar from '@/assets/svgs/pay/icon/alipay_bar.svg' +import svg_wx_pub from '@/assets/svgs/pay/icon/wx_pub.svg' +import svg_wx_lite from '@/assets/svgs/pay/icon/wx_lite.svg' +import svg_wx_app from '@/assets/svgs/pay/icon/wx_app.svg' +import svg_wx_native from '@/assets/svgs/pay/icon/wx_native.svg' +import svg_wx_bar from '@/assets/svgs/pay/icon/wx_bar.svg' +import svg_mock from '@/assets/svgs/pay/icon/mock.svg' + +defineOptions({ name: 'PayCashier' }) + +const message = useMessage() // 消息弹窗 +const route = useRoute() // 路由 +const { push, currentRoute } = useRouter() // 路由 +const { delView } = useTagsViewStore() // 视图操作 + +const id = ref(undefined) // 支付单号 +const returnUrl = ref<string | undefined>(undefined) // 支付完的回调地址 +const loading = ref(false) // 支付信息的 loading +const payOrder = ref({}) // 支付信息 +const channelsAlipay = [ + { + name: '支付宝 PC 网站支付', + icon: svg_alipay_pc, + code: 'alipay_pc' + }, + { + name: '支付宝 Wap 网站支付', + icon: svg_alipay_wap, + code: 'alipay_wap' + }, + { + name: '支付宝 App 网站支付', + icon: svg_alipay_app, + code: 'alipay_app' + }, + { + name: '支付宝扫码支付', + icon: svg_alipay_qr, + code: 'alipay_qr' + }, + { + name: '支付宝条码支付', + icon: svg_alipay_bar, + code: 'alipay_bar' + } +] +const channelsWechat = [ + { + name: '微信公众号支付', + icon: svg_wx_pub, + code: 'wx_pub' + }, + { + name: '微信小程序支付', + icon: svg_wx_lite, + code: 'wx_lite' + }, + { + name: '微信 App 支付', + icon: svg_wx_app, + code: 'wx_app' + }, + { + name: '微信扫码支付', + icon: svg_wx_native, + code: 'wx_native' + }, + { + name: '微信条码支付', + icon: svg_wx_bar, + code: 'wx_bar' + } +] +const channelsMock = [ + { + name: '模拟支付', + icon: svg_mock, + code: 'mock' + } +] + +const submitLoading = ref(false) // 提交支付的 loading +const interval = ref<any>(undefined) // 定时任务,轮询是否完成支付 +const qrCode = ref({ + // 展示形式:二维码 + url: '', + title: '', + visible: false +}) +const barCode = ref({ + // 展示形式:条形码 + channelCode: '', + value: '', + title: '', + visible: false +}) + +/** 获得支付信息 */ +const getDetail = async () => { + // 1.1 未传递订单编号 + if (!id.value) { + message.error('未传递支付单号,无法查看对应的支付信息') + goReturnUrl('cancel') + return + } + const data = await PayOrderApi.getOrder(id.value) + payOrder.value = data + // 1.2 无法查询到支付信息 + if (!data) { + message.error('支付订单不存在,请检查!') + goReturnUrl('cancel') + return + } + // 1.3 如果已支付、或者已关闭,则直接跳转 + if (data.status === PayOrderStatusEnum.SUCCESS.status) { + message.success('支付成功') + goReturnUrl('success') + return + } else if (data.status === PayOrderStatusEnum.CLOSED.status) { + message.error('无法支付,原因:订单已关闭') + goReturnUrl('close') + return + } +} + +/** 提交支付 */ +const submit = (channelCode) => { + // 条形码支付,需要特殊处理 + if (channelCode === PayChannelEnum.ALIPAY_BAR.code) { + barCode.value = { + channelCode: channelCode, + value: '', + title: '“支付宝”条码支付', + visible: true + } + return + } + if (channelCode === PayChannelEnum.WX_BAR.code) { + barCode.value = { + channelCode: channelCode, + value: '', + title: '“微信”条码支付', + visible: true + } + return + } + + // 微信公众号、小程序支付,无法在 PC 网页中进行 + if (channelCode === PayChannelEnum.WX_PUB.code) { + message.error('微信公众号支付:不支持 PC 网站') + return + } + if (channelCode === PayChannelEnum.WX_LITE.code) { + message.error('微信小程序:不支持 PC 网站') + return + } + + // 默认的提交处理 + submit0(channelCode) +} + +const submit0 = async (channelCode) => { + submitLoading.value = true + try { + const formData = { + id: id.value, + channelCode: channelCode, + returnUrl: location.href, // 支付成功后,支付渠道跳转回当前页;再由当前页,跳转回 {@link returnUrl} 对应的地址 + ...buildSubmitParam(channelCode) + } + const data = await PayOrderApi.submitOrder(formData) + // 直接返回已支付的情况,例如说扫码支付 + if (data.status === PayOrderStatusEnum.SUCCESS.status) { + clearQueryInterval() + message.success('支付成功!') + goReturnUrl('success') + return + } + + // 展示对应的界面 + if (data.displayMode === PayDisplayModeEnum.URL.mode) { + displayUrl(channelCode, data) + } else if (data.displayMode === PayDisplayModeEnum.QR_CODE.mode) { + displayQrCode(channelCode, data) + } else if (data.displayMode === PayDisplayModeEnum.APP.mode) { + displayApp(channelCode) + } + + // 打开轮询任务 + createQueryInterval() + } finally { + submitLoading.value = false + } +} + +/** 构建提交支付的额外参数 */ +const buildSubmitParam = (channelCode) => { + // ① 支付宝 BarCode 支付时,需要传递 authCode 条形码 + if (channelCode === PayChannelEnum.ALIPAY_BAR.code) { + return { + channelExtras: { + auth_code: barCode.value.value + } + } + } + // ② 微信 BarCode 支付时,需要传递 authCode 条形码 + if (channelCode === PayChannelEnum.WX_BAR.code) { + return { + channelExtras: { + authCode: barCode.value.value + } + } + } + return {} +} + +/** 提交支付后,URL 的展示形式 */ +const displayUrl = (_channelCode, data) => { + location.href = data.displayContent + submitLoading.value = false +} + +/** 提交支付后(扫码支付) */ +const displayQrCode = (channelCode, data) => { + let title = '请使用手机浏览器“扫一扫”' + if (channelCode === PayChannelEnum.ALIPAY_WAP.code) { + // 考虑到 WAP 测试,所以引导手机浏览器搞 + } else if (channelCode.indexOf('alipay_') === 0) { + title = '请使用支付宝“扫一扫”扫码支付' + } else if (channelCode.indexOf('wx_') === 0) { + title = '请使用微信“扫一扫”扫码支付' + } + qrCode.value = { + title: title, + url: data.displayContent, + visible: true + } + submitLoading.value = false +} + +/** 提交支付后(App) */ +const displayApp = (channelCode) => { + if (channelCode === PayChannelEnum.ALIPAY_APP.code) { + message.error('支付宝 App 支付:无法在网页支付!') + } + if (channelCode === PayChannelEnum.WX_APP.code) { + message.error('微信 App 支付:无法在网页支付!') + } + submitLoading.value = false +} + +/** 轮询查询任务 */ +const createQueryInterval = () => { + if (interval.value) { + return + } + interval.value = setInterval(async () => { + const data = await PayOrderApi.getOrder(id.value) + // 已支付 + if (data.status === PayOrderStatusEnum.SUCCESS.status) { + clearQueryInterval() + message.success('支付成功!') + goReturnUrl('success') + } + // 已取消 + if (data.status === PayOrderStatusEnum.CLOSED.status) { + clearQueryInterval() + message.error('支付已关闭!') + goReturnUrl('close') + } + }, 1000 * 2) +} + +/** 清空查询任务 */ +const clearQueryInterval = () => { + // 清空各种弹窗 + qrCode.value = { + title: '', + url: '', + visible: false + } + // 清空任务 + clearInterval(interval.value) + interval.value = undefined +} + +/** + * 回到业务的 URL + * + * @param payResult 支付结果 + * ① success:支付成功 + * ② cancel:取消支付 + * ③ close:支付已关闭 + */ +const goReturnUrl = (payResult) => { + // 清理任务 + clearQueryInterval() + + // 未配置的情况下,只能关闭 + if (!returnUrl.value) { + delView(unref(currentRoute)) + return + } + + const url = + returnUrl.value.indexOf('?') >= 0 + ? returnUrl.value + '&payResult=' + payResult + : returnUrl.value + '?payResult=' + payResult + // 如果有配置,且是 http 开头,则浏览器跳转 + if (returnUrl.value.indexOf('http') === 0) { + location.href = url + } else { + delView(unref(currentRoute)) + push({ + path: url + }) + } +} + +/** 初始化 */ +onMounted(() => { + id.value = route.query.id + if (route.query.returnUrl) { + returnUrl.value = decodeURIComponent(route.query.returnUrl) + } + getDetail() +}) +</script> + +<style lang="scss" scoped> +.pay-channel-container { + display: flex; + margin-top: -10px; + + .box { + width: 160px; + padding-top: 10px; + padding-bottom: 5px; + margin-right: 10px; + text-align: center; + cursor: pointer; + border: 1px solid #e6ebf5; + + img { + width: 40px; + height: 40px; + } + + .title { + padding-top: 5px; + } + } +} +</style> diff --git a/src/views/pay/demo/order/index.vue b/src/views/pay/demo/order/index.vue new file mode 100644 index 0000000..32f0de1 --- /dev/null +++ b/src/views/pay/demo/order/index.vue @@ -0,0 +1,240 @@ +<template> + <doc-alert title="支付宝支付接入" url="https://doc.iocoder.cn/pay/alipay-pay-demo/" /> + <doc-alert title="支付宝、微信退款接入" url="https://doc.iocoder.cn/pay/refund-demo/" /> + <doc-alert title="微信公众号支付接入" url="https://doc.iocoder.cn/pay/wx-pub-pay-demo/" /> + <doc-alert title="微信小程序支付接入" url="https://doc.iocoder.cn/pay/wx-lite-pay-demo/" /> + + <!-- 操作工具栏 --> + <el-row :gutter="10" class="mb8"> + <el-col :span="1.5"> + <el-button type="primary" plain @click="openForm"><Icon icon="ep:plus" />发起订单</el-button> + </el-col> + </el-row> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="订单编号" align="center" prop="id" /> + <el-table-column label="用户编号" align="center" prop="userId" /> + <el-table-column label="商品名字" align="center" prop="spuName" /> + <el-table-column label="支付价格" align="center" prop="price"> + <template #default="scope"> + <span>¥{{ (scope.row.price / 100.0).toFixed(2) }}</span> + </template> + </el-table-column> + <el-table-column label="退款金额" align="center" prop="refundPrice"> + <template #default="scope"> + <span>¥{{ (scope.row.refundPrice / 100.0).toFixed(2) }}</span> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="支付单号" align="center" prop="payOrderId" /> + <el-table-column label="是否支付" align="center" prop="payStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.payStatus" /> + </template> + </el-table-column> + <el-table-column + label="支付时间" + align="center" + prop="payTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="退款时间" align="center" prop="refundTime" width="180"> + <template #default="scope"> + <span v-if="scope.row.refundTime">{{ formatDate(scope.row.refundTime) }}</span> + <span v-else-if="scope.row.payRefundId">退款中,等待退款结果</span> + </template> + </el-table-column> + <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> + <template #default="scope"> + <el-button link type="primary" @click="handlePay(scope.row)" v-if="!scope.row.payStatus"> + 前往支付 + </el-button> + <el-button + link + type="danger" + @click="handleRefund(scope.row)" + v-if="scope.row.payStatus && !scope.row.payRefundId" + > + 发起退款 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 对话框(添加 / 修改) --> + <Dialog title="发起订单" v-model="dialogVisible" width="500px"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="商品" prop="spuId"> + <el-select + v-model="formData.spuId" + placeholder="请输入下单商品" + clearable + style="width: 380px" + > + <el-option v-for="item in spus" :key="item.id" :label="item.name" :value="item.id"> + <span style="float: left">{{ item.name }}</span> + <span style="float: right; font-size: 13px; color: #8492a6"> + ¥{{ (item.price / 100.0).toFixed(2) }} + </span> + </el-option> + </el-select> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup name="PayDemoOrder"> +import * as PayDemoApi from '@/api/pay/demo' +import { dateFormatter, formatDate } from '@/utils/formatTime' +import { DICT_TYPE } from '@/utils/dict' + +const { t } = useI18n() // 国际化 +const router = useRouter() // 路由对象 +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +// 查询条件 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10 +}) + +const formRef = ref() + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PayDemoApi.getDemoOrderPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 支付按钮操作 */ +const handlePay = (row: any) => { + router.push({ + name: 'PayCashier', + query: { + id: row.payOrderId, + returnUrl: encodeURIComponent('/pay/demo/order?id=' + row.id) + } + }) +} + +/** 退款按钮操作 */ +const handleRefund = async (row: any) => { + const id = row.id + try { + await message.confirm('是否确认退款编号为"' + id + '"的示例订单?') + await PayDemoApi.refundDemoOrder(id) + await getList() + message.success('发起退款成功!') + } catch {} +} + +// ========== 弹窗 ========== + +// 商品数组 +const spus = ref([ + { + id: 1, + name: '华为手机', + price: 1 + }, + { + id: 2, + name: '小米电视', + price: 10 + }, + { + id: 3, + name: '苹果手表', + price: 100 + }, + { + id: 4, + name: '华硕笔记本', + price: 1000 + }, + { + id: 5, + name: '蔚来汽车', + price: 200000 + } +]) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const formData = ref<any>({}) // 表单数据 +const formRules = { + spuId: [{ required: true, message: '商品编号不能为空', trigger: 'blur' }] +} + +/** 表单重置 */ +const reset = () => { + formData.value = { + spuId: undefined + } + formRef.value?.resetFields() +} + +/** 新增按钮操作 */ +const openForm = () => { + reset() + dialogVisible.value = true +} + +/** 提交按钮 */ +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await PayDemoApi.createDemoOrder(formData.value) + message.success(t('common.createSuccess')) + dialogVisible.value = false + } finally { + formLoading.value = false + getList() + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/pay/demo/transfer/DemoTransferForm.vue b/src/views/pay/demo/transfer/DemoTransferForm.vue new file mode 100644 index 0000000..e5448f1 --- /dev/null +++ b/src/views/pay/demo/transfer/DemoTransferForm.vue @@ -0,0 +1,122 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="800px"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label="转账类型" prop="type"> + <el-radio-group v-model="formData.type"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.PAY_TRANSFER_TYPE)" + :key="dict.value" + :label="dict.value" + :disabled="dict.value === 2 || dict.value === 3 || dict.value === 4" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="转账金额(元)" prop="price"> + <el-input-number + v-model="formData.price" + :min="0" + :precision="2" + :step="0.01" + placeholder="请输入转账金额" + style="width: 200px" + /> + </el-form-item> + <el-form-item label="收款人姓名" prop="userName"> + <el-input v-model="formData.userName" placeholder="请输入收款人姓名" /> + </el-form-item> + <el-form-item v-show="formData.type === 1" label="支付宝登录账号" prop="alipayLogonId"> + <el-input v-model="formData.alipayLogonId" placeholder="请输入支付宝登录账号" /> + </el-form-item> + <el-form-item v-show="formData.type === 2" label="微信 openid" prop="openid"> + <el-input v-model="formData.openid" placeholder="请输入微信 openid" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as DemoTransferApi from '@/api/pay/demo/transfer' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { yuanToFen } from '@/utils' +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + price: undefined, + type: undefined, + userName: undefined, + alipayLogonId: undefined, + openid: undefined +}) +const formRules = reactive({ + price: [{ required: true, message: '转账金额不能为空', trigger: 'blur' }], + type: [{ required: true, message: '转账类型不能为空', trigger: 'change' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() +} +/** 关闭弹窗 */ +const close = async () => { + dialogVisible.value = false + resetForm() +} +defineExpose({ open, close }) // 提供 open, close 方法,用于打开, 关闭弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = { ...formData.value } + data.price = yuanToFen(data.price) + if (formType.value === 'create') { + await DemoTransferApi.createDemoTransfer(data) + message.success(t('common.createSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + price: undefined, + userName: undefined, + alipayLogonId: undefined, + openid: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/pay/demo/transfer/index.vue b/src/views/pay/demo/transfer/index.vue new file mode 100644 index 0000000..44d07b1 --- /dev/null +++ b/src/views/pay/demo/transfer/index.vue @@ -0,0 +1,159 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button type="primary" plain @click="openForm('create')" + ><Icon icon="ep:plus" />创建业务转账单 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true"> + <el-table-column label="订单编号" align="center" prop="id" /> + <el-table-column label="转账类型" align="center" prop="type" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_TRANSFER_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column label="转账金额" align="center" prop="price"> + <template #default="scope"> + <span>¥{{ (scope.row.price / 100.0).toFixed(2) }}</span> + </template> + </el-table-column> + <el-table-column label="收款人姓名" align="center" prop="userName" width="120" /> + <el-table-column label="支付宝登录账号" align="center" prop="alipayLogonId" width="180" /> + <el-table-column label="微信 openid" align="center" prop="openid" width="120" /> + <el-table-column label="转账状态" align="center" prop="transferStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_TRANSFER_STATUS" :value="scope.row.transferStatus" /> + </template> + </el-table-column> + <el-table-column label="转账单号" align="center" prop="payTransferId" /> + <el-table-column label="支付渠道" align="center" prop="payChannelCode" /> + <el-table-column + label="转账时间" + align="center" + prop="transferTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column + label="操作" + align="center" + class-name="small-padding fixed-width" + width="100" + fixed="right" + > + <template #default="scope"> + <el-button + link + type="primary" + @click="handleTransfer(scope.row)" + v-if="scope.row.transferStatus === 0" + v-hasPermi="['pay:transfer:create']" + > + 发起转账 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <DemoTransferForm ref="demoFormRef" @success="getList" /> + <CreatePayTransfer ref="payTransferRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as DemoTransferApi from '@/api/pay/demo/transfer' +import * as PayTransferApi from '@/api/pay/transfer' +import DemoTransferForm from './DemoTransferForm.vue' +import CreatePayTransfer from '../../transfer/CreatePayTransfer.vue' +import { DICT_TYPE } from '@/utils/dict' +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10 +}) +const queryFormRef = ref() // 搜索的表单 + +let payTransfer = { + appId: undefined, + merchantTransferId: undefined, + type: undefined, + price: undefined, + subject: undefined, + userName: undefined, + alipayLogonId: undefined, + openid: undefined +} as PayTransferApi.TransferVO // 传递给转账订单的数据 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DemoTransferApi.getDemoTransferPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 创建业务转账单操作 */ +const demoFormRef = ref() +const payTransferRef = ref() +const openForm = (type: string) => { + demoFormRef.value.open(type) +} + +/** 发起转账操作 */ +const handleTransfer = (row: any) => { + payTransfer = { ...row } + payTransfer.merchantTransferId = row.id.toString() + payTransfer.subject = '示例转账' + payTransferRef.value.showPayTransfer(payTransfer) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/pay/notify/NotifyDetail.vue b/src/views/pay/notify/NotifyDetail.vue new file mode 100644 index 0000000..938a3ee --- /dev/null +++ b/src/views/pay/notify/NotifyDetail.vue @@ -0,0 +1,86 @@ +<template> + <Dialog v-model="dialogVisible" title="通知详情" width="50%"> + <el-descriptions :column="2"> + <el-descriptions-item label="商户订单编号"> + <el-tag>{{ detailData.merchantOrderId }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="通知状态"> + <dict-tag :type="DICT_TYPE.PAY_NOTIFY_STATUS" :value="detailData.status" /> + </el-descriptions-item> + + <el-descriptions-item label="应用编号">{{ detailData.appId }}</el-descriptions-item> + <el-descriptions-item label="应用名称">{{ detailData.appName }}</el-descriptions-item> + + <el-descriptions-item label="关联编号">{{ detailData.dataId }}</el-descriptions-item> + <el-descriptions-item label="通知类型"> + <dict-tag :type="DICT_TYPE.PAY_NOTIFY_TYPE" :value="detailData.type" /> + </el-descriptions-item> + + <el-descriptions-item label="通知次数">{{ detailData.notifyTimes }}</el-descriptions-item> + <el-descriptions-item label="最大通知次数"> + {{ detailData.maxNotifyTimes }} + </el-descriptions-item> + + <el-descriptions-item label="最后通知时间"> + {{ formatDate(detailData.lastExecuteTime) }} + </el-descriptions-item> + <el-descriptions-item label="下次通知时间"> + {{ formatDate(detailData.nextNotifyTime) }} + </el-descriptions-item> + + <el-descriptions-item label="创建时间"> + {{ formatDate(detailData.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="更新时间"> + {{ formatDate(detailData.updateTime) }} + </el-descriptions-item> + </el-descriptions> + + <!-- 分割线 --> + <el-divider /> + + <el-descriptions :column="1" direction="vertical" border> + <el-descriptions-item label="回调日志"> + <el-table :data="detailData.logs"> + <el-table-column label="日志编号" align="center" prop="id" /> + <el-table-column label="通知状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_NOTIFY_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="通知次数" align="center" prop="notifyTimes" /> + <el-table-column label="通知时间" align="center" prop="lastExecuteTime" width="180"> + <template #default="scope"> + <span>{{ formatDate(scope.row.createTime) }}</span> + </template> + </el-table-column> + <el-table-column label="响应结果" align="center" prop="response" /> + </el-table> + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import * as PayNotifyApi from '@/api/pay/notify' +import { formatDate } from '@/utils/formatTime' + +defineOptions({ name: 'PayNotifyDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({}) + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = await PayNotifyApi.getNotifyTaskDetail(id) + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/pay/notify/index.vue b/src/views/pay/notify/index.vue new file mode 100644 index 0000000..5daf754 --- /dev/null +++ b/src/views/pay/notify/index.vue @@ -0,0 +1,224 @@ +<template> + <doc-alert title="支付功能开启" url="https://doc.iocoder.cn/pay/build/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="100px" + > + <el-form-item label="应用编号" prop="appId"> + <el-select + v-model="queryParams.appId" + placeholder="请选择应用信息" + clearable + filterable + class="!w-240px" + > + <el-option v-for="item in appList" :key="item.id" :label="item.name" :value="item.id" /> + </el-select> + </el-form-item> + <el-form-item label="通知类型" prop="type"> + <el-select + v-model="queryParams.type" + placeholder="请选择通知类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.PAY_NOTIFY_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="关联编号" prop="dataId"> + <el-input + v-model="queryParams.dataId" + placeholder="请输入关联编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="通知状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择通知状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.PAY_NOTIFY_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="商户订单编号" prop="merchantOrderId"> + <el-input + v-model="queryParams.merchantOrderId" + placeholder="请输入商户订单编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + style="width: 240px" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + range-separator="-" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="任务编号" align="center" prop="id" /> + <el-table-column label="应用编号" align="center" prop="appName" /> + <el-table-column label="商户订单编号" align="center" prop="merchantOrderId" /> + <el-table-column label="通知类型" align="center" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_NOTIFY_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column label="关联编号" align="center" prop="dataId" /> + <el-table-column label="通知状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_NOTIFY_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="最后通知时间" + align="center" + prop="lastExecuteTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column + label="下次通知时间" + align="center" + prop="nextNotifyTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="通知次数" align="center" prop="notifyTimes"> + <template #default="scope"> + <el-tag size="small" type="success"> + {{ scope.row.notifyTimes }} / {{ scope.row.maxNotifyTimes }} + </el-tag> + </template> + </el-table-column> + <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openDetail(scope.row.id)" + v-hasPermi="['pay:notify:query']" + > + 查看详情 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:预览 --> + <NotifyDetail ref="detailRef" /> +</template> + +<script lang="ts" setup> +import * as PayNotifyApi from '@/api/pay/notify' +import * as PayAppApi from '@/api/pay/app' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import NotifyDetail from './NotifyDetail.vue' + +defineOptions({ name: 'PayNotify' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref() // 列表的数据 +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + appId: null, + type: null, + dataId: null, + status: null, + merchantOrderId: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const appList = ref([]) // 支付应用列表集合 +// 是否显示弹出层 +const open = ref(false) +// 通知详情 +const notifyDetail = ref<any>({ + logs: [] +}) + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.value.pageNo = 1 + getList() +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PayNotifyApi.getNotifyTaskPage(queryParams.value) + list.value = data.list + total.value = data.total + loading.value = false + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + handleQuery() +} + +/** 详情按钮操作 */ +const detailRef = ref() +const openDetail = (id: number) => { + detailRef.value.open(id) +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 获得筛选项 + appList.value = await PayAppApi.getAppList() +}) +</script> diff --git a/src/views/pay/order/OrderDetail.vue b/src/views/pay/order/OrderDetail.vue new file mode 100644 index 0000000..895bc16 --- /dev/null +++ b/src/views/pay/order/OrderDetail.vue @@ -0,0 +1,113 @@ +<template> + <Dialog v-model="dialogVisible" title="订单详情" width="700px"> + <el-descriptions :column="2" label-class-name="desc-label"> + <el-descriptions-item label="商户单号"> + <el-tag size="small">{{ detailData.merchantOrderId }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="支付单号"> + <el-tag type="warning" size="small" v-if="detailData.no">{{ detailData.no }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="应用编号">{{ detailData.appId }}</el-descriptions-item> + <el-descriptions-item label="应用名称">{{ detailData.appName }}</el-descriptions-item> + <el-descriptions-item label="支付状态"> + <dict-tag :type="DICT_TYPE.PAY_ORDER_STATUS" :value="detailData.status" size="small" /> + </el-descriptions-item> + <el-descriptions-item label="支付金额"> + <el-tag type="success" size="small">¥{{ (detailData.price / 100.0).toFixed(2) }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="手续费"> + <el-tag type="warning" size="small"> + ¥{{ (detailData.channelFeePrice / 100.0).toFixed(2) }} + </el-tag> + </el-descriptions-item> + <el-descriptions-item label="手续费比例"> + {{ (detailData.channelFeeRate / 100.0).toFixed(2) }}% + </el-descriptions-item> + <el-descriptions-item label="支付时间"> + {{ formatDate(detailData.successTime) }} + </el-descriptions-item> + <el-descriptions-item label="失效时间"> + {{ formatDate(detailData.expireTime) }} + </el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(detailData.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="更新时间"> + {{ formatDate(detailData.updateTime) }} + </el-descriptions-item> + </el-descriptions> + <!-- 分割线 --> + <el-divider /> + <el-descriptions :column="2" label-class-name="desc-label"> + <el-descriptions-item label="商品标题">{{ detailData.subject }}</el-descriptions-item> + <el-descriptions-item label="商品描述">{{ detailData.body }}</el-descriptions-item> + <el-descriptions-item label="支付渠道"> + <dict-tag :type="DICT_TYPE.PAY_CHANNEL_CODE" :value="detailData.channelCode" /> + </el-descriptions-item> + <el-descriptions-item label="支付 IP">{{ detailData.userIp }}</el-descriptions-item> + <el-descriptions-item label="渠道单号"> + <el-tag size="mini" type="success" v-if="detailData.channelOrderNo"> + {{ detailData.channelOrderNo }} + </el-tag> + </el-descriptions-item> + <el-descriptions-item label="渠道用户">{{ detailData.channelUserId }}</el-descriptions-item> + <el-descriptions-item label="退款金额"> + <el-tag size="mini" type="danger"> + ¥{{ (detailData.refundPrice / 100.0).toFixed(2) }} + </el-tag> + </el-descriptions-item> + <el-descriptions-item label="通知 URL">{{ detailData.notifyUrl }}</el-descriptions-item> + </el-descriptions> + <!-- 分割线 --> + <el-divider /> + <el-descriptions :column="1" label-class-name="desc-label" direction="vertical" border> + <el-descriptions-item label="支付通道异步回调内容"> + <el-text style="white-space: pre-wrap; word-break: break-word"> + {{ detailData.extension.channelNotifyData }} + </el-text> + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import * as OrderApi from '@/api/pay/order' +import { formatDate } from '@/utils/formatTime' + +defineOptions({ name: 'PayOrderDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({ + extension: {} +}) + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = await OrderApi.getOrderDetail(id) + if (!detailData.value.extension) { + detailData.value.extension = {} + } + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> +<style> +.tag-purple { + color: #722ed1; + background: #f9f0ff; + border-color: #d3adf7; +} + +.tag-pink { + color: #eb2f96; + background: #fff0f6; + border-color: #ffadd2; +} +</style> diff --git a/src/views/pay/order/index.vue b/src/views/pay/order/index.vue new file mode 100644 index 0000000..1602659 --- /dev/null +++ b/src/views/pay/order/index.vue @@ -0,0 +1,273 @@ +<template> + <doc-alert title="支付宝支付接入" url="https://doc.iocoder.cn/pay/alipay-pay-demo/" /> + <doc-alert title="微信公众号支付接入" url="https://doc.iocoder.cn/pay/wx-pub-pay-demo/" /> + <doc-alert title="微信小程序支付接入" url="https://doc.iocoder.cn/pay/wx-lite-pay-demo/" /> + + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="100px" + > + <el-form-item label="应用编号" prop="appId"> + <el-select + clearable + v-model="queryParams.appId" + placeholder="请选择应用信息" + class="!w-240px" + > + <el-option v-for="item in appList" :key="item.id" :label="item.name" :value="item.id" /> + </el-select> + </el-form-item> + <el-form-item label="支付渠道" prop="channelCode"> + <el-select + v-model="queryParams.channelCode" + placeholder="请选择支付渠道" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.PAY_CHANNEL_CODE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="商户单号" prop="merchantOrderId"> + <el-input + v-model="queryParams.merchantOrderId" + placeholder="请输入商户单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="支付单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入支付单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="渠道单号" prop="channelOrderNo"> + <el-input + v-model="queryParams.channelOrderNo" + placeholder="请输入渠道单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="支付状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择支付状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.PAY_ORDER_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['system:tenant:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" width="80" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="支付金额" align="center" prop="price" width="100"> + <template #default="scope"> ¥{{ parseFloat(scope.row.price / 100).toFixed(2) }} </template> + </el-table-column> + <el-table-column label="退款金额" align="center" prop="refundPrice" width="100"> + <template #default="scope"> + ¥{{ parseFloat(scope.row.refundPrice / 100).toFixed(2) }} + </template> + </el-table-column> + <el-table-column label="手续金额" align="center" prop="channelFeePrice" width="100"> + <template #default="scope"> + ¥{{ parseFloat(scope.row.channelFeePrice / 100).toFixed(2) }} + </template> + </el-table-column> + <el-table-column label="订单号" align="left" width="300"> + <template #default="scope"> + <p class="order-font"> + <el-tag size="small"> 商户</el-tag> {{ scope.row.merchantOrderId }} + </p> + <p class="order-font" v-if="scope.row.no"> + <el-tag size="small" type="warning">支付</el-tag> {{ scope.row.no }} + </p> + <p class="order-font" v-if="scope.row.channelOrderNo"> + <el-tag size="small" type="success">渠道</el-tag> {{ scope.row.channelOrderNo }} + </p> + </template> + </el-table-column> + <el-table-column label="支付状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_ORDER_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="支付渠道" align="center" prop="channelCode" width="140"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_CHANNEL_CODE" :value="scope.row.channelCode" /> + </template> + </el-table-column> + <el-table-column + label="支付时间" + align="center" + prop="successTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="支付应用" align="center" prop="appName" width="100" /> + <el-table-column label="商品标题" align="center" prop="subject" width="180" /> + <el-table-column label="操作" align="center" fixed="right"> + <template #default="scope"> + <el-button + type="primary" + link + @click="openDetail(scope.row.id)" + v-hasPermi="['pay:order:query']" + > + 详情 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:预览 --> + <OrderDetail ref="detailRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as OrderApi from '@/api/pay/order' +import OrderDetail from './OrderDetail.vue' +import download from '@/utils/download' + +defineOptions({ name: 'PayOrder' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(false) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + appId: null, + channelCode: null, + merchantOrderId: null, + channelOrderNo: null, + no: null, + status: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出等待 +const appList = ref([]) // 支付应用列表集合 + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await OrderApi.getOrderPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await OrderApi.exportOrder(queryParams) + download.excel(data, '支付订单.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 预览详情 */ +const detailRef = ref() +const openDetail = (id: number) => { + detailRef.value.open(id) +} + +/** 初始化 **/ +onMounted(async () => { + await getList() +}) +</script> +<style> +.order-font { + padding: 2px 0; + font-size: 12px; +} +</style> diff --git a/src/views/pay/refund/RefundDetail.vue b/src/views/pay/refund/RefundDetail.vue new file mode 100644 index 0000000..20a1654 --- /dev/null +++ b/src/views/pay/refund/RefundDetail.vue @@ -0,0 +1,95 @@ +<template> + <Dialog v-model="dialogVisible" title="详情" width="700px"> + <el-descriptions :column="2" label-class-name="desc-label"> + <el-descriptions-item label="商户退款单号"> + <el-tag size="small">{{ refundDetail.merchantRefundId }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="渠道退款单号"> + <el-tag type="success" size="small" v-if="refundDetail.channelRefundNo">{{ + refundDetail.channelRefundNo + }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="商户支付单号"> + <el-tag size="small">{{ refundDetail.merchantOrderId }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="渠道支付单号"> + <el-tag type="success" size="small">{{ refundDetail.channelOrderNo }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="应用编号">{{ refundDetail.appId }}</el-descriptions-item> + <el-descriptions-item label="应用名称">{{ refundDetail.appName }}</el-descriptions-item> + <el-descriptions-item label="支付金额"> + <el-tag type="success" size="small"> + ¥{{ (refundDetail.payPrice / 100.0).toFixed(2) }} + </el-tag> + </el-descriptions-item> + <el-descriptions-item label="退款金额"> + <el-tag size="mini" type="danger"> + ¥{{ (refundDetail.refundPrice / 100.0).toFixed(2) }} + </el-tag> + </el-descriptions-item> + <el-descriptions-item label="退款状态"> + <dict-tag :type="DICT_TYPE.PAY_REFUND_STATUS" :value="refundDetail.status" /> + </el-descriptions-item> + <el-descriptions-item label="退款时间"> + {{ formatDate(refundDetail.successTime) }} + </el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(refundDetail.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="更新时间"> + {{ formatDate(refundDetail.updateTime) }} + </el-descriptions-item> + </el-descriptions> + <!-- 分割线 --> + <el-divider /> + <el-descriptions :column="2" label-class-name="desc-label"> + <el-descriptions-item label="退款渠道"> + <dict-tag :type="DICT_TYPE.PAY_CHANNEL_CODE" :value="refundDetail.channelCode" /> + </el-descriptions-item> + <el-descriptions-item label="退款原因">{{ refundDetail.reason }}</el-descriptions-item> + <el-descriptions-item label="退款 IP">{{ refundDetail.userIp }}</el-descriptions-item> + <el-descriptions-item label="通知 URL">{{ refundDetail.notifyUrl }}</el-descriptions-item> + </el-descriptions> + <!-- 分割线 --> + <el-divider /> + <el-descriptions :column="2" label-class-name="desc-label"> + <el-descriptions-item label="渠道错误码"> + {{ refundDetail.channelErrorCode }} + </el-descriptions-item> + <el-descriptions-item label="渠道错误码描述"> + {{ refundDetail.channelErrorMsg }} + </el-descriptions-item> + </el-descriptions> + <el-descriptions :column="1" label-class-name="desc-label" direction="vertical" border> + <el-descriptions-item label="支付通道异步回调内容"> + <el-text style="white-space: pre-wrap; word-break: break-word"> + {{ refundDetail.channelNotifyData }} + </el-text> + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import * as RefundApi from '@/api/pay/refund' + +defineOptions({ name: 'PayRefundDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const refundDetail = ref({}) + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + refundDetail.value = await RefundApi.getRefund(id) + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/pay/refund/index.vue b/src/views/pay/refund/index.vue new file mode 100644 index 0000000..eaa17b4 --- /dev/null +++ b/src/views/pay/refund/index.vue @@ -0,0 +1,298 @@ +<template> + <doc-alert title="支付宝、微信退款接入" url="https://doc.iocoder.cn/pay/refund-demo/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="120px" + > + <el-form-item label="应用编号" prop="appId"> + <el-select + v-model="queryParams.appId" + clearable + placeholder="请选择应用信息" + class="!w-240px" + > + <el-option v-for="item in appList" :key="item.id" :label="item.name" :value="item.id" /> + </el-select> + </el-form-item> + <el-form-item label="退款渠道" prop="channelCode"> + <el-select + v-model="queryParams.channelCode" + placeholder="请选择退款渠道" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.PAY_CHANNEL_CODE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="商户支付单号" prop="merchantOrderId"> + <el-input + v-model="queryParams.merchantOrderId" + placeholder="请输入商户支付单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="商户退款单号" prop="merchantRefundId"> + <el-input + v-model="queryParams.merchantRefundId" + placeholder="请输入商户退款单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="渠道支付单号" prop="channelOrderNo"> + <el-input + v-model="queryParams.channelOrderNo" + placeholder="请输入渠道支付单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="渠道退款单号" prop="channelRefundNo"> + <el-input + v-model="queryParams.channelRefundNo" + placeholder="请输入渠道退款单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="退款状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择退款状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.PAY_REFUND_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> <Icon icon="ep:search" class="mr-5px" /> 搜索 </el-button> + <el-button @click="resetQuery"> <Icon icon="ep:refresh" class="mr-5px" /> 重置 </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['system:tenant:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="支付金额" align="center" prop="payPrice" width="100"> + <template #default="scope"> + ¥{{ parseFloat(scope.row.payPrice / 100).toFixed(2) }} + </template> + </el-table-column> + <el-table-column label="退款金额" align="center" prop="refundPrice" width="100"> + <template #default="scope"> + ¥{{ parseFloat(scope.row.refundPrice / 100).toFixed(2) }} + </template> + </el-table-column> + <el-table-column label="退款订单号" align="left" width="300"> + <template #default="scope"> + <p class="order-font"> + <el-tag size="small">商户</el-tag> {{ scope.row.merchantRefundId }} + </p> + <p class="order-font"> + <el-tag size="small" type="warning">退款</el-tag> {{ scope.row.no }} + </p> + <p class="order-font" v-if="scope.row.channelRefundNo"> + <el-tag size="small" type="success">渠道</el-tag> {{ scope.row.channelRefundNo }} + </p> + </template> + </el-table-column> + <el-table-column label="支付订单号" align="left" width="300"> + <template #default="scope"> + <p class="order-font"> + <el-tag size="small">商户</el-tag> {{ scope.row.merchantOrderId }} + </p> + <p class="order-font"> + <el-tag size="small" type="success">渠道</el-tag> {{ scope.row.channelOrderNo }} + </p> + </template> + </el-table-column> + <el-table-column label="退款状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_REFUND_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="退款渠道" align="center" width="140"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_CHANNEL_CODE" :value="scope.row.channelCode" /> + </template> + </el-table-column> + <el-table-column + label="成功时间" + align="center" + prop="successTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="支付应用" align="center" prop="successTime" width="100"> + <template #default="scope"> + <span>{{ scope.row.appName }}</span> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right"> + <template #default="scope"> + <el-button + type="primary" + link + @click="openDetail(scope.row.id)" + v-hasPermi="['pay:order:query']" + > + 详情 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:预览 --> + <RefundDetail ref="detailRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as RefundApi from '@/api/pay/refund' +import * as AppApi from '@/api/pay/app' +import RefundDetail from './RefundDetail.vue' +import download from '@/utils/download' + +defineOptions({ name: 'PayRefund' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(false) // 列表遮罩层 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + merchantId: undefined, + appId: undefined, + channelCode: undefined, + merchantOrderId: undefined, + merchantRefundId: undefined, + status: undefined, + payPrice: undefined, + refundPrice: undefined, + channelOrderNo: undefined, + channelRefundNo: undefined, + createTime: [], + successTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出等待 +const appList = ref([]) // 支付应用列表集合 + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await RefundApi.getRefundPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + handleQuery() +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await RefundApi.exportRefund(queryParams) + download.excel(data, '支付订单.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 预览详情 */ +const detailRef = ref() +const openDetail = (id: number) => { + detailRef.value.open(id) +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + appList.value = await AppApi.getAppList() +}) +</script> +<style> +.order-font { + padding: 2px 0; + font-size: 12px; +} +</style> diff --git a/src/views/pay/transfer/CreatePayTransfer.vue b/src/views/pay/transfer/CreatePayTransfer.vue new file mode 100644 index 0000000..3170650 --- /dev/null +++ b/src/views/pay/transfer/CreatePayTransfer.vue @@ -0,0 +1,135 @@ +<template> + <Dialog title="发起转账" v-model="dialogVisible" width="800px"> + <el-card style="margin-top: 10px"> + <el-descriptions title="转账信息" :column="2" border> + <el-descriptions-item label="转账类型"> + {{ typeName }} + </el-descriptions-item> + <el-descriptions-item label="转账金额(元)"> + ¥{{ (transfer.price / 100.0).toFixed(2) }} + </el-descriptions-item> + <el-descriptions-item label="收款人姓名"> + {{ transfer.userName }} + </el-descriptions-item> + <el-descriptions-item label="支付宝登录账号" v-if="transfer.type === 1"> + {{ transfer.alipayLogonId }} + </el-descriptions-item> + <el-descriptions-item label="微信 openid" v-if="transfer.type === 2"> + {{ transfer.openid }} + </el-descriptions-item> + </el-descriptions> + </el-card> + <el-card style="margin-top: 20px"> + <template #header> + <div class="card-header"> + <span>选择转账渠道</span> + </div> + </template> + <div> + <el-radio-group v-model="channelCode"> + <el-radio + label="alipay_pc" + :disabled="transfer.type === 2 || transfer.type === 3 || transfer.type === 4" + > + <img :src="svg_alipay_app" /> + </el-radio> + <el-radio + label="wx_app" + :disabled="transfer.type === 1 || transfer.type === 3 || transfer.type === 4" + > + <img :src="svg_wx_app" /> + </el-radio> + </el-radio-group> + </div> + </el-card> + <el-divider /> + <div style="text-align: right"> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </div> + </Dialog> +</template> + +<script lang="ts" setup> +import * as PayTransferApi from '@/api/pay/transfer' +import { computed, PropType } from 'vue' +import { DICT_TYPE, getDictLabel } from '@/utils/dict' +// 导入图标 +import svg_alipay_app from '@/assets/svgs/pay/icon/alipay_app.svg' +import svg_wx_app from '@/assets/svgs/pay/icon/wx_app.svg' +import { yuanToFen } from '@/utils' +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const formLoading = ref(false) // 提交的按钮禁用 +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +defineOptions({ name: 'CreatePayTransfer' }) + +// 提交数据 +let submitTransferData: PayTransferApi.TransferVO + +const transfer = reactive({ + appId: undefined, + channelCode: undefined, + merchantTransferId: undefined, + type: undefined, + price: undefined, + subject: undefined, + userName: undefined, + alipayLogonId: undefined, + openid: undefined +}) +const dialogVisible = ref(false) +const typeName = computed(() => { + return getDictLabel(DICT_TYPE.PAY_TRANSFER_TYPE, transfer.type) +}) +const channelCode = computed(() => { + let channelCode = 'alipay_pc' + if (transfer.type === 2) { + channelCode = 'wx_app' + } + // TODO 银行卡和钱包 转账待实现 + return channelCode +}) + +/** 打开弹窗 */ +const showPayTransfer = async (payTransfer: PayTransferApi.TransferVO) => { + dialogVisible.value = true + submitTransferData = payTransfer + transfer.merchantTransferId = payTransfer.merchantTransferId + transfer.price = payTransfer.price + transfer.userName = payTransfer.userName + transfer.type = payTransfer.type + transfer.appId = payTransfer.appId + transfer.subject = payTransfer.subject + transfer.alipayLogonId = payTransfer.alipayLogonId + transfer.openid = payTransfer.openid +} +/** 关闭弹窗 */ +const close = async () => { + dialogVisible.value = false +} +defineExpose({ showPayTransfer, close }) // 提供 showPayTransfer, close 方法,用于打开, 关闭弹窗 + +const submitForm = async () => { + // 校验表单 + formLoading.value = true + try { + submitTransferData.channelCode = channelCode.value + await PayTransferApi.createTransfer(submitTransferData) + message.success('发起转账成功. 是否转账成功,以转账订单状态为准') + // 发送操作成功的事件 + emit('success') + dialogVisible.value = false + } finally { + formLoading.value = false + } +} +</script> + +<style lang="scss" scoped> +.card-header { + display: flex; + justify-content: space-between; + align-items: center; +} +</style> diff --git a/src/views/pay/transfer/TransferDetail.vue b/src/views/pay/transfer/TransferDetail.vue new file mode 100644 index 0000000..ad769d2 --- /dev/null +++ b/src/views/pay/transfer/TransferDetail.vue @@ -0,0 +1,80 @@ +<template> + <Dialog v-model="dialogVisible" title="转账单详情" width="700px"> + <el-descriptions :column="2" label-class-name="desc-label"> + <el-descriptions-item label="商户单号"> + <el-tag size="small">{{ detailData.merchantTransferId }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="转账单号"> + <el-tag type="warning" size="small" v-if="detailData.no">{{ detailData.no }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="应用编号">{{ detailData.appId }}</el-descriptions-item> + <el-descriptions-item label="转账状态"> + <dict-tag :type="DICT_TYPE.PAY_TRANSFER_STATUS" :value="detailData.status" size="small" /> + </el-descriptions-item> + <el-descriptions-item label="转账金额"> + <el-tag type="success" size="small">¥{{ (detailData.price / 100.0).toFixed(2) }}</el-tag> + </el-descriptions-item> + <el-descriptions-item label="转账时间"> + {{ formatDate(detailData.successTime) }} + </el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(detailData.createTime) }} + </el-descriptions-item> + </el-descriptions> + <!-- 分割线 --> + <el-divider /> + <el-descriptions :column="2" label-class-name="desc-label"> + <el-descriptions-item label="收款人姓名">{{ detailData.userName }}</el-descriptions-item> + <el-descriptions-item label="支付宝登录账号" v-if="detailData.type === 1"> + {{ detailData.alipayLogonId }} + </el-descriptions-item> + <el-descriptions-item label="微信 openid" v-if="detailData.type === 2"> + {{ detailData.openid }} + </el-descriptions-item> + <el-descriptions-item label="支付渠道"> + <dict-tag :type="DICT_TYPE.PAY_CHANNEL_CODE" :value="detailData.channelCode" /> + </el-descriptions-item> + <el-descriptions-item label="支付 IP">{{ detailData.userIp }}</el-descriptions-item> + <el-descriptions-item label="渠道单号"> + <el-tag size="mini" type="success" v-if="detailData.channelTransferNo"> + {{ detailData.channelTransferNo }} + </el-tag> + </el-descriptions-item> + <el-descriptions-item label="通知 URL">{{ detailData.notifyUrl }}</el-descriptions-item> + </el-descriptions> + <el-divider /> + <el-descriptions :column="1" label-class-name="desc-label" direction="vertical" border> + <el-descriptions-item label="转账渠道通知内容"> + <el-text>{{ detailData.channelNotifyData }}</el-text> + </el-descriptions-item> + </el-descriptions> + <el-divider /> + <div style="text-align: right"> + <el-button @click="dialogVisible = false">取 消</el-button> + </div> + </Dialog> +</template> + +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import * as TransferApi from '@/api/pay/transfer' +import { formatDate } from '@/utils/formatTime' +defineOptions({ name: 'PayTransferDetail' }) +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({}) +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = await TransferApi.getTransfer(id) + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> + +<style scoped></style> diff --git a/src/views/pay/transfer/index.vue b/src/views/pay/transfer/index.vue new file mode 100644 index 0000000..b901f34 --- /dev/null +++ b/src/views/pay/transfer/index.vue @@ -0,0 +1,267 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="100px" + > + <el-form-item label="转账单号" prop="no"> + <el-input + v-model="queryParams.no" + placeholder="请输入转账单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="转账渠道" prop="channelCode"> + <el-select + v-model="queryParams.channelCode" + placeholder="请选择支付渠道" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.PAY_CHANNEL_CODE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="商户单号" prop="merchantTransferId"> + <el-input + v-model="queryParams.merchantTransferId" + placeholder="请输入商户单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="类型" prop="type"> + <el-select v-model="queryParams.type" placeholder="请选择类型" clearable class="!w-240px"> + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.PAY_TRANSFER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="转账状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择转账状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.PAY_TRANSFER_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + + <el-form-item label="收款人姓名" prop="userName"> + <el-input + v-model="queryParams.userName" + placeholder="请输入收款人姓名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="渠道单号" prop="channelTransferNo"> + <el-input + v-model="queryParams.channelTransferNo" + placeholder="渠道单号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="应用编号" align="center" prop="appId" /> + <el-table-column label="类型" align="center" prop="type" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_TRANSFER_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column label="转账金额" align="center" prop="price"> + <template #default="scope"> + <span>¥{{ (scope.row.price / 100.0).toFixed(2) }}</span> + </template> + </el-table-column> + <el-table-column label="转账状态" align="center" prop="status" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_TRANSFER_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="订单号" align="left" width="300"> + <template #default="scope"> + <p class="transfer-font"> + <el-tag size="small"> 商户</el-tag> + {{ scope.row.merchantTransferId }} + </p> + <p class="transfer-font" v-if="scope.row.no"> + <el-tag size="small" type="warning">转账</el-tag> + {{ scope.row.no }} + </p> + <p class="transfer-font" v-if="scope.row.channelTransferNo"> + <el-tag size="small" type="success">渠道</el-tag> + {{ scope.row.channelTransferNo }} + </p> + </template> + </el-table-column> + <el-table-column label="收款人姓名" align="center" prop="userName" width="120" /> + <el-table-column label="收款账号" align="left" width="200"> + <template #default="scope"> + <p class="transfer-font" v-if="scope.row.alipayLogonId"> + <el-tag size="small">支付宝登录号</el-tag> + {{ scope.row.alipayLogonId }} + </p> + <p class="transfer-font" v-if="scope.row.openid"> + <el-tag size="small">微信 openId</el-tag> + {{ scope.row.openid }} + </p> + </template> + </el-table-column> + <el-table-column label="转账标题" align="center" prop="subject" width="120" /> + <el-table-column label="转账渠道" align="center" prop="channelCode"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.PAY_CHANNEL_CODE" :value="scope.row.channelCode" /> + </template> + </el-table-column> + <el-table-column + label="转账成功时间" + align="center" + prop="successTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" fixed="right"> + <template #default="scope"> + <el-button link type="primary" @click="openDetail(scope.row.id)"> 详情 </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + <TransferDetail ref="detailRef" /> + </ContentWrap> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import * as TransferApi from '@/api/pay/transfer' +import { DICT_TYPE, getStrDictOptions } from '@/utils/dict' +import TransferDetail from './TransferDetail.vue' +defineOptions({ name: 'PayTransfer' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + no: null, + appId: null, + channelId: null, + channelCode: null, + merchantTransferId: null, + type: null, + status: null, + successTime: [], + price: null, + subject: null, + userName: null, + alipayLogonId: null, + openid: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await TransferApi.getTransferPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const detailRef = ref() +const openDetail = (id: number) => { + detailRef.value.open(id) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> + +<style scoped> +.transfer-font { + padding: 2px 0; + font-size: 12px; +} +</style> diff --git a/src/views/pay/wallet/balance/WalletForm.vue b/src/views/pay/wallet/balance/WalletForm.vue new file mode 100644 index 0000000..8173e12 --- /dev/null +++ b/src/views/pay/wallet/balance/WalletForm.vue @@ -0,0 +1,22 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible" width="800"> + <WalletTransactionList :wallet-id="walletId" /> + <template #footer> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import WalletTransactionList from '../transaction/WalletTransactionList.vue' +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const walletId = ref(0) +/** 打开弹窗 */ +const open = async (theWalletId: number) => { + dialogVisible.value = true + dialogTitle.value = '钱包余额明细' + walletId.value = theWalletId +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/pay/wallet/balance/index.vue b/src/views/pay/wallet/balance/index.vue new file mode 100644 index 0000000..1a37eec --- /dev/null +++ b/src/views/pay/wallet/balance/index.vue @@ -0,0 +1,156 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-input + v-model="queryParams.userId" + placeholder="请输入用户编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="用户类型" prop="userType"> + <el-select + v-model="queryParams.userType" + placeholder="请选择用户类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="用户编号" align="center" prop="userId" /> + <el-table-column label="用户类型" align="center" prop="userType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" /> + </template> + </el-table-column> + <el-table-column label="余额" align="center" prop="balance"> + <template #default="{ row }"> {{ fenToYuan(row.balance) }} 元</template> + </el-table-column> + <el-table-column label="累计支出" align="center" prop="totalExpense"> + <template #default="{ row }"> {{ fenToYuan(row.totalExpense) }} 元</template> + </el-table-column> + <el-table-column label="累计充值" align="center" prop="totalRecharge"> + <template #default="{ row }"> {{ fenToYuan(row.totalRecharge) }} 元</template> + </el-table-column> + <el-table-column label="冻结金额" align="center" prop="freezePrice"> + <template #default="{ row }"> {{ fenToYuan(row.freezePrice) }} 元</template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button link type="primary" @click="openForm(scope.row.id)">详情</el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 弹窗 --> + <WalletForm ref="formRef" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { fenToYuan } from '@/utils' +import * as WalletApi from '@/api/pay/wallet/balance' +import WalletForm from './WalletForm.vue' + +defineOptions({ name: 'WalletBalance' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: null, + userType: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await WalletApi.getWalletPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (id?: number) => { + formRef.value.open(id) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/pay/wallet/rechargePackage/WalletRechargePackageForm.vue b/src/views/pay/wallet/rechargePackage/WalletRechargePackageForm.vue new file mode 100644 index 0000000..0153225 --- /dev/null +++ b/src/views/pay/wallet/rechargePackage/WalletRechargePackageForm.vue @@ -0,0 +1,122 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="150px" + v-loading="formLoading" + > + <el-form-item label="套餐名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入套餐名" /> + </el-form-item> + <el-form-item label="支付金额(元)" prop="payPrice"> + <el-input-number v-model="formData.payPrice" :min="0" :precision="2" :step="0.01" /> + </el-form-item> + <el-form-item label="赠送金额(元)" prop="bonusPrice"> + <el-input-number v-model="formData.bonusPrice" :min="0" :precision="2" :step="0.01" /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as WalletRechargePackageApi from '@/api/pay/wallet/rechargePackage' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { fenToYuan, yuanToFen } from '@/utils' +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + payPrice: undefined, + bonusPrice: undefined, + status: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '套餐名不能为空', trigger: 'blur' }], + payPrice: [{ required: true, message: '支付金额不能为空', trigger: 'blur' }], + bonusPrice: [{ required: true, message: '赠送金额不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await WalletRechargePackageApi.getWalletRechargePackage(id) + formData.value.payPrice = fenToYuan(formData.value.payPrice) + formData.value.bonusPrice = fenToYuan(formData.value.bonusPrice) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = { ...formData.value } + data.payPrice = yuanToFen(data.payPrice) + data.bonusPrice = yuanToFen(data.bonusPrice) + if (formType.value === 'create') { + await WalletRechargePackageApi.createWalletRechargePackage(data) + message.success(t('common.createSuccess')) + } else { + await WalletRechargePackageApi.updateWalletRechargePackage(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + payPrice: undefined, + bonusPrice: undefined, + status: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/pay/wallet/rechargePackage/index.vue b/src/views/pay/wallet/rechargePackage/index.vue new file mode 100644 index 0000000..f097577 --- /dev/null +++ b/src/views/pay/wallet/rechargePackage/index.vue @@ -0,0 +1,185 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="套餐名" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入套餐名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['pay:wallet-recharge-package:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="套餐名" align="center" prop="name" /> + <el-table-column label="支付金额" align="center" prop="payPrice"> + <template #default="{ row }"> {{ fenToYuan(row.payPrice) }} 元</template> + </el-table-column> + <el-table-column label="赠送金额" align="center" prop="bonusPrice"> + <template #default="{ row }"> {{ fenToYuan(row.bonusPrice) }} 元</template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['pay:wallet-recharge-package:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['pay:wallet-recharge-package:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <WalletRechargePackageForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as WalletRechargePackageApi from '@/api/pay/wallet/rechargePackage' +import WalletRechargePackageForm from './WalletRechargePackageForm.vue' +import { fenToYuan } from '@/utils' + +defineOptions({ name: 'WalletRechargePackage' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + payPrice: null, + bonusPrice: null, + status: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await WalletRechargePackageApi.getWalletRechargePackagePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await WalletRechargePackageApi.deleteWalletRechargePackage(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/pay/wallet/transaction/WalletTransactionList.vue b/src/views/pay/wallet/transaction/WalletTransactionList.vue new file mode 100644 index 0000000..c440778 --- /dev/null +++ b/src/views/pay/wallet/transaction/WalletTransactionList.vue @@ -0,0 +1,68 @@ +<template> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="钱包编号" align="center" prop="walletId" /> + <el-table-column label="关联业务标题" align="center" prop="title" /> + <el-table-column label="交易金额" align="center" prop="price"> + <template #default="{ row }"> {{ fenToYuan(row.price) }} 元</template> + </el-table-column> + <el-table-column label="钱包余额" align="center" prop="balance"> + <template #default="{ row }"> {{ fenToYuan(row.balance) }} 元</template> + </el-table-column> + <el-table-column + label="交易时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import * as WalletTransactionApi from '@/api/pay/wallet/transaction' +import { fenToYuan } from '@/utils' +defineOptions({ name: 'WalletTransactionList' }) +const { walletId }: { walletId: number } = defineProps({ + walletId: { + type: Number, + required: false + } +}) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + walletId: null +}) +const list = ref([]) // 列表的数据 +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + queryParams.walletId = walletId + const data = await WalletTransactionApi.getWalletTransactionPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> +<style scoped lang="scss"></style> diff --git a/src/views/report/goview/index.vue b/src/views/report/goview/index.vue new file mode 100644 index 0000000..dd10cca --- /dev/null +++ b/src/views/report/goview/index.vue @@ -0,0 +1,12 @@ +<template> + <ContentWrap> + <doc-alert title="大屏设计器" url="https://doc.iocoder.cn/report/screen/" /> + + <IFrame :src="src" /> + </ContentWrap> +</template> +<script lang="ts" setup> +defineOptions({ name: 'GoView' }) + +const src = 'http://127.0.0.1:3000' +</script> diff --git a/src/views/report/jmreport/index.vue b/src/views/report/jmreport/index.vue new file mode 100644 index 0000000..382d789 --- /dev/null +++ b/src/views/report/jmreport/index.vue @@ -0,0 +1,15 @@ +<template> + <ContentWrap> + <doc-alert title="报表设计器" url="https://doc.iocoder.cn/report/" /> + + <IFrame :src="src" /> + </ContentWrap> +</template> +<script lang="ts" setup> +import { getAccessToken } from '@/utils/auth' + +defineOptions({ name: 'JimuReport' }) + +const BASE_URL = import.meta.env.VITE_BASE_URL +const src = ref(BASE_URL + '/jmreport/list?token=' + getAccessToken()) +</script> diff --git a/src/views/system/area/AreaForm.vue b/src/views/system/area/AreaForm.vue new file mode 100644 index 0000000..47dfd1d --- /dev/null +++ b/src/views/system/area/AreaForm.vue @@ -0,0 +1,72 @@ +<template> + <Dialog v-model="dialogVisible" title="IP 查询"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="IP" prop="ip"> + <el-input v-model="formData.ip" placeholder="请输入 IP 地址" /> + </el-form-item> + <el-form-item label="地址" prop="result"> + <el-input v-model="formData.result" placeholder="展示查询 IP 结果" readonly /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as AreaApi from '@/api/system/area' + +defineOptions({ name: 'SystemAreaForm' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:提交的按钮禁用 +const formData = ref({ + ip: '', + result: undefined +}) +const formRules = reactive({ + ip: [{ required: true, message: 'IP 地址不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async () => { + dialogVisible.value = true + resetForm() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + formData.value.result = await AreaApi.getAreaByIp(formData.value.ip!.trim()) + message.success('查询成功') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + ip: '', + result: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/area/index.vue b/src/views/system/area/index.vue new file mode 100644 index 0000000..72cbcad --- /dev/null +++ b/src/views/system/area/index.vue @@ -0,0 +1,75 @@ +<template> + <doc-alert title="地区 & IP" url="https://doc.iocoder.cn/area-and-ip/" /> + + <!-- 操作栏 --> + <ContentWrap> + <el-button type="primary" plain @click="openForm()"> + <Icon icon="ep:plus" class="mr-5px" /> IP 查询 + </el-button> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <div style="width: 100%; height: 700px"> + <!-- AutoResizer 自动调节大小 --> + <el-auto-resizer> + <template #default="{ height, width }"> + <!-- Virtualized Table 虚拟化表格:高性能,解决表格在大数据量下的卡顿问题 --> + <el-table-v2 + :columns="columns" + :data="list" + :width="width" + :height="height" + expand-column-key="id" + /> + </template> + </el-auto-resizer> + </div> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <AreaForm ref="formRef" /> +</template> +<script setup lang="tsx"> +import type { Column } from 'element-plus' +import AreaForm from './AreaForm.vue' +import * as AreaApi from '@/api/system/area' + +defineOptions({ name: 'SystemArea' }) + +// 表格的 column 字段 +const columns: Column[] = [ + { + dataKey: 'id', // 需要渲染当前列的数据字段。例如说:{id:9527, name:'Mike'},则填 id + title: '编号', // 显示在单元格表头的文本 + width: 400, // 当前列的宽度,必须设置 + fixed: true, // 是否固定列 + key: 'id' // 树形展开对应的 key + }, + { + dataKey: 'name', + title: '地名', + width: 200 + } +] +// 表格的数据 +const list = ref([]) + +/** + * 获得数据列表 + */ +const getList = async () => { + list.value = await AreaApi.getAreaTree() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = () => { + formRef.value.open() +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/dept/DeptForm.vue b/src/views/system/dept/DeptForm.vue new file mode 100644 index 0000000..c759ef3 --- /dev/null +++ b/src/views/system/dept/DeptForm.vue @@ -0,0 +1,174 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="上级部门" prop="parentId"> + <el-tree-select + v-model="formData.parentId" + :data="deptTree" + :props="defaultProps" + check-strictly + default-expand-all + placeholder="请选择上级部门" + value-key="deptId" + /> + </el-form-item> + <el-form-item label="部门名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入部门名称" /> + </el-form-item> + <el-form-item label="显示排序" prop="sort"> + <el-input-number v-model="formData.sort" :min="0" controls-position="right" /> + </el-form-item> + <el-form-item label="负责人" prop="leaderUserId"> + <el-select v-model="formData.leaderUserId" clearable placeholder="请输入负责人"> + <el-option + v-for="item in userList" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="联系电话" prop="phone"> + <el-input v-model="formData.phone" maxlength="11" placeholder="请输入联系电话" /> + </el-form-item> + <el-form-item label="邮箱" prop="email"> + <el-input v-model="formData.email" maxlength="50" placeholder="请输入邮箱" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="formData.status" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-form> + <template #footer> + <el-button type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { defaultProps, handleTree } from '@/utils/tree' +import * as DeptApi from '@/api/system/dept' +import * as UserApi from '@/api/system/user' +import { CommonStatusEnum } from '@/utils/constants' +import { FormRules } from 'element-plus' + +defineOptions({ name: 'SystemDeptForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + title: '', + parentId: undefined, + name: undefined, + sort: undefined, + leaderUserId: undefined, + phone: undefined, + email: undefined, + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive<FormRules>({ + parentId: [{ required: true, message: '上级部门不能为空', trigger: 'blur' }], + name: [{ required: true, message: '部门名称不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '显示排序不能为空', trigger: 'blur' }], + email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change'] }], + phone: [ + { pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: '请输入正确的手机号码', trigger: 'blur' } + ], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const deptTree = ref() // 树形结构 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await DeptApi.getDept(id) + } finally { + formLoading.value = false + } + } + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() + // 获得部门树 + await getTree() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as DeptApi.DeptVO + if (formType.value === 'create') { + await DeptApi.createDept(data) + message.success(t('common.createSuccess')) + } else { + await DeptApi.updateDept(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + title: '', + parentId: undefined, + name: undefined, + sort: undefined, + leaderUserId: undefined, + phone: undefined, + email: undefined, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} + +/** 获得部门树 */ +const getTree = async () => { + deptTree.value = [] + const data = await DeptApi.getSimpleDeptList() + let dept: Tree = { id: 0, name: '顶级部门', children: [] } + dept.children = handleTree(data) + deptTree.value.push(dept) +} +</script> diff --git a/src/views/system/dept/index.vue b/src/views/system/dept/index.vue new file mode 100644 index 0000000..4757e5c --- /dev/null +++ b/src/views/system/dept/index.vue @@ -0,0 +1,189 @@ +<template> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="部门名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入部门名称" + clearable + class="!w-240px" + /> + </el-form-item> + <el-form-item label="部门状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择部门状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:dept:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button type="danger" plain @click="toggleExpandAll"> + <Icon icon="ep:sort" class="mr-5px" /> 展开/折叠 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + row-key="id" + :default-expand-all="isExpandAll" + v-if="refreshTable" + > + <el-table-column prop="name" label="部门名称" /> + <el-table-column prop="leader" label="负责人"> + <template #default="scope"> + {{ userList.find((user) => user.id === scope.row.leaderUserId)?.nickname }} + </template> + </el-table-column> + <el-table-column prop="sort" label="排序" /> + <el-table-column prop="status" label="状态"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:dept:update']" + > + 修改 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['system:dept:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <DeptForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import { handleTree } from '@/utils/tree' +import * as DeptApi from '@/api/system/dept' +import DeptForm from './DeptForm.vue' +import * as UserApi from '@/api/system/user' + +defineOptions({ name: 'SystemDept' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref() // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 100, + name: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const isExpandAll = ref(true) // 是否展开,默认全部展开 +const refreshTable = ref(true) // 重新渲染表格状态 +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +/** 查询部门列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DeptApi.getDeptPage(queryParams) + list.value = handleTree(data) + } finally { + loading.value = false + } +} + +/** 展开/折叠操作 */ +const toggleExpandAll = () => { + refreshTable.value = false + isExpandAll.value = !isExpandAll.value + nextTick(() => { + refreshTable.value = true + }) +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryParams.pageNo = 1 + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DeptApi.deleteDept(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 获取用户列表 + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/system/dict/DictTypeForm.vue b/src/views/system/dict/DictTypeForm.vue new file mode 100644 index 0000000..5e416d7 --- /dev/null +++ b/src/views/system/dict/DictTypeForm.vue @@ -0,0 +1,124 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="字典名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入字典名称" /> + </el-form-item> + <el-form-item label="字典类型" prop="type"> + <el-input + v-model="formData.type" + :disabled="typeof formData.id !== 'undefined'" + placeholder="请输入参数名称" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as DictTypeApi from '@/api/system/dict/dict.type' +import { CommonStatusEnum } from '@/utils/constants' + +defineOptions({ name: 'SystemDictTypeForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + type: '', + status: CommonStatusEnum.ENABLE, + remark: '' +}) +const formRules = reactive({ + name: [{ required: true, message: '字典名称不能为空', trigger: 'blur' }], + type: [{ required: true, message: '字典类型不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'change' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await DictTypeApi.getDictType(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as DictTypeApi.DictTypeVO + if (formType.value === 'create') { + await DictTypeApi.createDictType(data) + message.success(t('common.createSuccess')) + } else { + await DictTypeApi.updateDictType(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + type: '', + name: '', + status: CommonStatusEnum.ENABLE, + remark: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/dict/data/DictDataForm.vue b/src/views/system/dict/data/DictDataForm.vue new file mode 100644 index 0000000..2094371 --- /dev/null +++ b/src/views/system/dict/data/DictDataForm.vue @@ -0,0 +1,183 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="字典类型" prop="type"> + <el-input + v-model="formData.dictType" + :disabled="typeof formData.id !== 'undefined'" + placeholder="请输入参数名称" + /> + </el-form-item> + <el-form-item label="数据标签" prop="label"> + <el-input v-model="formData.label" placeholder="请输入数据标签" /> + </el-form-item> + <el-form-item label="数据键值" prop="value"> + <el-input v-model="formData.value" placeholder="请输入数据键值" /> + </el-form-item> + <el-form-item label="显示排序" prop="sort"> + <el-input-number v-model="formData.sort" :min="0" controls-position="right" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="颜色类型" prop="colorType"> + <el-select v-model="formData.colorType"> + <el-option + v-for="item in colorTypeOptions" + :key="item.value" + :label="item.label + '(' + item.value + ')'" + :value="item.value" + /> + </el-select> + </el-form-item> + <el-form-item label="CSS Class" prop="cssClass"> + <el-input v-model="formData.cssClass" placeholder="请输入 CSS Class" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as DictDataApi from '@/api/system/dict/dict.data' +import { CommonStatusEnum } from '@/utils/constants' + +defineOptions({ name: 'SystemDictDataForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + sort: undefined, + label: '', + value: '', + dictType: '', + status: CommonStatusEnum.ENABLE, + colorType: '', + cssClass: '', + remark: '' +}) +const formRules = reactive({ + label: [{ required: true, message: '数据标签不能为空', trigger: 'blur' }], + value: [{ required: true, message: '数据键值不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '数据顺序不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'change' }] +}) +const formRef = ref() // 表单 Ref + +// 数据标签回显样式 +const colorTypeOptions = readonly([ + { + value: 'default', + label: '默认' + }, + { + value: 'primary', + label: '主要' + }, + { + value: 'success', + label: '成功' + }, + { + value: 'info', + label: '信息' + }, + { + value: 'warning', + label: '警告' + }, + { + value: 'danger', + label: '危险' + } +]) + +/** 打开弹窗 */ +const open = async (type: string, id?: number, dictType?: string) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + if (dictType) { + formData.value.dictType = dictType + } + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await DictDataApi.getDictData(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as DictDataApi.DictDataVO + if (formType.value === 'create') { + await DictDataApi.createDictData(data) + message.success(t('common.createSuccess')) + } else { + await DictDataApi.updateDictData(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + sort: undefined, + label: '', + value: '', + dictType: '', + status: CommonStatusEnum.ENABLE, + colorType: '', + cssClass: '', + remark: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/dict/data/index.vue b/src/views/system/dict/data/index.vue new file mode 100644 index 0000000..2811f06 --- /dev/null +++ b/src/views/system/dict/data/index.vue @@ -0,0 +1,210 @@ +<template> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="字典名称" prop="dictType"> + <el-select v-model="queryParams.dictType" class="!w-240px"> + <el-option + v-for="item in dictTypeList" + :key="item.type" + :label="item.name" + :value="item.type" + /> + </el-select> + </el-form-item> + <el-form-item label="字典标签" prop="label"> + <el-input + v-model="queryParams.label" + placeholder="请输入字典标签" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="数据状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:dict:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['system:dict:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="字典编码" align="center" prop="id" /> + <el-table-column label="字典标签" align="center" prop="label" /> + <el-table-column label="字典键值" align="center" prop="value" /> + <el-table-column label="字典排序" align="center" prop="sort" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="颜色类型" align="center" prop="colorType" /> + <el-table-column label="CSS Class" align="center" prop="cssClass" /> + <el-table-column label="备注" align="center" prop="remark" show-overflow-tooltip /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:dict:update']" + > + 修改 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['system:dict:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <DictDataForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as DictDataApi from '@/api/system/dict/dict.data' +import * as DictTypeApi from '@/api/system/dict/dict.type' +import DictDataForm from './DictDataForm.vue' + +defineOptions({ name: 'SystemDictData' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 +const route = useRoute() // 路由 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + label: '', + status: undefined, + dictType: route.params.dictType +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const dictTypeList = ref<DictTypeApi.DictTypeVO[]>() // 字典类型的列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DictDataApi.getDictDataPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id, queryParams.dictType) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DictDataApi.deleteDictData(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await DictDataApi.exportDictData(queryParams) + download.excel(data, '字典数据.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 查询字典(精简)列表 + dictTypeList.value = await DictTypeApi.getSimpleDictTypeList() +}) +</script> diff --git a/src/views/system/dict/index.vue b/src/views/system/dict/index.vue new file mode 100644 index 0000000..acc737c --- /dev/null +++ b/src/views/system/dict/index.vue @@ -0,0 +1,231 @@ +<template> + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="字典名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入字典名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="字典类型" prop="type"> + <el-input + v-model="queryParams.type" + class="!w-240px" + clearable + placeholder="请输入字典类型" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select + v-model="queryParams.status" + class="!w-240px" + clearable + placeholder="请选择字典状态" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['system:dict:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button + v-hasPermi="['system:dict:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="字典编号" prop="id" /> + <el-table-column align="center" label="字典名称" prop="name" show-overflow-tooltip /> + <el-table-column align="center" label="字典类型" prop="type" width="300" /> + <el-table-column align="center" label="状态" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column align="center" label="备注" prop="remark" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['system:dict:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 修改 + </el-button> + <router-link :to="'/dict/type/data/' + scope.row.type"> + <el-button link type="primary">数据</el-button> + </router-link> + <el-button + v-hasPermi="['system:dict:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <DictTypeForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as DictTypeApi from '@/api/system/dict/dict.type' +import DictTypeForm from './DictTypeForm.vue' +import download from '@/utils/download' + +defineOptions({ name: 'SystemDictType' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 字典表格数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: '', + type: '', + status: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询字典类型列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DictTypeApi.getDictTypePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DictTypeApi.deleteDictType(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await DictTypeApi.exportDictType(queryParams) + download.excel(data, '字典类型.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/loginlog/LoginLogDetail.vue b/src/views/system/loginlog/LoginLogDetail.vue new file mode 100644 index 0000000..ff49453 --- /dev/null +++ b/src/views/system/loginlog/LoginLogDetail.vue @@ -0,0 +1,51 @@ +<template> + <Dialog v-model="dialogVisible" title="详情" width="800"> + <el-descriptions :column="1" border> + <el-descriptions-item label="日志编号" min-width="120"> + {{ detailData.id }} + </el-descriptions-item> + <el-descriptions-item label="操作类型"> + <dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="detailData.logType" /> + </el-descriptions-item> + <el-descriptions-item label="用户名称"> + {{ detailData.username }} + </el-descriptions-item> + <el-descriptions-item label="登录地址"> + {{ detailData.userIp }} + </el-descriptions-item> + <el-descriptions-item label="浏览器"> + {{ detailData.userAgent }} + </el-descriptions-item> + <el-descriptions-item label="登陆结果"> + <dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_RESULT" :value="detailData.result" /> + </el-descriptions-item> + <el-descriptions-item label="登录日期"> + {{ formatDate(detailData.createTime) }} + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import * as LoginLogApi from '@/api/system/loginLog' + +defineOptions({ name: 'SystemLoginLogDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({} as LoginLogApi.LoginLogVO) // 详情数据 + +/** 打开弹窗 */ +const open = async (data: LoginLogApi.LoginLogVO) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = data + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/system/loginlog/index.vue b/src/views/system/loginlog/index.vue new file mode 100644 index 0000000..eea2f19 --- /dev/null +++ b/src/views/system/loginlog/index.vue @@ -0,0 +1,180 @@ +<template> + <doc-alert title="系统日志" url="https://doc.iocoder.cn/system-log/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户名称" prop="username"> + <el-input + v-model="queryParams.username" + placeholder="请输入用户名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="登录地址" prop="userIp"> + <el-input + v-model="queryParams.userIp" + placeholder="请输入登录地址" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="登录日期" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:login-log:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="日志编号" align="center" prop="id" /> + <el-table-column label="操作类型" align="center" prop="logType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_TYPE" :value="scope.row.logType" /> + </template> + </el-table-column> + <el-table-column label="用户名称" align="center" prop="username" width="180" /> + <el-table-column label="登录地址" align="center" prop="userIp" width="180" /> + <el-table-column label="浏览器" align="center" prop="userAgent" /> + <el-table-column label="登陆结果" align="center" prop="result"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_LOGIN_RESULT" :value="scope.row.result" /> + </template> + </el-table-column> + <el-table-column + label="登录日期" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openDetail(scope.row)" + v-hasPermi="['infra:login-log:query']" + > + 详情 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:详情 --> + <LoginLogDetail ref="detailRef" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as LoginLogApi from '@/api/system/loginLog' +import LoginLogDetail from './LoginLogDetail.vue' + +defineOptions({ name: 'SystemLoginLog' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + username: undefined, + userIp: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await LoginLogApi.getLoginLogPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 详情操作 */ +const detailRef = ref() +const openDetail = (data: LoginLogApi.LoginLogVO) => { + detailRef.value.open(data) +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await LoginLogApi.exportLoginLog(queryParams) + download.excel(data, '登录日志.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/mail/account/MailAccountDetail.vue b/src/views/system/mail/account/MailAccountDetail.vue new file mode 100644 index 0000000..4174fab --- /dev/null +++ b/src/views/system/mail/account/MailAccountDetail.vue @@ -0,0 +1,28 @@ +<template> + <Dialog v-model="dialogVisible" title="详情"> + <Descriptions :data="detailData" :schema="allSchemas.detailSchema" /> + </Dialog> +</template> +<script lang="ts" setup> +import * as MailAccountApi from '@/api/system/mail/account' +import { allSchemas } from './account.data' + +defineOptions({ name: 'SystemMailAccountDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref() // 详情数据 + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = await MailAccountApi.getMailAccount(id) + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/system/mail/account/MailAccountForm.vue b/src/views/system/mail/account/MailAccountForm.vue new file mode 100644 index 0000000..e010872 --- /dev/null +++ b/src/views/system/mail/account/MailAccountForm.vue @@ -0,0 +1,68 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <Form ref="formRef" v-loading="formLoading" :rules="rules" :schema="allSchemas.formSchema" /> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as MailAccountApi from '@/api/system/mail/account' +import { allSchemas, rules } from './account.data' + +defineOptions({ name: 'SystemMailAccountForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = await MailAccountApi.getMailAccount(id) + formRef.value.setValues(data) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.getElFormRef().validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formRef.value.formModel as MailAccountApi.MailAccountVO + if (formType.value === 'create') { + await MailAccountApi.createMailAccount(data) + message.success(t('common.createSuccess')) + } else { + await MailAccountApi.updateMailAccount(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} +</script> diff --git a/src/views/system/mail/account/account.data.ts b/src/views/system/mail/account/account.data.ts new file mode 100644 index 0000000..23b1f08 --- /dev/null +++ b/src/views/system/mail/account/account.data.ts @@ -0,0 +1,86 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter } from '@/utils/formatTime' +const { t } = useI18n() // 国际化 + +// 表单校验 +export const rules = reactive({ + mail: [ + { required: true, message: t('profile.rules.mail'), trigger: 'blur' }, + { + type: 'email', + message: t('profile.rules.truemail'), + trigger: ['blur', 'change'] + } + ], + username: [required], + password: [required], + host: [required], + port: [required], + sslEnable: [required], + starttlsEnable: [required] +}) + +// CrudSchema:https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '邮箱', + field: 'mail', + isSearch: true + }, + { + label: '用户名', + field: 'username', + isSearch: true + }, + { + label: '密码', + field: 'password', + isTable: false + }, + { + label: 'SMTP 服务器域名', + field: 'host' + }, + { + label: 'SMTP 服务器端口', + field: 'port', + form: { + component: 'InputNumber', + value: 465 + } + }, + { + label: '是否开启 SSL', + field: 'sslEnable', + dictType: DICT_TYPE.INFRA_BOOLEAN_STRING, + dictClass: 'boolean', + form: { + component: 'Radio' + } + }, + { + label: '是否开启 STARTTLS', + field: 'starttlsEnable', + dictType: DICT_TYPE.INFRA_BOOLEAN_STRING, + dictClass: 'boolean', + form: { + component: 'Radio' + } + }, + { + label: '创建时间', + field: 'createTime', + isForm: false, + formatter: dateFormatter, + detail: { + dateFormat: 'YYYY-MM-DD HH:mm:ss' + } + }, + { + label: '操作', + field: 'action', + isForm: false, + isDetail: false + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) diff --git a/src/views/system/mail/account/index.vue b/src/views/system/mail/account/index.vue new file mode 100644 index 0000000..8706338 --- /dev/null +++ b/src/views/system/mail/account/index.vue @@ -0,0 +1,106 @@ +<template> + <doc-alert title="邮件配置" url="https://doc.iocoder.cn/mail" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <Search :schema="allSchemas.searchSchema" @search="setSearchParams" @reset="setSearchParams"> + <!-- 新增等操作按钮 --> + <template #actionMore> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:mail-account:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </template> + </Search> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <Table + :columns="allSchemas.tableColumns" + :data="tableObject.tableList" + :loading="tableObject.loading" + :pagination="{ + total: tableObject.total + }" + v-model:pageSize="tableObject.pageSize" + v-model:currentPage="tableObject.currentPage" + > + <template #action="{ row }"> + <el-button + link + type="primary" + @click="openForm('update', row.id)" + v-hasPermi="['system:mail-account:update']" + > + 编辑 + </el-button> + <el-button + link + type="primary" + @click="openDetail(row.id)" + v-hasPermi="['system:mail-account:query']" + > + 详情 + </el-button> + <el-button + link + type="danger" + v-hasPermi="['system:mail-account:delete']" + @click="handleDelete(row.id)" + > + 删除 + </el-button> + </template> + </Table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <MailAccountForm ref="formRef" @success="getList" /> + <!-- 详情弹窗 --> + <MailAccountDetail ref="detailRef" /> +</template> +<script lang="ts" setup> +import { allSchemas } from './account.data' +import * as MailAccountApi from '@/api/system/mail/account' +import MailAccountForm from './MailAccountForm.vue' +import MailAccountDetail from './MailAccountDetail.vue' + +defineOptions({ name: 'SystemMailAccount' }) + +// tableObject:表格的属性对象,可获得分页大小、条数等属性 +// tableMethods:表格的操作对象,可进行获得分页、删除记录等操作 +// 详细可见:https://doc.iocoder.cn/vue3/crud-schema/ +const { tableObject, tableMethods } = useTable({ + getListApi: MailAccountApi.getMailAccountPage, // 分页接口 + delListApi: MailAccountApi.deleteMailAccount // 删除接口 +}) +// 获得表格的各种操作 +const { getList, setSearchParams } = tableMethods + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 详情操作 */ +const detailRef = ref() +const openDetail = (id: number) => { + detailRef.value.open(id) +} + +/** 删除按钮操作 */ +const handleDelete = (id: number) => { + tableMethods.delList(id, false) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/mail/log/MailLogDetail.vue b/src/views/system/mail/log/MailLogDetail.vue new file mode 100644 index 0000000..b1f1ea7 --- /dev/null +++ b/src/views/system/mail/log/MailLogDetail.vue @@ -0,0 +1,33 @@ +<template> + <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="详情"> + <Descriptions :data="detailData" :schema="allSchemas.detailSchema"> + <!-- 展示 HTML 内容 --> + <template #templateContent="{ row }"> + <div v-dompurify-html="row.templateContent"></div> + </template> + </Descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import * as MailLogApi from '@/api/system/mail/log' +import { allSchemas } from './log.data' + +defineOptions({ name: 'SystemMailLogDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref() // 详情数据 + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = await MailLogApi.getMailLog(id) + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/system/mail/log/index.vue b/src/views/system/mail/log/index.vue new file mode 100644 index 0000000..810ee22 --- /dev/null +++ b/src/views/system/mail/log/index.vue @@ -0,0 +1,63 @@ +<template> + <doc-alert title="邮件配置" url="https://doc.iocoder.cn/mail" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <Search :schema="allSchemas.searchSchema" @search="setSearchParams" @reset="setSearchParams" /> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <Table + :columns="allSchemas.tableColumns" + :data="tableObject.tableList" + :loading="tableObject.loading" + :pagination="{ + total: tableObject.total + }" + v-model:pageSize="tableObject.pageSize" + v-model:currentPage="tableObject.currentPage" + > + <template #action="{ row }"> + <el-button + link + type="primary" + @click="openDetail(row.id)" + v-hasPermi="['system:mail-log:query']" + > + 详情 + </el-button> + </template> + </Table> + </ContentWrap> + + <!-- 表单弹窗:详情 --> + <mail-log-detail ref="detailRef" /> +</template> +<script lang="ts" setup> +import { allSchemas } from './log.data' +import * as MailLogApi from '@/api/system/mail/log' +import MailLogDetail from './MailLogDetail.vue' + +defineOptions({ name: 'SystemMailLog' }) + +// tableObject:表格的属性对象,可获得分页大小、条数等属性 +// tableMethods:表格的操作对象,可进行获得分页、删除记录等操作 +// 详细可见:https://doc.iocoder.cn/vue3/crud-schema/ +const { tableObject, tableMethods } = useTable({ + getListApi: MailLogApi.getMailLogPage // 分页接口 +}) +// 获得表格的各种操作 +const { getList, setSearchParams } = tableMethods + +/** 详情操作 */ +const detailRef = ref() +const openDetail = (id: number) => { + detailRef.value.open(id) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/mail/log/log.data.ts b/src/views/system/mail/log/log.data.ts new file mode 100644 index 0000000..62cbf51 --- /dev/null +++ b/src/views/system/mail/log/log.data.ts @@ -0,0 +1,133 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter } from '@/utils/formatTime' +import * as MailAccountApi from '@/api/system/mail/account' + +// 邮箱账号的列表 +const accountList = await MailAccountApi.getSimpleMailAccountList() + +// CrudSchema:https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '编号', + field: 'id' + }, + { + label: '发送时间', + field: 'sendTime', + formatter: dateFormatter, + search: { + show: true, + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD HH:mm:ss', + type: 'daterange', + defaultTime: [new Date('1 00:00:00'), new Date('1 23:59:59')] + } + }, + detail: { + dateFormat: 'YYYY-MM-DD HH:mm:ss' + } + }, + { + label: '接收邮箱', + field: 'toMail' + }, + { + label: '用户编号', + field: 'userId', + isSearch: true, + isTable: false + }, + { + label: '用户类型', + field: 'userType', + dictType: DICT_TYPE.USER_TYPE, + dictClass: 'number', + isSearch: true, + isTable: false + }, + { + label: '邮件标题', + field: 'templateTitle' + }, + { + label: '邮件内容', + field: 'templateContent', + isTable: false + }, + { + label: '邮箱参数', + field: 'templateParams', + isTable: false + }, + { + label: '发送状态', + field: 'sendStatus', + dictType: DICT_TYPE.SYSTEM_MAIL_SEND_STATUS, + dictClass: 'string', + isSearch: true + }, + { + label: '邮箱账号', + field: 'accountId', + isTable: false, + search: { + show: true, + component: 'Select', + api: () => accountList, + componentProps: { + optionsAlias: { + labelField: 'mail', + valueField: 'id' + } + } + } + }, + { + label: '发送邮箱地址', + field: 'fromMail', + table: { + label: '邮箱账号' + } + }, + { + label: '模板编号', + field: 'templateId', + isSearch: true + }, + { + label: '模板编码', + field: 'templateCode', + isTable: false + }, + { + label: '模版发送人名称', + field: 'templateNickname', + isTable: false + }, + { + label: '发送返回的消息编号', + field: 'sendMessageId', + isTable: false + }, + { + label: '发送异常', + field: 'sendException', + isTable: false + }, + { + label: '创建时间', + field: 'createTime', + isTable: false, + formatter: dateFormatter, + detail: { + dateFormat: 'YYYY-MM-DD HH:mm:ss' + } + }, + { + label: '操作', + field: 'action', + isDetail: false + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) diff --git a/src/views/system/mail/template/MailTemplateForm.vue b/src/views/system/mail/template/MailTemplateForm.vue new file mode 100644 index 0000000..0d9fa89 --- /dev/null +++ b/src/views/system/mail/template/MailTemplateForm.vue @@ -0,0 +1,74 @@ +<template> + <Dialog + v-model="dialogVisible" + :max-height="500" + :scroll="true" + :title="dialogTitle" + :width="800" + > + <Form ref="formRef" v-loading="formLoading" :rules="rules" :schema="allSchemas.formSchema" /> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as MailTemplateApi from '@/api/system/mail/template' +import { allSchemas, rules } from './template.data' + +defineOptions({ name: 'SystemMailTemplateForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = await MailTemplateApi.getMailTemplate(id) + formRef.value.setValues(data) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.getElFormRef().validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formRef.value.formModel as MailTemplateApi.MailTemplateVO + if (formType.value === 'create') { + await MailTemplateApi.createMailTemplate(data) + message.success(t('common.createSuccess')) + } else { + await MailTemplateApi.updateMailTemplate(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} +</script> diff --git a/src/views/system/mail/template/MailTemplateSendForm.vue b/src/views/system/mail/template/MailTemplateSendForm.vue new file mode 100644 index 0000000..ebf945d --- /dev/null +++ b/src/views/system/mail/template/MailTemplateSendForm.vue @@ -0,0 +1,115 @@ +<template> + <Dialog v-model="dialogVisible" title="测试"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="120px" + > + <el-form-item label="模板内容" prop="content"> + <Editor :model-value="formData.content" height="150px" readonly /> + </el-form-item> + <el-form-item label="收件邮箱" prop="mail"> + <el-input v-model="formData.mail" placeholder="请输入收件邮箱" /> + </el-form-item> + <el-form-item + v-for="param in formData.params" + :key="param" + :label="'参数 {' + param + '}'" + :prop="'templateParams.' + param" + > + <el-input + v-model="formData.templateParams[param]" + :placeholder="'请输入 ' + param + ' 参数'" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as MailTemplateApi from '@/api/system/mail/template' + +defineOptions({ name: 'SystemMailTemplateSendForm' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + content: '', + params: {}, + mail: '', + templateCode: '', + templateParams: new Map() +}) +const formRules = reactive({ + mail: [{ required: true, message: '邮箱不能为空', trigger: 'blur' }], + templateCode: [{ required: true, message: '模版编号不能为空', trigger: 'blur' }], + templateParams: {} +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + resetForm() + // 设置数据 + formLoading.value = true + try { + const data = await MailTemplateApi.getMailTemplate(id) + // 设置动态表单 + formData.value.content = data.content + formData.value.params = data.params + formData.value.templateCode = data.code + formData.value.templateParams = data.params.reduce((obj, item) => { + obj[item] = '' // 给每个动态属性赋值,避免无法读取 + return obj + }, {}) + formRules.templateParams = data.params.reduce((obj, item) => { + obj[item] = { required: true, message: '参数 ' + item + ' 不能为空', trigger: 'blur' } + return obj + }, {}) + } finally { + formLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as MailTemplateApi.MailSendReqVO + const logId = await MailTemplateApi.sendMail(data) + if (logId) { + message.success('提交发送成功!发送结果,见发送日志编号:' + logId) + } + dialogVisible.value = false + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + content: '', + params: {}, + mail: '', + templateCode: '', + templateParams: new Map() + } + formRules.templateParams = {} + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/mail/template/index.vue b/src/views/system/mail/template/index.vue new file mode 100644 index 0000000..0d73620 --- /dev/null +++ b/src/views/system/mail/template/index.vue @@ -0,0 +1,107 @@ +<template> + <doc-alert title="邮件配置" url="https://doc.iocoder.cn/mail" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <Search :schema="allSchemas.searchSchema" @search="setSearchParams" @reset="setSearchParams"> + <!-- 新增等操作按钮 --> + <template #actionMore> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:mail-template:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </template> + </Search> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <Table + :columns="allSchemas.tableColumns" + :data="tableObject.tableList" + :loading="tableObject.loading" + :pagination="{ + total: tableObject.total + }" + v-model:pageSize="tableObject.pageSize" + v-model:currentPage="tableObject.currentPage" + > + <template #action="{ row }"> + <el-button + link + type="primary" + @click="openSendForm(row.id)" + v-hasPermi="['system:mail-template:send-mail']" + > + 测试 + </el-button> + <el-button + link + type="primary" + @click="openForm('update', row.id)" + v-hasPermi="['system:mail-template:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + v-hasPermi="['system:mail-template:delete']" + @click="handleDelete(row.id)" + > + 删除 + </el-button> + </template> + </Table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <MailTemplateForm ref="formRef" @success="getList" /> + + <!-- 表单弹窗:发送测试 --> + <MailTemplateSendForm ref="sendFormRef" /> +</template> +<script lang="ts" setup> +import { allSchemas } from './template.data' +import * as MailTemplateApi from '@/api/system/mail/template' +import MailTemplateForm from './MailTemplateForm.vue' +import MailTemplateSendForm from './MailTemplateSendForm.vue' + +defineOptions({ name: 'SystemMailTemplate' }) + +// tableObject:表格的属性对象,可获得分页大小、条数等属性 +// tableMethods:表格的操作对象,可进行获得分页、删除记录等操作 +// 详细可见:https://doc.iocoder.cn/vue3/crud-schema/ +const { tableObject, tableMethods } = useTable({ + getListApi: MailTemplateApi.getMailTemplatePage, // 分页接口 + delListApi: MailTemplateApi.deleteMailTemplate // 删除接口 +}) +// 获得表格的各种操作 +const { getList, setSearchParams } = tableMethods + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = (id: number) => { + tableMethods.delList(id, false) +} + +/** 发送测试操作 */ +const sendFormRef = ref() +const openSendForm = (id: number) => { + sendFormRef.value.open(id) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/mail/template/template.data.ts b/src/views/system/mail/template/template.data.ts new file mode 100644 index 0000000..e68f875 --- /dev/null +++ b/src/views/system/mail/template/template.data.ts @@ -0,0 +1,113 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter } from '@/utils/formatTime' +import { TableColumn } from '@/types/table' +import * as MailAccountApi from '@/api/system/mail/account' + +// 邮箱账号的列表 +const accountList = await MailAccountApi.getSimpleMailAccountList() + +// 表单校验 +export const rules = reactive({ + name: [required], + code: [required], + accountId: [required], + label: [required], + content: [required], + params: [required], + status: [required] +}) + +// CrudSchema:https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '模板编码', + field: 'code', + isSearch: true + }, + { + label: '模板名称', + field: 'name', + isSearch: true + }, + { + label: '模板标题', + field: 'title' + }, + { + label: '模板内容', + field: 'content', + form: { + component: 'Editor', + componentProps: { + valueHtml: '', + height: 200 + } + } + }, + { + label: '邮箱账号', + field: 'accountId', + width: '200px', + formatter: (_: Recordable, __: TableColumn, cellValue: number) => { + return accountList.find((account) => account.id === cellValue)?.mail + }, + search: { + show: true, + component: 'Select', + api: () => accountList, + componentProps: { + optionsAlias: { + labelField: 'mail', + valueField: 'id' + } + } + }, + form: { + component: 'Select', + api: () => accountList, + componentProps: { + optionsAlias: { + labelField: 'mail', + valueField: 'id' + } + } + } + }, + { + label: '发送人名称', + field: 'nickname' + }, + { + label: '开启状态', + field: 'status', + isSearch: true, + dictType: DICT_TYPE.COMMON_STATUS, + dictClass: 'number' + }, + { + label: '备注', + field: 'remark', + isTable: false + }, + { + label: '创建时间', + field: 'createTime', + isForm: false, + formatter: dateFormatter, + search: { + show: true, + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD HH:mm:ss', + type: 'daterange', + defaultTime: [new Date('1 00:00:00'), new Date('1 23:59:59')] + } + } + }, + { + label: '操作', + field: 'action', + isForm: false + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) diff --git a/src/views/system/menu/MenuForm.vue b/src/views/system/menu/MenuForm.vue new file mode 100644 index 0000000..2b4a90d --- /dev/null +++ b/src/views/system/menu/MenuForm.vue @@ -0,0 +1,256 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item label="上级菜单"> + <el-tree-select + v-model="formData.parentId" + :data="menuTree" + :default-expanded-keys="[0]" + :props="defaultProps" + check-strictly + node-key="id" + /> + </el-form-item> + <el-form-item label="菜单名称" prop="name"> + <el-input v-model="formData.name" clearable placeholder="请输入菜单名称" /> + </el-form-item> + <el-form-item label="菜单类型" prop="type"> + <el-radio-group v-model="formData.type"> + <el-radio-button + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_MENU_TYPE)" + :key="dict.label" + :label="dict.value" + > + {{ dict.label }} + </el-radio-button> + </el-radio-group> + </el-form-item> + <el-form-item v-if="formData.type !== 3" label="菜单图标"> + <IconSelect v-model="formData.icon" clearable /> + </el-form-item> + <el-form-item v-if="formData.type !== 3" label="路由地址" prop="path"> + <template #label> + <Tooltip + message="访问的路由地址,如:`user`。如需外网地址时,则以 `http(s)://` 开头" + title="路由地址" + /> + </template> + <el-input v-model="formData.path" clearable placeholder="请输入路由地址" /> + </el-form-item> + <el-form-item v-if="formData.type === 2" label="组件地址" prop="component"> + <el-input v-model="formData.component" clearable placeholder="例如说:system/user/index" /> + </el-form-item> + <el-form-item v-if="formData.type === 2" label="组件名字" prop="componentName"> + <el-input v-model="formData.componentName" clearable placeholder="例如说:SystemUser" /> + </el-form-item> + <el-form-item v-if="formData.type !== 1" label="权限标识" prop="permission"> + <template #label> + <Tooltip + message="Controller 方法上的权限字符,如:@PreAuthorize(`@ss.hasPermission('system:user:list')`)" + title="权限标识" + /> + </template> + <el-input v-model="formData.permission" clearable placeholder="请输入权限标识" /> + </el-form-item> + <el-form-item label="显示排序" prop="sort"> + <el-input-number v-model="formData.sort" :min="0" clearable controls-position="right" /> + </el-form-item> + <el-form-item label="菜单状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.label" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item v-if="formData.type !== 3" label="显示状态" prop="visible"> + <template #label> + <Tooltip message="选择隐藏时,路由将不会出现在侧边栏,但仍然可以访问" title="显示状态" /> + </template> + <el-radio-group v-model="formData.visible"> + <el-radio key="true" :label="true" border>显示</el-radio> + <el-radio key="false" :label="false" border>隐藏</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item v-if="formData.type !== 3" label="总是显示" prop="alwaysShow"> + <template #label> + <Tooltip + message="选择不是时,当该菜单只有一个子菜单时,不展示自己,直接展示子菜单" + title="总是显示" + /> + </template> + <el-radio-group v-model="formData.alwaysShow"> + <el-radio key="true" :label="true" border>总是</el-radio> + <el-radio key="false" :label="false" border>不是</el-radio> + </el-radio-group> + </el-form-item> + <el-form-item v-if="formData.type === 2" label="缓存状态" prop="keepAlive"> + <template #label> + <Tooltip + message="选择缓存时,则会被 `keep-alive` 缓存,必须填写「组件名称」字段" + title="缓存状态" + /> + </template> + <el-radio-group v-model="formData.keepAlive"> + <el-radio key="true" :label="true" border>缓存</el-radio> + <el-radio key="false" :label="false" border>不缓存</el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as MenuApi from '@/api/system/menu' +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' +import { CommonStatusEnum, SystemMenuTypeEnum } from '@/utils/constants' +import { defaultProps, handleTree } from '@/utils/tree' + +defineOptions({ name: 'SystemMenuForm' }) + +const { wsCache } = useCache() +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + permission: '', + type: SystemMenuTypeEnum.DIR, + sort: Number(undefined), + parentId: 0, + path: '', + icon: '', + component: '', + componentName: '', + status: CommonStatusEnum.ENABLE, + visible: true, + keepAlive: true, + alwaysShow: true +}) +const formRules = reactive({ + name: [{ required: true, message: '菜单名称不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '菜单顺序不能为空', trigger: 'blur' }], + path: [{ required: true, message: '路由地址不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number, parentId?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + if (parentId) { + formData.value.parentId = parentId + } + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await MenuApi.getMenu(id) + } finally { + formLoading.value = false + } + } + // 获得菜单列表 + await getTree() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + if ( + formData.value.type === SystemMenuTypeEnum.DIR || + formData.value.type === SystemMenuTypeEnum.MENU + ) { + if (!isExternal(formData.value.path)) { + if (formData.value.parentId === 0 && formData.value.path.charAt(0) !== '/') { + message.error('路径必须以 / 开头') + return + } else if (formData.value.parentId !== 0 && formData.value.path.charAt(0) === '/') { + message.error('路径不能以 / 开头') + return + } + } + } + const data = formData.value as unknown as MenuApi.MenuVO + if (formType.value === 'create') { + await MenuApi.createMenu(data) + message.success(t('common.createSuccess')) + } else { + await MenuApi.updateMenu(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + // 清空,从而触发刷新 + wsCache.delete(CACHE_KEY.ROLE_ROUTERS) + } +} + +/** 获取下拉框[上级菜单]的数据 */ +const menuTree = ref<Tree[]>([]) // 树形结构 +const getTree = async () => { + menuTree.value = [] + const res = await MenuApi.getSimpleMenusList() + let menu: Tree = { id: 0, name: '主类目', children: [] } + menu.children = handleTree(res) + menuTree.value.push(menu) +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + permission: '', + type: SystemMenuTypeEnum.DIR, + sort: Number(undefined), + parentId: 0, + path: '', + icon: '', + component: '', + componentName: '', + status: CommonStatusEnum.ENABLE, + visible: true, + keepAlive: true, + alwaysShow: true + } + formRef.value?.resetFields() +} + +/** 判断 path 是不是外部的 HTTP 等链接 */ +const isExternal = (path: string) => { + return /^(https?:|mailto:|tel:)/.test(path) +} +</script> diff --git a/src/views/system/menu/index.vue b/src/views/system/menu/index.vue new file mode 100644 index 0000000..bf64a80 --- /dev/null +++ b/src/views/system/menu/index.vue @@ -0,0 +1,215 @@ +<template> + <doc-alert title="功能权限" url="https://doc.iocoder.cn/resource-permission" /> + <doc-alert title="菜单路由" url="https://doc.iocoder.cn/vue3/route/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="菜单名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入菜单名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select + v-model="queryParams.status" + class="!w-240px" + clearable + placeholder="请选择菜单状态" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['system:menu:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button plain type="danger" @click="toggleExpandAll"> + <Icon class="mr-5px" icon="ep:sort" /> + 展开/折叠 + </el-button> + <el-button plain @click="refreshMenu"> + <Icon class="mr-5px" icon="ep:refresh" /> + 刷新菜单缓存 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-if="refreshTable" + v-loading="loading" + :data="list" + :default-expand-all="isExpandAll" + row-key="id" + > + <el-table-column :show-overflow-tooltip="true" label="菜单名称" prop="name" width="250" /> + <el-table-column align="center" label="图标" prop="icon" width="100"> + <template #default="scope"> + <Icon :icon="scope.row.icon" /> + </template> + </el-table-column> + <el-table-column label="排序" prop="sort" width="60" /> + <el-table-column :show-overflow-tooltip="true" label="权限标识" prop="permission" /> + <el-table-column :show-overflow-tooltip="true" label="组件路径" prop="component" /> + <el-table-column :show-overflow-tooltip="true" label="组件名称" prop="componentName" /> + <el-table-column label="状态" prop="status" width="80"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['system:menu:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 修改 + </el-button> + <el-button + v-hasPermi="['system:menu:create']" + link + type="primary" + @click="openForm('create', undefined, scope.row.id)" + > + 新增 + </el-button> + <el-button + v-hasPermi="['system:menu:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <MenuForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { handleTree } from '@/utils/tree' +import * as MenuApi from '@/api/system/menu' +import MenuForm from './MenuForm.vue' +import { CACHE_KEY, useCache } from '@/hooks/web/useCache' + +defineOptions({ name: 'SystemMenu' }) + +const { wsCache } = useCache() +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const list = ref<any>([]) // 列表的数据 +const queryParams = reactive({ + name: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const isExpandAll = ref(false) // 是否展开,默认全部折叠 +const refreshTable = ref(true) // 重新渲染表格状态 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await MenuApi.getMenuList(queryParams) + list.value = handleTree(data) + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number, parentId?: number) => { + formRef.value.open(type, id, parentId) +} + +/** 展开/折叠操作 */ +const toggleExpandAll = () => { + refreshTable.value = false + isExpandAll.value = !isExpandAll.value + nextTick(() => { + refreshTable.value = true + }) +} + +/** 刷新菜单缓存按钮操作 */ +const refreshMenu = async () => { + try { + await message.confirm('即将更新缓存刷新浏览器!', '刷新菜单缓存') + // 清空,从而触发刷新 + wsCache.delete(CACHE_KEY.USER) + wsCache.delete(CACHE_KEY.ROLE_ROUTERS) + // 刷新浏览器 + location.reload() + } catch {} +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await MenuApi.deleteMenu(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/notice/NoticeForm.vue b/src/views/system/notice/NoticeForm.vue new file mode 100644 index 0000000..5c18d80 --- /dev/null +++ b/src/views/system/notice/NoticeForm.vue @@ -0,0 +1,132 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="800"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="公告标题" prop="title"> + <el-input v-model="formData.title" placeholder="请输入公告标题" /> + </el-form-item> + <el-form-item label="公告内容" prop="content"> + <Editor v-model="formData.content" height="150px" /> + </el-form-item> + <el-form-item label="公告类型" prop="type"> + <el-select v-model="formData.type" clearable placeholder="请选择公告类型"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_NOTICE_TYPE)" + :key="parseInt(dict.value as any)" + :label="dict.label" + :value="parseInt(dict.value as any)" + /> + </el-select> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="formData.status" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="parseInt(dict.value as any)" + :label="dict.label" + :value="parseInt(dict.value as any)" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输备注" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import * as NoticeApi from '@/api/system/notice' + +defineOptions({ name: 'SystemNoticeForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + title: '', + type: undefined, + content: '', + status: CommonStatusEnum.ENABLE, + remark: '' +}) +const formRules = reactive({ + title: [{ required: true, message: '公告标题不能为空', trigger: 'blur' }], + type: [{ required: true, message: '公告类型不能为空', trigger: 'change' }], + status: [{ required: true, message: '状态不能为空', trigger: 'change' }], + content: [{ required: true, message: '公告内容不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await NoticeApi.getNotice(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as NoticeApi.NoticeVO + if (formType.value === 'create') { + await NoticeApi.createNotice(data) + message.success(t('common.createSuccess')) + } else { + await NoticeApi.updateNotice(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + title: '', + type: undefined, + content: '', + status: CommonStatusEnum.ENABLE, + remark: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/notice/index.vue b/src/views/system/notice/index.vue new file mode 100644 index 0000000..f482f91 --- /dev/null +++ b/src/views/system/notice/index.vue @@ -0,0 +1,189 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="公告标题" prop="title"> + <el-input + v-model="queryParams.title" + placeholder="请输入公告标题" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="公告状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择公告状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:notice:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="公告编号" align="center" prop="id" /> + <el-table-column label="公告标题" align="center" prop="title" /> + <el-table-column label="公告类型" align="center" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_NOTICE_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:notice:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['system:notice:delete']" + > + 删除 + </el-button> + <el-button link @click="handlePush(scope.row.id)" v-hasPermi="['system:notice:update']"> + 推送 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <NoticeForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as NoticeApi from '@/api/system/notice' +import NoticeForm from './NoticeForm.vue' + +defineOptions({ name: 'SystemNotice' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + title: '', + type: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询公告列表 */ +const getList = async () => { + loading.value = true + try { + const data = await NoticeApi.getNoticePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await NoticeApi.deleteNotice(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 推送按钮操作 */ +const handlePush = async (id: number) => { + try { + // 推送的二次确认 + await message.confirm('是否推送所选中通知?') + // 发起推送 + await NoticeApi.pushNotice(id) + message.success(t('推送成功')) + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/notify/message/NotifyMessageDetail.vue b/src/views/system/notify/message/NotifyMessageDetail.vue new file mode 100644 index 0000000..8472351 --- /dev/null +++ b/src/views/system/notify/message/NotifyMessageDetail.vue @@ -0,0 +1,66 @@ +<template> + <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="详情"> + <el-descriptions :column="1" border> + <el-descriptions-item label="编号" min-width="120"> + {{ detailData.id }} + </el-descriptions-item> + <el-descriptions-item label="用户类型"> + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="detailData.userType" /> + </el-descriptions-item> + <el-descriptions-item label="用户编号"> + {{ detailData.userId }} + </el-descriptions-item> + <el-descriptions-item label="模版编号"> + {{ detailData.templateId }} + </el-descriptions-item> + <el-descriptions-item label="模板编码"> + {{ detailData.templateCode }} + </el-descriptions-item> + <el-descriptions-item label="发送人名称"> + {{ detailData.templateNickname }} + </el-descriptions-item> + <el-descriptions-item label="模版内容"> + {{ detailData.templateContent }} + </el-descriptions-item> + <el-descriptions-item label="模版参数"> + {{ detailData.templateParams }} + </el-descriptions-item> + <el-descriptions-item label="模版类型"> + <dict-tag :type="DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE" :value="detailData.templateType" /> + </el-descriptions-item> + <el-descriptions-item label="是否已读"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="detailData.readStatus" /> + </el-descriptions-item> + <el-descriptions-item label="阅读时间"> + {{ formatDate(detailData.readTime) }} + </el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(detailData.createTime) }} + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import * as NotifyMessageApi from '@/api/system/notify/message' + +defineOptions({ name: 'SystemNotifyMessageDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({} as NotifyMessageApi.NotifyMessageVO) // 详情数据 + +/** 打开弹窗 */ +const open = async (data: NotifyMessageApi.NotifyMessageVO) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = data + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/system/notify/message/index.vue b/src/views/system/notify/message/index.vue new file mode 100644 index 0000000..9484411 --- /dev/null +++ b/src/views/system/notify/message/index.vue @@ -0,0 +1,212 @@ +<template> + <doc-alert title="站内信配置" url="https://doc.iocoder.cn/notify/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户编号" prop="userId"> + <el-input + v-model="queryParams.userId" + placeholder="请输入用户编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="用户类型" prop="userType"> + <el-select + v-model="queryParams.userType" + placeholder="请选择用户类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="模板编码" prop="templateCode"> + <el-input + v-model="queryParams.templateCode" + placeholder="请输入模板编码" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="模版类型" prop="templateType"> + <el-select + v-model="queryParams.templateType" + placeholder="请选择模版类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="用户类型" align="center" prop="userType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" /> + </template> + </el-table-column> + <el-table-column label="用户编号" align="center" prop="userId" width="80" /> + <el-table-column label="模板编码" align="center" prop="templateCode" width="80" /> + <el-table-column label="发送人名称" align="center" prop="templateNickname" width="180" /> + <el-table-column + label="模版内容" + align="center" + prop="templateContent" + width="200" + show-overflow-tooltip + /> + <el-table-column + label="模版参数" + align="center" + prop="templateParams" + width="180" + show-overflow-tooltip + > + <template #default="scope"> {{ scope.row.templateParams }}</template> + </el-table-column> + <el-table-column label="模版类型" align="center" prop="templateType" width="120"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE" :value="scope.row.templateType" /> + </template> + </el-table-column> + <el-table-column label="是否已读" align="center" prop="readStatus" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.readStatus" /> + </template> + </el-table-column> + <el-table-column + label="阅读时间" + align="center" + prop="readTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openDetail(scope.row)" + v-hasPermi="['system:notify-message:query']" + > + 详情 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:详情 --> + <NotifyMessageDetail ref="detailRef" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as NotifyMessageApi from '@/api/system/notify/message' +import NotifyMessageDetail from './NotifyMessageDetail.vue' + +defineOptions({ name: 'SystemNotifyMessage' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userType: undefined, + userId: undefined, + templateCode: undefined, + templateType: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await NotifyMessageApi.getNotifyMessagePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 详情操作 */ +const detailRef = ref() +const openDetail = (data: NotifyMessageApi.NotifyMessageVO) => { + detailRef.value.open(data) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/notify/my/MyNotifyMessageDetail.vue b/src/views/system/notify/my/MyNotifyMessageDetail.vue new file mode 100644 index 0000000..0bfa30c --- /dev/null +++ b/src/views/system/notify/my/MyNotifyMessageDetail.vue @@ -0,0 +1,48 @@ +<template> + <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="消息详情"> + <el-descriptions :column="1" border> + <el-descriptions-item label="发送人"> + {{ detailData.templateNickname }} + </el-descriptions-item> + <el-descriptions-item label="发送时间"> + {{ formatDate(detailData.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="消息类型"> + <dict-tag :type="DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE" :value="detailData.templateType" /> + </el-descriptions-item> + <el-descriptions-item label="是否已读"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="detailData.readStatus" /> + </el-descriptions-item> + <el-descriptions-item v-if="detailData.readStatus" label="阅读时间"> + {{ formatDate(detailData.readTime) }} + </el-descriptions-item> + <el-descriptions-item label="内容"> + {{ detailData.templateContent }} + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import * as NotifyMessageApi from '@/api/system/notify/message' + +defineOptions({ name: 'MyNotifyMessageDetailDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({} as NotifyMessageApi.NotifyMessageVO) // 详情数据 + +/** 打开弹窗 */ +const open = async (data: NotifyMessageApi.NotifyMessageVO) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = data + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/system/notify/my/index.vue b/src/views/system/notify/my/index.vue new file mode 100644 index 0000000..ae4b9c5 --- /dev/null +++ b/src/views/system/notify/my/index.vue @@ -0,0 +1,218 @@ +<template> + <doc-alert title="站内信配置" url="https://doc.iocoder.cn/notify/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="是否已读" prop="readStatus"> + <el-select + v-model="queryParams.readStatus" + placeholder="请选择状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="发送时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button @click="handleUpdateList"> + <Icon icon="ep:reading" class="mr-5px" /> 标记已读 + </el-button> + <el-button @click="handleUpdateAll"> + <Icon icon="ep:reading" class="mr-5px" /> 全部已读 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table + v-loading="loading" + :data="list" + ref="tableRef" + row-key="id" + @selection-change="handleSelectionChange" + > + <el-table-column type="selection" :selectable="selectable" :reserve-selection="true" /> + <el-table-column label="发送人" align="center" prop="templateNickname" width="180" /> + <el-table-column + label="发送时间" + align="center" + prop="createTime" + width="200" + :formatter="dateFormatter" + /> + <el-table-column label="类型" align="center" prop="templateType" width="180"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE" :value="scope.row.templateType" /> + </template> + </el-table-column> + <el-table-column + label="消息内容" + align="center" + prop="templateContent" + show-overflow-tooltip + /> + <el-table-column label="是否已读" align="center" prop="readStatus" width="160"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.readStatus" /> + </template> + </el-table-column> + <el-table-column + label="阅读时间" + align="center" + prop="readTime" + width="200" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center" width="160"> + <template #default="scope"> + <el-button + link + :type="scope.row.readStatus ? 'primary' : 'warning'" + @click="openDetail(scope.row)" + > + {{ scope.row.readStatus ? '详情' : '已读' }} + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:详情 --> + <MyNotifyMessageDetail ref="detailRef" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getBoolDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as NotifyMessageApi from '@/api/system/notify/message' +import MyNotifyMessageDetail from './MyNotifyMessageDetail.vue' + +defineOptions({ name: 'SystemMyNotify' }) + +const message = useMessage() // 消息 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + readStatus: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const tableRef = ref() // 表格的 Ref +const selectedIds = ref<number[]>([]) // 表格的选中 ID 数组 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await NotifyMessageApi.getMyNotifyMessagePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + tableRef.value.clearSelection() + handleQuery() +} + +/** 详情操作 */ +const detailRef = ref() +const openDetail = (data: NotifyMessageApi.NotifyMessageVO) => { + if (!data.readStatus) { + handleReadOne(data.id) + } + detailRef.value.open(data) +} + +/** 标记一条站内信已读 */ +const handleReadOne = async (id) => { + await NotifyMessageApi.updateNotifyMessageRead(id) + await getList() +} + +/** 标记全部站内信已读 **/ +const handleUpdateAll = async () => { + await NotifyMessageApi.updateAllNotifyMessageRead() + message.success('全部已读成功!') + tableRef.value.clearSelection() + await getList() +} + +/** 标记一些站内信已读 **/ +const handleUpdateList = async () => { + if (selectedIds.value.length === 0) { + return + } + await NotifyMessageApi.updateNotifyMessageRead(selectedIds.value) + message.success('批量已读成功!') + tableRef.value.clearSelection() + await getList() +} + +/** 某一行,是否允许选中 */ +const selectable = (row) => { + return !row.readStatus +} + +/** 当表格选择项发生变化时会触发该事件 */ +const handleSelectionChange = (array: NotifyMessageApi.NotifyMessageVO[]) => { + selectedIds.value = [] + if (!array) { + return + } + array.forEach((row) => selectedIds.value.push(row.id)) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/notify/template/NotifyTemplateForm.vue b/src/views/system/notify/template/NotifyTemplateForm.vue new file mode 100644 index 0000000..a734e2d --- /dev/null +++ b/src/views/system/notify/template/NotifyTemplateForm.vue @@ -0,0 +1,141 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="140px" + v-loading="formLoading" + > + <el-form-item label="模版编码" prop="code"> + <el-input v-model="formData.code" placeholder="请输入模版编码" /> + </el-form-item> + <el-form-item label="模板名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入模版名称" /> + </el-form-item> + <el-form-item label="发件人名称" prop="nickname"> + <el-input v-model="formData.nickname" placeholder="请输入发件人名称" /> + </el-form-item> + <el-form-item label="模板内容" prop="content"> + <el-input type="textarea" v-model="formData.content" placeholder="请输入模板内容" /> + </el-form-item> + <el-form-item label="类型" prop="type"> + <el-select v-model="formData.type" placeholder="请选择类型"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as NotifyTemplateApi from '@/api/system/notify/template' +import { CommonStatusEnum } from '@/utils/constants' +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型 +const formData = ref<NotifyTemplateApi.NotifyTemplateVO>({ + id: undefined, + name: '', + nickname: '', + code: '', + content: '', + type: undefined, + params: '', + status: CommonStatusEnum.ENABLE, + remark: '' +}) +const formRules = reactive({ + type: [{ required: true, message: '消息类型不能为空', trigger: 'change' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }], + code: [{ required: true, message: '模板编码不能为空', trigger: 'blur' }], + name: [{ required: true, message: '模板名称不能为空', trigger: 'blur' }], + nickname: [{ required: true, message: '发件人姓名不能为空', trigger: 'blur' }], + content: [{ required: true, message: '模板内容不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = type + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await NotifyTemplateApi.getNotifyTemplate(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + formLoading.value = true + try { + const data = formData.value as unknown as NotifyTemplateApi.NotifyTemplateVO + if (formType.value === 'create') { + await NotifyTemplateApi.createNotifyTemplate(data) + message.success('新增成功') + } else { + await NotifyTemplateApi.updateNotifyTemplate(data) + message.success('修改成功') + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + nickname: '', + code: '', + content: '', + type: undefined, + params: '', + status: CommonStatusEnum.ENABLE, + remark: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/notify/template/NotifyTemplateSendForm.vue b/src/views/system/notify/template/NotifyTemplateSendForm.vue new file mode 100644 index 0000000..126067c --- /dev/null +++ b/src/views/system/notify/template/NotifyTemplateSendForm.vue @@ -0,0 +1,146 @@ +<template> + <Dialog v-model="dialogVisible" title="测试发送" :max-height="500"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="140px" + > + <el-form-item label="模板内容" prop="content"> + <el-input + v-model="formData.content" + placeholder="请输入模板内容" + readonly + type="textarea" + /> + </el-form-item> + <el-form-item label="用户类型" prop="userType"> + <el-radio-group v-model="formData.userType"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item v-show="formData.userType === 1" label="接收人ID" prop="userId"> + <el-input v-model="formData.userId" style="width: 160px" /> + </el-form-item> + <el-form-item v-show="formData.userType === 2" label="接收人" prop="userId"> + <el-select v-model="formData.userId" placeholder="请选择接收人"> + <el-option + v-for="item in userOption" + :key="item.id" + :label="item.nickname" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item + v-for="param in formData.params" + :key="param" + :label="'参数 {' + param + '}'" + :prop="'templateParams.' + param" + > + <el-input + v-model="formData.templateParams[param]" + :placeholder="'请输入 ' + param + ' 参数'" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as UserApi from '@/api/system/user' +import * as NotifyTemplateApi from '@/api/system/notify/template' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' + +defineOptions({ name: 'SystemNotifyTemplateSendForm' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + content: '', + params: {}, + userId: undefined, + userType: 1, + templateCode: '', + templateParams: new Map() +}) +const formRules = reactive({ + userId: [{ required: true, message: '用户编号不能为空', trigger: 'change' }], + templateCode: [{ required: true, message: '模版编号不能为空', trigger: 'blur' }], + templateParams: {} +}) +const formRef = ref() // 表单 Ref +const userOption = ref<UserApi.UserVO[]>([]) + +const open = async (id: number) => { + dialogVisible.value = true + resetForm() + // 设置数据 + formLoading.value = true + try { + const data = await NotifyTemplateApi.getNotifyTemplate(id) + // 设置动态表单 + formData.value.content = data.content + formData.value.params = data.params + formData.value.templateCode = data.code + formData.value.templateParams = data.params.reduce((obj, item) => { + obj[item] = '' // 给每个动态属性赋值,避免无法读取 + return obj + }, {}) + formRules.templateParams = data.params.reduce((obj, item) => { + obj[item] = { required: true, message: '参数 ' + item + ' 不能为空', trigger: 'blur' } + return obj + }, {}) + } finally { + formLoading.value = false + } + // 加载用户列表 + userOption.value = await UserApi.getSimpleUserList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as NotifyTemplateApi.NotifySendReqVO + const logId = await NotifyTemplateApi.sendNotify(data) + if (logId) { + message.success('提交发送成功!发送结果,见发送日志编号:' + logId) + } + dialogVisible.value = false + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + content: '', + params: {}, + mobile: '', + templateCode: '', + templateParams: new Map(), + userType: 1 + } as any + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/notify/template/index.vue b/src/views/system/notify/template/index.vue new file mode 100644 index 0000000..5aef80d --- /dev/null +++ b/src/views/system/notify/template/index.vue @@ -0,0 +1,235 @@ +<template> + <doc-alert title="站内信配置" url="https://doc.iocoder.cn/notify/" /> + + <!-- 搜索工作栏 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="模板名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入模板名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="模板编号" prop="code"> + <el-input + v-model="queryParams.code" + placeholder="请输入模版编码" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择开启状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:notify-template:create']" + > + <Icon icon="ep:plus" class="mr-5px" />新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column + label="模板编码" + align="center" + prop="code" + width="120" + :show-overflow-tooltip="true" + /> + <el-table-column + label="模板名称" + align="center" + prop="name" + width="120" + :show-overflow-tooltip="true" + /> + <el-table-column label="类型" align="center" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column label="发送人名称" align="center" prop="nickname" /> + <el-table-column + label="模板内容" + align="center" + prop="content" + width="200" + :show-overflow-tooltip="true" + /> + <el-table-column label="开启状态" align="center" prop="status" width="80"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center" width="210" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:notify-template:update']" + > + 修改 + </el-button> + <el-button + link + type="primary" + @click="openSendForm(scope.row)" + v-hasPermi="['system:notify-template:send-notify']" + > + 测试 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['system:notify-template:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <NotifyTemplateForm ref="formRef" @success="getList" /> + <!-- 表单弹窗:测试发送 --> + <NotifyTemplateSendForm ref="sendFormRef" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as NotifyTemplateApi from '@/api/system/notify/template' +import NotifyTemplateForm from './NotifyTemplateForm.vue' +import NotifyTemplateSendForm from './NotifyTemplateSendForm.vue' + +defineOptions({ name: 'NotifySmsTemplate' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(false) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined, + code: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await NotifyTemplateApi.getNotifyTemplatePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await NotifyTemplateApi.deleteNotifyTemplate(id) + message.success('删除成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 发送站内信按钮 */ +const sendFormRef = ref() // 表单 Ref +const openSendForm = (row: NotifyTemplateApi.NotifyTemplateVO) => { + sendFormRef.value.open(row.id) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/oauth2/client/ClientForm.vue b/src/views/system/oauth2/client/ClientForm.vue new file mode 100644 index 0000000..5d13f71 --- /dev/null +++ b/src/views/system/oauth2/client/ClientForm.vue @@ -0,0 +1,261 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" max-height="500px" scroll> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="160px" + > + <el-form-item label="客户端编号" prop="secret"> + <el-input v-model="formData.clientId" placeholder="请输入客户端编号" /> + </el-form-item> + <el-form-item label="客户端密钥" prop="secret"> + <el-input v-model="formData.secret" placeholder="请输入客户端密钥" /> + </el-form-item> + <el-form-item label="应用名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入应用名" /> + </el-form-item> + <el-form-item label="应用图标"> + <UploadImg v-model="formData.logo" :limit="1" /> + </el-form-item> + <el-form-item label="应用描述"> + <el-input v-model="formData.description" placeholder="请输入应用名" type="textarea" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="访问令牌的有效期" prop="accessTokenValiditySeconds"> + <el-input-number v-model="formData.accessTokenValiditySeconds" placeholder="单位:秒" /> + </el-form-item> + <el-form-item label="刷新令牌的有效期" prop="refreshTokenValiditySeconds"> + <el-input-number v-model="formData.refreshTokenValiditySeconds" placeholder="单位:秒" /> + </el-form-item> + <el-form-item label="授权类型" prop="authorizedGrantTypes"> + <el-select + v-model="formData.authorizedGrantTypes" + filterable + multiple + placeholder="请输入授权类型" + style="width: 500px" + > + <el-option + v-for="dict in getDictOptions(DICT_TYPE.SYSTEM_OAUTH2_GRANT_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="授权范围" prop="scopes"> + <el-select + v-model="formData.scopes" + filterable + multiple + allow-create + placeholder="请输入授权范围" + style="width: 500px" + > + <el-option v-for="scope in formData.scopes" :key="scope" :label="scope" :value="scope" /> + </el-select> + </el-form-item> + <el-form-item label="自动授权范围" prop="autoApproveScopes"> + <el-select + v-model="formData.autoApproveScopes" + filterable + multiple + placeholder="请输入授权范围" + style="width: 500px" + > + <el-option v-for="scope in formData.scopes" :key="scope" :label="scope" :value="scope" /> + </el-select> + </el-form-item> + <el-form-item label="可重定向的 URI 地址" prop="redirectUris"> + <el-select + v-model="formData.redirectUris" + allow-create + filterable + multiple + placeholder="请输入可重定向的 URI 地址" + style="width: 500px" + > + <el-option + v-for="redirectUri in formData.redirectUris" + :key="redirectUri" + :label="redirectUri" + :value="redirectUri" + /> + </el-select> + </el-form-item> + <el-form-item label="权限" prop="authorities"> + <el-select + v-model="formData.authorities" + allow-create + filterable + multiple + placeholder="请输入权限" + style="width: 500px" + > + <el-option + v-for="authority in formData.authorities" + :key="authority" + :label="authority" + :value="authority" + /> + </el-select> + </el-form-item> + <el-form-item label="资源" prop="resourceIds"> + <el-select + v-model="formData.resourceIds" + allow-create + filterable + multiple + placeholder="请输入资源" + style="width: 500px" + > + <el-option + v-for="resourceId in formData.resourceIds" + :key="resourceId" + :label="resourceId" + :value="resourceId" + /> + </el-select> + </el-form-item> + <el-form-item label="附加信息" prop="additionalInformation"> + <el-input + v-model="formData.additionalInformation" + placeholder="请输入附加信息,JSON 格式数据" + type="textarea" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getDictOptions, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import * as ClientApi from '@/api/system/oauth2/client' + +defineOptions({ name: 'SystemOAuth2ClientForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + clientId: undefined, + secret: undefined, + name: undefined, + logo: undefined, + description: undefined, + status: CommonStatusEnum.ENABLE, + accessTokenValiditySeconds: 30 * 60, + refreshTokenValiditySeconds: 30 * 24 * 60, + redirectUris: [], + authorizedGrantTypes: [], + scopes: [], + autoApproveScopes: [], + authorities: [], + resourceIds: [], + additionalInformation: undefined +}) +const formRules = reactive({ + clientId: [{ required: true, message: '客户端编号不能为空', trigger: 'blur' }], + secret: [{ required: true, message: '客户端密钥不能为空', trigger: 'blur' }], + name: [{ required: true, message: '应用名不能为空', trigger: 'blur' }], + logo: [{ required: true, message: '应用图标不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }], + accessTokenValiditySeconds: [ + { required: true, message: '访问令牌的有效期不能为空', trigger: 'blur' } + ], + refreshTokenValiditySeconds: [ + { required: true, message: '刷新令牌的有效期不能为空', trigger: 'blur' } + ], + redirectUris: [{ required: true, message: '可重定向的 URI 地址不能为空', trigger: 'blur' }], + authorizedGrantTypes: [{ required: true, message: '授权类型不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ClientApi.getOAuth2Client(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ClientApi.OAuth2ClientVO + if (formType.value === 'create') { + await ClientApi.createOAuth2Client(data) + message.success(t('common.createSuccess')) + } else { + await ClientApi.updateOAuth2Client(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + clientId: undefined, + secret: undefined, + name: undefined, + logo: undefined, + description: undefined, + status: CommonStatusEnum.ENABLE, + accessTokenValiditySeconds: 30 * 60, + refreshTokenValiditySeconds: 30 * 24 * 60, + redirectUris: [], + authorizedGrantTypes: [], + scopes: [], + autoApproveScopes: [], + authorities: [], + resourceIds: [], + additionalInformation: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/oauth2/client/index.vue b/src/views/system/oauth2/client/index.vue new file mode 100644 index 0000000..fceee57 --- /dev/null +++ b/src/views/system/oauth2/client/index.vue @@ -0,0 +1,191 @@ +<template> + <doc-alert title="OAuth 2.0(SSO 单点登录)" url="https://doc.iocoder.cn/oauth2/" /> + + <!-- 搜索 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="应用名" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入应用名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + plain + type="primary" + @click="openForm('create')" + v-hasPermi="['system:oauth2-client:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="客户端编号" align="center" prop="clientId" /> + <el-table-column label="客户端密钥" align="center" prop="secret" /> + <el-table-column label="应用名" align="center" prop="name" /> + <el-table-column label="应用图标" align="center" prop="logo"> + <template #default="scope"> + <img width="40px" height="40px" :src="scope.row.logo" /> + </template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="访问令牌的有效期" align="center" prop="accessTokenValiditySeconds"> + <template #default="scope">{{ scope.row.accessTokenValiditySeconds }} 秒</template> + </el-table-column> + <el-table-column label="刷新令牌的有效期" align="center" prop="refreshTokenValiditySeconds"> + <template #default="scope">{{ scope.row.refreshTokenValiditySeconds }} 秒</template> + </el-table-column> + <el-table-column label="授权类型" align="center" prop="authorizedGrantTypes"> + <template #default="scope"> + <el-tag + :disable-transitions="true" + :key="index" + v-for="(authorizedGrantType, index) in scope.row.authorizedGrantTypes" + :index="index" + class="mr-5px" + > + {{ authorizedGrantType }} + </el-tag> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:oauth2-client:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['system:oauth2-client:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ClientForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as ClientApi from '@/api/system/oauth2/client' +import ClientForm from './ClientForm.vue' + +defineOptions({ name: 'SystemOAuth2Client' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ClientApi.getOAuth2ClientPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ClientApi.deleteOAuth2Client(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/oauth2/token/index.vue b/src/views/system/oauth2/token/index.vue new file mode 100644 index 0000000..2a94f8e --- /dev/null +++ b/src/views/system/oauth2/token/index.vue @@ -0,0 +1,164 @@ +<template> + <doc-alert title="OAuth 2.0(SSO 单点登录)" url="https://doc.iocoder.cn/oauth2/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="90px" + > + <el-form-item label="用户编号" prop="userId"> + <el-input + v-model="queryParams.userId" + placeholder="请输入用户编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="用户类型" prop="userType"> + <el-select + v-model="queryParams.userType" + placeholder="请选择用户类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="客户端编号" prop="clientId"> + <el-input + v-model="queryParams.clientId" + placeholder="请输入客户端编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="访问令牌" align="center" prop="accessToken" width="300" /> + <el-table-column label="刷新令牌" align="center" prop="refreshToken" width="300" /> + <el-table-column label="用户编号" align="center" prop="userId" /> + <el-table-column label="用户类型" align="center" prop="userType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" /> + </template> + </el-table-column> + <el-table-column + label="过期时间" + align="center" + prop="expiresTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="danger" + @click="handleForceLogout(scope.row.accessToken)" + v-hasPermi="['system:oauth2-token:delete']" + > + 强退 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as OAuth2AccessTokenApi from '@/api/system/oauth2/token' + +defineOptions({ name: 'SystemTokenClient' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: null, + userType: undefined, + clientId: null +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await OAuth2AccessTokenApi.getAccessTokenPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 强制退出操作 */ +const handleForceLogout = async (accessToken: string) => { + try { + // 删除的二次确认 + await message.confirm('是否要强制退出用户') + // 发起删除 + await OAuth2AccessTokenApi.deleteAccessToken(accessToken) + message.success(t('common.success')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/operatelog/OperateLogDetail.vue b/src/views/system/operatelog/OperateLogDetail.vue new file mode 100644 index 0000000..150edc8 --- /dev/null +++ b/src/views/system/operatelog/OperateLogDetail.vue @@ -0,0 +1,68 @@ +<template> + <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="详情" width="800"> + <el-descriptions :column="1" border> + <el-descriptions-item label="日志主键" min-width="120"> + {{ detailData.id }} + </el-descriptions-item> + <el-descriptions-item label="链路追踪" v-if="detailData.traceId"> + {{ detailData.traceId }} + </el-descriptions-item> + <el-descriptions-item label="操作人编号"> + {{ detailData.userId }} + </el-descriptions-item> + <el-descriptions-item label="操作人名字"> + {{ detailData.userName }} + </el-descriptions-item> + <el-descriptions-item label="操作人 IP"> + {{ detailData.userIp }} + </el-descriptions-item> + <el-descriptions-item label="操作人 UA"> + {{ detailData.userAgent }} + </el-descriptions-item> + <el-descriptions-item label="操作模块"> + {{ detailData.type }} + </el-descriptions-item> + <el-descriptions-item label="操作名"> + {{ detailData.subType }} + </el-descriptions-item> + <el-descriptions-item label="操作内容"> + {{ detailData.action }} + </el-descriptions-item> + <el-descriptions-item v-if="detailData.extra" label="操作拓展参数"> + {{ detailData.extra }} + </el-descriptions-item> + <el-descriptions-item label="请求 URL"> + {{ detailData.requestMethod }} {{ detailData.requestUrl }} + </el-descriptions-item> + <el-descriptions-item label="操作时间"> + {{ formatDate(detailData.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="业务编号"> + {{ detailData.bizId }} + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { formatDate } from '@/utils/formatTime' +import * as OperateLogApi from '@/api/system/operatelog' + +defineOptions({ name: 'SystemOperateLogDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({} as OperateLogApi.OperateLogVO) // 详情数据 + +/** 打开弹窗 */ +const open = async (data: OperateLogApi.OperateLogVO) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = data + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/system/operatelog/index.vue b/src/views/system/operatelog/index.vue new file mode 100644 index 0000000..b8a97e1 --- /dev/null +++ b/src/views/system/operatelog/index.vue @@ -0,0 +1,213 @@ +<template> + <doc-alert title="系统日志" url="https://doc.iocoder.cn/system-log/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="操作人" prop="userId"> + <el-select + v-model="queryParams.userId" + clearable + filterable + placeholder="请输入操作人员" + class="!w-240px" + > + <el-option + v-for="user in userList" + :key="user.id" + :label="user.nickname" + :value="user.id" + /> + </el-select> + </el-form-item> + <el-form-item label="操作模块" prop="type"> + <el-input + v-model="queryParams.type" + placeholder="请输入操作模块" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="操作名" prop="subType"> + <el-input + v-model="queryParams.subType" + placeholder="请输入操作名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="操作内容" prop="action"> + <el-input + v-model="queryParams.action" + placeholder="请输入操作名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="操作时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-220px" + /> + </el-form-item> + <el-form-item label="业务编号" prop="bizId"> + <el-input + v-model="queryParams.bizId" + placeholder="请输入业务编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['infra:operate-log:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="日志编号" align="center" prop="id" width="100" /> + <el-table-column label="操作人" align="center" prop="userName" width="120" /> + <el-table-column label="操作模块" align="center" prop="type" width="120" /> + <el-table-column label="操作名" align="center" prop="subType" width="160" /> + <el-table-column label="操作内容" align="center" prop="action" /> + <el-table-column + label="操作时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="业务编号" align="center" prop="bizId" width="120" /> + <el-table-column label="操作 IP" align="center" prop="userIp" width="120" /> + <el-table-column label="操作" align="center" fixed="right" width="60"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openDetail(scope.row)" + v-hasPermi="['infra:operate-log:query']" + > + 详情 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:详情 --> + <OperateLogDetail ref="detailRef" /> +</template> +<script lang="ts" setup> +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as OperateLogApi from '@/api/system/operatelog' +import OperateLogDetail from './OperateLogDetail.vue' +import * as UserApi from '@/api/system/user' +const userList = ref<UserApi.UserVO[]>([]) // 用户列表 + +defineOptions({ name: 'SystemOperateLog' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + userId: undefined, + type: undefined, + subType: undefined, + action: undefined, + createTime: [], + bizId: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await OperateLogApi.getOperateLogPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 详情操作 */ +const detailRef = ref() +const openDetail = (data: OperateLogApi.OperateLogVO) => { + detailRef.value.open(data) +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await OperateLogApi.exportOperateLog(queryParams) + download.excel(data, '操作日志.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 获得用户列表 + userList.value = await UserApi.getSimpleUserList() +}) +</script> diff --git a/src/views/system/post/PostForm.vue b/src/views/system/post/PostForm.vue new file mode 100644 index 0000000..1894e0c --- /dev/null +++ b/src/views/system/post/PostForm.vue @@ -0,0 +1,125 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="800"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="岗位标题" prop="name"> + <el-input v-model="formData.name" placeholder="请输入岗位标题" /> + </el-form-item> + <el-form-item label="岗位编码" prop="code"> + <el-input v-model="formData.code" placeholder="请输入岗位编码" /> + </el-form-item> + <el-form-item label="岗位顺序" prop="sort"> + <el-input v-model="formData.sort" placeholder="请输入岗位顺序" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="formData.status" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输备注" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import * as PostApi from '@/api/system/post' + +defineOptions({ name: 'SystemPostForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + code: '', + sort: 0, + status: CommonStatusEnum.ENABLE, + remark: '' +}) +const formRules = reactive({ + name: [{ required: true, message: '岗位标题不能为空', trigger: 'blur' }], + code: [{ required: true, message: '岗位编码不能为空', trigger: 'change' }], + status: [{ required: true, message: '岗位状态不能为空', trigger: 'change' }], + remark: [{ required: false, message: '岗位内容不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await PostApi.getPost(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as PostApi.PostVO + if (formType.value === 'create') { + await PostApi.createPost(data) + message.success(t('common.createSuccess')) + } else { + await PostApi.updatePost(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + code: '', + sort: undefined, + status: CommonStatusEnum.ENABLE, + remark: '' + } as any + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/post/index.vue b/src/views/system/post/index.vue new file mode 100644 index 0000000..dd06c2a --- /dev/null +++ b/src/views/system/post/index.vue @@ -0,0 +1,201 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="岗位名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入岗位名称" + clearable + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="岗位编码" prop="code"> + <el-input + v-model="queryParams.code" + placeholder="请输入岗位编码" + clearable + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:post:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['system:post:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="岗位编号" align="center" prop="id" /> + <el-table-column label="岗位名称" align="center" prop="name" /> + <el-table-column label="岗位编码" align="center" prop="code" /> + <el-table-column label="岗位顺序" align="center" prop="sort" /> + <el-table-column label="岗位备注" align="center" prop="remark" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:post:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['system:post:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <PostForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as PostApi from '@/api/system/post' +import PostForm from './PostForm.vue' + +defineOptions({ name: 'SystemPost' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + code: '', + name: '', + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询岗位列表 */ +const getList = async () => { + loading.value = true + try { + const data = await PostApi.getPostPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await PostApi.deletePost(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await PostApi.exportPost(queryParams) + download.excel(data, '岗位列表.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/role/RoleAssignMenuForm.vue b/src/views/system/role/RoleAssignMenuForm.vue new file mode 100644 index 0000000..c3294a5 --- /dev/null +++ b/src/views/system/role/RoleAssignMenuForm.vue @@ -0,0 +1,160 @@ +<template> + <Dialog v-model="dialogVisible" title="菜单权限"> + <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="80px"> + <el-form-item label="角色名称"> + <el-tag>{{ formData.name }}</el-tag> + </el-form-item> + <el-form-item label="角色标识"> + <el-tag>{{ formData.code }}</el-tag> + </el-form-item> + <el-form-item label="菜单权限"> + <el-card class="cardHeight"> + <template #header> + 全选/全不选: + <el-switch + v-model="treeNodeAll" + active-text="是" + inactive-text="否" + inline-prompt + @change="handleCheckedTreeNodeAll" + /> + 全部展开/折叠: + <el-switch + v-model="menuExpand" + active-text="展开" + inactive-text="折叠" + inline-prompt + @change="handleCheckedTreeExpand" + /> + </template> + <el-tree + ref="treeRef" + :data="menuOptions" + :props="defaultProps" + empty-text="加载中,请稍候" + node-key="id" + show-checkbox + /> + </el-card> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { defaultProps, handleTree } from '@/utils/tree' +import * as RoleApi from '@/api/system/role' +import * as MenuApi from '@/api/system/menu' +import * as PermissionApi from '@/api/system/permission' + +defineOptions({ name: 'SystemRoleAssignMenuForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = reactive({ + id: undefined, + name: '', + code: '', + menuIds: [] +}) +const formRef = ref() // 表单 Ref +const menuOptions = ref<any[]>([]) // 菜单树形结构 +const menuExpand = ref(false) // 展开/折叠 +const treeRef = ref() // 菜单树组件 Ref +const treeNodeAll = ref(false) // 全选/全不选 + +/** 打开弹窗 */ +const open = async (row: RoleApi.RoleVO) => { + dialogVisible.value = true + resetForm() + // 加载 Menu 列表。注意,必须放在前面,不然下面 setChecked 没数据节点 + menuOptions.value = handleTree(await MenuApi.getSimpleMenusList()) + // 设置数据 + formData.id = row.id + formData.name = row.name + formData.code = row.code + formLoading.value = true + try { + formData.value.menuIds = await PermissionApi.getRoleMenuList(row.id) + // 设置选中 + formData.value.menuIds.forEach((menuId: number) => { + treeRef.value.setChecked(menuId, true, false) + }) + } finally { + formLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = { + roleId: formData.id, + menuIds: [ + ...(treeRef.value.getCheckedKeys(false) as unknown as Array<number>), // 获得当前选中节点 + ...(treeRef.value.getHalfCheckedKeys() as unknown as Array<number>) // 获得半选中的父节点 + ] + } + await PermissionApi.assignRoleMenu(data) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + // 重置选项 + treeNodeAll.value = false + menuExpand.value = false + // 重置表单 + formData.value = { + id: undefined, + name: '', + code: '', + menuIds: [] + } + treeRef.value?.setCheckedNodes([]) + formRef.value?.resetFields() +} + +/** 全选/全不选 */ +const handleCheckedTreeNodeAll = () => { + treeRef.value.setCheckedNodes(treeNodeAll.value ? menuOptions.value : []) +} + +/** 展开/折叠全部 */ +const handleCheckedTreeExpand = () => { + const nodes = treeRef.value?.store.nodesMap + for (let node in nodes) { + if (nodes[node].expanded === menuExpand.value) { + continue + } + nodes[node].expanded = menuExpand.value + } +} +</script> +<style lang="scss" scoped> +.cardHeight { + width: 100%; + max-height: 400px; + overflow-y: scroll; +} +</style> diff --git a/src/views/system/role/RoleDataPermissionForm.vue b/src/views/system/role/RoleDataPermissionForm.vue new file mode 100644 index 0000000..476a623 --- /dev/null +++ b/src/views/system/role/RoleDataPermissionForm.vue @@ -0,0 +1,169 @@ +<template> + <Dialog v-model="dialogVisible" title="菜单权限" width="800"> + <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="80px"> + <el-form-item label="角色名称"> + <el-tag>{{ formData.name }}</el-tag> + </el-form-item> + <el-form-item label="角色标识"> + <el-tag>{{ formData.code }}</el-tag> + </el-form-item> + <el-form-item label="权限范围"> + <el-select v-model="formData.dataScope"> + <el-option + v-for="item in getIntDictOptions(DICT_TYPE.SYSTEM_DATA_SCOPE)" + :key="item.value" + :label="item.label" + :value="item.value" + /> + </el-select> + </el-form-item> + </el-form> + <el-form-item + v-if="formData.dataScope === SystemDataScopeEnum.DEPT_CUSTOM" + label="权限范围" + style="display: flex" + > + <el-card class="card" shadow="never"> + <template #header> + 全选/全不选: + <el-switch + v-model="treeNodeAll" + active-text="是" + inactive-text="否" + inline-prompt + @change="handleCheckedTreeNodeAll()" + /> + 全部展开/折叠: + <el-switch + v-model="deptExpand" + active-text="展开" + inactive-text="折叠" + inline-prompt + @change="handleCheckedTreeExpand" + /> + 父子联动(选中父节点,自动选择子节点): + <el-switch v-model="checkStrictly" active-text="是" inactive-text="否" inline-prompt /> + </template> + <el-tree + ref="treeRef" + :check-strictly="!checkStrictly" + :data="deptOptions" + :props="defaultProps" + default-expand-all + empty-text="加载中,请稍后" + node-key="id" + show-checkbox + /> + </el-card> + </el-form-item> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { defaultProps, handleTree } from '@/utils/tree' +import { SystemDataScopeEnum } from '@/utils/constants' +import * as RoleApi from '@/api/system/role' +import * as DeptApi from '@/api/system/dept' +import * as PermissionApi from '@/api/system/permission' + +defineOptions({ name: 'SystemRoleDataPermissionForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = reactive({ + id: undefined, + name: '', + code: '', + dataScope: undefined, + dataScopeDeptIds: [] +}) +const formRef = ref() // 表单 Ref +const deptOptions = ref<any[]>([]) // 部门树形结构 +const deptExpand = ref(true) // 展开/折叠 +const treeRef = ref() // 菜单树组件 Ref +const treeNodeAll = ref(false) // 全选/全不选 +const checkStrictly = ref(true) // 是否严格模式,即父子不关联 + +/** 打开弹窗 */ +const open = async (row: RoleApi.RoleVO) => { + dialogVisible.value = true + resetForm() + // 加载 Dept 列表。注意,必须放在前面,不然下面 setChecked 没数据节点 + deptOptions.value = handleTree(await DeptApi.getSimpleDeptList()) + // 设置数据 + formData.id = row.id + formData.name = row.name + formData.code = row.code + formData.dataScope = row.dataScope + await nextTick() + // 需要在 DOM 渲染完成后,再设置选中状态 + row.dataScopeDeptIds?.forEach((deptId: number): void => { + treeRef.value.setChecked(deptId, true, false) + }) +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + formLoading.value = true + try { + const data = { + roleId: formData.id, + dataScope: formData.dataScope, + dataScopeDeptIds: + formData.dataScope !== SystemDataScopeEnum.DEPT_CUSTOM + ? [] + : treeRef.value.getCheckedKeys(false) + } + await PermissionApi.assignRoleDataScope(data) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + // 重置选项 + treeNodeAll.value = false + deptExpand.value = true + checkStrictly.value = true + // 重置表单 + formData.value = { + id: undefined, + name: '', + code: '', + dataScope: undefined, + dataScopeDeptIds: [] + } + treeRef.value?.setCheckedNodes([]) + formRef.value?.resetFields() +} + +/** 全选/全不选 */ +const handleCheckedTreeNodeAll = () => { + treeRef.value.setCheckedNodes(treeNodeAll.value ? deptOptions.value : []) +} + +/** 展开/折叠全部 */ +const handleCheckedTreeExpand = () => { + const nodes = treeRef.value?.store.nodesMap + for (let node in nodes) { + if (nodes[node].expanded === deptExpand.value) { + continue + } + nodes[node].expanded = deptExpand.value + } +} +</script> diff --git a/src/views/system/role/RoleForm.vue b/src/views/system/role/RoleForm.vue new file mode 100644 index 0000000..161b757 --- /dev/null +++ b/src/views/system/role/RoleForm.vue @@ -0,0 +1,126 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="角色名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入角色名称" /> + </el-form-item> + <el-form-item label="角色标识" prop="code"> + <el-input v-model="formData.code" placeholder="请输入角色标识" /> + </el-form-item> + <el-form-item label="显示顺序" prop="sort"> + <el-input v-model="formData.sort" placeholder="请输入显示顺序" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="formData.status" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输备注" type="textarea" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import * as RoleApi from '@/api/system/role' + +defineOptions({ name: 'SystemRoleForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: '', + code: '', + sort: undefined, + status: CommonStatusEnum.ENABLE, + remark: '' +}) +const formRules = reactive({ + name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }], + code: [{ required: true, message: '角色标识不能为空', trigger: 'change' }], + sort: [{ required: true, message: '显示顺序不能为空', trigger: 'change' }], + status: [{ required: true, message: '状态不能为空', trigger: 'change' }], + remark: [{ required: false, message: '备注不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await RoleApi.getRole(id) + } finally { + formLoading.value = false + } + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: '', + code: '', + sort: undefined, + status: CommonStatusEnum.ENABLE, + remark: '' + } + formRef.value?.resetFields() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as RoleApi.RoleVO + if (formType.value === 'create') { + await RoleApi.createRole(data) + message.success(t('common.createSuccess')) + } else { + await RoleApi.updateRole(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} +</script> diff --git a/src/views/system/role/index.vue b/src/views/system/role/index.vue new file mode 100644 index 0000000..6af7d0f --- /dev/null +++ b/src/views/system/role/index.vue @@ -0,0 +1,269 @@ +<template> + <doc-alert title="功能权限" url="https://doc.iocoder.cn/resource-permission" /> + <doc-alert title="数据权限" url="https://doc.iocoder.cn/data-permission" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="角色名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入角色名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="角色标识" prop="code"> + <el-input + v-model="queryParams.code" + class="!w-240px" + clearable + placeholder="请输入角色标识" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['system:role:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + <el-button + v-hasPermi="['system:role:export']" + :loading="exportLoading" + plain + type="success" + @click="handleExport" + > + <Icon class="mr-5px" icon="ep:download" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column align="center" label="角色编号" prop="id" /> + <el-table-column align="center" label="角色名称" prop="name" /> + <el-table-column label="角色类型" align="center" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_ROLE_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column align="center" label="角色标识" prop="code" /> + <el-table-column align="center" label="显示顺序" prop="sort" /> + <el-table-column align="center" label="备注" prop="remark" /> + <el-table-column align="center" label="状态" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + <el-table-column :width="300" align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['system:role:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['system:permission:assign-role-menu']" + link + preIcon="ep:basketball" + title="菜单权限" + type="primary" + @click="openAssignMenuForm(scope.row)" + > + 菜单权限 + </el-button> + <el-button + v-hasPermi="['system:permission:assign-role-data-scope']" + link + preIcon="ep:coin" + title="数据权限" + type="primary" + @click="openDataPermissionForm(scope.row)" + > + 数据权限 + </el-button> + <el-button + v-hasPermi="['system:role:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <RoleForm ref="formRef" @success="getList" /> + <!-- 表单弹窗:菜单权限 --> + <RoleAssignMenuForm ref="assignMenuFormRef" @success="getList" /> + <!-- 表单弹窗:数据权限 --> + <RoleDataPermissionForm ref="dataPermissionFormRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as RoleApi from '@/api/system/role' +import RoleForm from './RoleForm.vue' +import RoleAssignMenuForm from './RoleAssignMenuForm.vue' +import RoleDataPermissionForm from './RoleDataPermissionForm.vue' + +defineOptions({ name: 'SystemRole' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + code: '', + name: '', + status: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询角色列表 */ +const getList = async () => { + loading.value = true + try { + const data = await RoleApi.getRolePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 数据权限操作 */ +const dataPermissionFormRef = ref() +const openDataPermissionForm = async (row: RoleApi.RoleVO) => { + dataPermissionFormRef.value.open(row) +} + +/** 菜单权限操作 */ +const assignMenuFormRef = ref() +const openAssignMenuForm = async (row: RoleApi.RoleVO) => { + assignMenuFormRef.value.open(row) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await RoleApi.deleteRole(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await RoleApi.exportRole(queryParams) + download.excel(data, '角色列表.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/sms/channel/SmsChannelForm.vue b/src/views/system/sms/channel/SmsChannelForm.vue new file mode 100644 index 0000000..049868a --- /dev/null +++ b/src/views/system/sms/channel/SmsChannelForm.vue @@ -0,0 +1,144 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="130px" + > + <el-form-item label="短信签名" prop="signature"> + <el-input v-model="formData.signature" placeholder="请输入短信签名" /> + </el-form-item> + <el-form-item label="渠道编码" prop="code"> + <el-select v-model="formData.code" clearable placeholder="请选择渠道编码"> + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="启用状态"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + <el-form-item label="短信 API 的账号" prop="apiKey"> + <el-input v-model="formData.apiKey" placeholder="请输入短信 API 的账号" /> + </el-form-item> + <el-form-item label="短信 API 的密钥" prop="apiSecret"> + <el-input v-model="formData.apiSecret" placeholder="请输入短信 API 的密钥" /> + </el-form-item> + <el-form-item label="短信发送回调 URL" prop="callbackUrl"> + <el-input v-model="formData.callbackUrl" placeholder="请输入短信发送回调 URL" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict' +import * as SmsChannelApi from '@/api/system/sms/smsChannel' +import { CommonStatusEnum } from '@/utils/constants' + +defineOptions({ name: 'SystemSmsChannelForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + signature: '', + code: '', + status: CommonStatusEnum.ENABLE, + remark: '', + apiKey: '', + apiSecret: '', + callbackUrl: '' +}) +const formRules = reactive({ + signature: [{ required: true, message: '短信签名不能为空', trigger: 'blur' }], + code: [{ required: true, message: '渠道编码不能为空', trigger: 'blur' }], + status: [{ required: true, message: '启用状态不能为空', trigger: 'blur' }], + apiKey: [{ required: true, message: '短信 API 的账号不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await SmsChannelApi.getSmsChannel(id) + console.log(formData) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as SmsChannelApi.SmsChannelVO + if (formType.value === 'create') { + await SmsChannelApi.createSmsChannel(data) + message.success(t('common.createSuccess')) + } else { + await SmsChannelApi.updateSmsChannel(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + signature: '', + code: '', + status: CommonStatusEnum.ENABLE, + remark: '', + apiKey: '', + apiSecret: '', + callbackUrl: '' + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/sms/channel/index.vue b/src/views/system/sms/channel/index.vue new file mode 100644 index 0000000..342a991 --- /dev/null +++ b/src/views/system/sms/channel/index.vue @@ -0,0 +1,207 @@ +<template> + <doc-alert title="短信配置" url="https://doc.iocoder.cn/sms/" /> + + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="短信签名" prop="signature"> + <el-input + v-model="queryParams.signature" + placeholder="请输入短信签名" + clearable + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="启用状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择启用状态" + class="!w-240px" + clearable + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:sms-channel:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增</el-button + > + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="短信签名" align="center" prop="signature" /> + <el-table-column label="渠道编码" align="center" prop="code"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE" :value="scope.row.code" /> + </template> + </el-table-column> + <el-table-column label="启用状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" /> + <el-table-column + label="短信 API 的账号" + align="center" + prop="apiKey" + :show-overflow-tooltip="true" + width="180" + /> + <el-table-column + label="短信 API 的密钥" + align="center" + prop="apiSecret" + :show-overflow-tooltip="true" + width="180" + /> + <el-table-column + label="短信发送回调 URL" + align="center" + prop="callbackUrl" + :show-overflow-tooltip="true" + width="180" + /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:sms-channel:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['system:sms-channel:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SmsChannelForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as SmsChannelApi from '@/api/system/sms/smsChannel' +import SmsChannelForm from './SmsChannelForm.vue' + +defineOptions({ name: 'SystemSmsChannel' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const loading = ref(false) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryFormRef = ref() // 搜索的表单 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + signature: undefined, + status: undefined, + createTime: [] +}) + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SmsChannelApi.getSmsChannelPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await SmsChannelApi.deleteSmsChannel(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/sms/log/SmsLogDetail.vue b/src/views/system/sms/log/SmsLogDetail.vue new file mode 100644 index 0000000..b0d22c2 --- /dev/null +++ b/src/views/system/sms/log/SmsLogDetail.vue @@ -0,0 +1,86 @@ +<template> + <Dialog v-model="dialogVisible" :max-height="500" :scroll="true" title="详情" width="800"> + <el-descriptions :column="1" border> + <el-descriptions-item label="日志主键" min-width="120"> + {{ detailData.id }} + </el-descriptions-item> + <el-descriptions-item label="短信渠道"> + {{ channelList.find((channel) => channel.id === detailData.channelId)?.signature }} + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE" :value="detailData.channelCode" /> + </el-descriptions-item> + <el-descriptions-item label="短信模板"> + {{ detailData.templateId }} | {{ detailData.templateCode }} + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE" :value="detailData.templateType" /> + </el-descriptions-item> + <el-descriptions-item label="API 的模板编号"> + {{ detailData.apiTemplateId }} + </el-descriptions-item> + <el-descriptions-item label="用户信息"> + {{ detailData.mobile }} + <span v-if="detailData.userType && detailData.userId"> + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="detailData.userType" /> + ({{ detailData.userId }}) + </span> + </el-descriptions-item> + <el-descriptions-item label="短信内容"> + {{ detailData.templateContent }} + </el-descriptions-item> + <el-descriptions-item label="短信参数"> + {{ detailData.templateParams }} + </el-descriptions-item> + <el-descriptions-item label="创建时间"> + {{ formatDate(detailData.createTime) }} + </el-descriptions-item> + <el-descriptions-item label="发送状态"> + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_SEND_STATUS" :value="detailData.sendStatus" /> + </el-descriptions-item> + <el-descriptions-item label="发送时间"> + {{ formatDate(detailData.sendTime) }} + </el-descriptions-item> + <el-descriptions-item label="API 发送结果"> + {{ detailData.apiSendCode }} | {{ detailData.apiSendMsg }} + </el-descriptions-item> + <el-descriptions-item label="API 短信编号"> + {{ detailData.apiSerialNo }} + </el-descriptions-item> + <el-descriptions-item label="API 请求编号"> + {{ detailData.apiRequestId }} + </el-descriptions-item> + <el-descriptions-item label="API 接收状态"> + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_RECEIVE_STATUS" :value="detailData.receiveStatus" /> + {{ formatDate(detailData.receiveTime) }} + </el-descriptions-item> + <el-descriptions-item label="API 接收结果"> + {{ detailData.apiReceiveCode }} | {{ detailData.apiReceiveMsg }} + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import { formatDate } from '@/utils/formatTime' +import * as SmsLogApi from '@/api/system/sms/smsLog' +import * as SmsChannelApi from '@/api/system/sms/smsChannel' + +defineOptions({ name: 'SystemSmsLogDetail' }) + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref() // 详情数据 +const channelList = ref([]) // 短信渠道列表 + +/** 打开弹窗 */ +const open = async (data: SmsLogApi.SmsLogVO) => { + dialogVisible.value = true + // 设置数据 + detailLoading.value = true + try { + detailData.value = data + } finally { + detailLoading.value = false + } + // 加载渠道列表 + channelList.value = await SmsChannelApi.getSimpleSmsChannelList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/system/sms/log/index.vue b/src/views/system/sms/log/index.vue new file mode 100644 index 0000000..e1a5a23 --- /dev/null +++ b/src/views/system/sms/log/index.vue @@ -0,0 +1,268 @@ +<template> + <doc-alert title="短信配置" url="https://doc.iocoder.cn/sms/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="100px" + > + <el-form-item label="手机号" prop="mobile"> + <el-input + v-model="queryParams.mobile" + placeholder="请输入手机号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="短信渠道" prop="channelId"> + <el-select + v-model="queryParams.channelId" + placeholder="请选择短信渠道" + clearable + class="!w-240px" + > + <el-option + v-for="channel in channelList" + :key="channel.id" + :value="channel.id" + :label=" + channel.signature + + `【 ${getDictLabel(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE, channel.code)}】` + " + /> + </el-select> + </el-form-item> + <el-form-item label="模板编号" prop="templateId"> + <el-input + v-model="queryParams.templateId" + placeholder="请输入模板编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="发送状态" prop="sendStatus"> + <el-select + v-model="queryParams.sendStatus" + placeholder="请选择发送状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SMS_SEND_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="发送时间" prop="sendTime"> + <el-date-picker + v-model="queryParams.sendTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="接收状态" prop="receiveStatus"> + <el-select + v-model="queryParams.receiveStatus" + placeholder="请选择接收状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SMS_RECEIVE_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="接收时间" prop="receiveTime"> + <el-date-picker + v-model="queryParams.receiveTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['system:sms-log:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="手机号" align="center" prop="mobile" width="120"> + <template #default="scope"> + <div>{{ scope.row.mobile }}</div> + <div v-if="scope.row.userType && scope.row.userId"> + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" /> + {{ '(' + scope.row.userId + ')' }} + </div> + </template> + </el-table-column> + <el-table-column label="短信内容" align="center" prop="templateContent" width="300" /> + <el-table-column label="发送状态" align="center" width="180"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_SEND_STATUS" :value="scope.row.sendStatus" /> + <div>{{ formatDate(scope.row.sendTime) }}</div> + </template> + </el-table-column> + <el-table-column label="接收状态" align="center" width="180"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_RECEIVE_STATUS" :value="scope.row.receiveStatus" /> + <div>{{ formatDate(scope.row.receiveTime) }}</div> + </template> + </el-table-column> + <el-table-column label="短信渠道" align="center" width="120"> + <template #default="scope"> + <div> + {{ channelList.find((channel) => channel.id === scope.row.channelId)?.signature }} + </div> + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE" :value="scope.row.channelCode" /> + </template> + </el-table-column> + <el-table-column label="模板编号" align="center" prop="templateId" /> + <el-table-column label="短信类型" align="center" prop="templateType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE" :value="scope.row.templateType" /> + </template> + </el-table-column> + <el-table-column label="操作" align="center" fixed="right" class-name="fixed-width"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openDetail(scope.row)" + v-hasPermi="['system:sms-log:query']" + > + 详情 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:详情 --> + <SmsLogDetail ref="detailRef" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions, getDictLabel } from '@/utils/dict' +import { dateFormatter, formatDate } from '@/utils/formatTime' +import download from '@/utils/download' +import * as SmsChannelApi from '@/api/system/sms/smsChannel' +import * as SmsLogApi from '@/api/system/sms/smsLog' +import SmsLogDetail from './SmsLogDetail.vue' + +defineOptions({ name: 'SystemSmsLog' }) + +const message = useMessage() // 消息弹窗 + +const loading = ref(false) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryFormRef = ref() // 搜索的表单 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + channelId: null, + templateId: null, + mobile: '', + sendStatus: null, + receiveStatus: null, + sendTime: [], + receiveTime: [] +}) +const exportLoading = ref(false) // 导出的加载中 +const channelList = ref([]) // 短信渠道列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SmsLogApi.getSmsLogPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await SmsLogApi.exportSmsLog(queryParams) + download.excel(data, '短信日志.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 详情操作 */ +const detailRef = ref() +const openDetail = (data: SmsLogApi.SmsLogVO) => { + detailRef.value.open(data) +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载渠道列表 + channelList.value = await SmsChannelApi.getSimpleSmsChannelList() +}) +</script> diff --git a/src/views/system/sms/template/SmsTemplateForm.vue b/src/views/system/sms/template/SmsTemplateForm.vue new file mode 100644 index 0000000..9750e8a --- /dev/null +++ b/src/views/system/sms/template/SmsTemplateForm.vue @@ -0,0 +1,163 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="140px" + > + <el-form-item label="短信渠道编号" prop="channelId"> + <el-select v-model="formData.channelId" placeholder="请选择短信渠道编号"> + <el-option + v-for="channel in channelList" + :key="channel.id" + :label=" + channel.signature + + `【 ${getDictLabel(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE, channel.code)}】` + " + :value="channel.id" + /> + </el-select> + </el-form-item> + <el-form-item label="短信类型" prop="type"> + <el-select v-model="formData.type" placeholder="请选择短信类型"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="模板编号" prop="code"> + <el-input v-model="formData.code" placeholder="请输入模板编号" /> + </el-form-item> + <el-form-item label="模板名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入模板名称" /> + </el-form-item> + <el-form-item label="模板内容" prop="content"> + <el-input v-model="formData.content" placeholder="请输入模板内容" type="textarea" /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="短信 API 模板编号" prop="apiTemplateId"> + <el-input v-model="formData.apiTemplateId" placeholder="请输入短信 API 的模板编号" /> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getDictLabel, getIntDictOptions } from '@/utils/dict' +import * as SmsTemplateApi from '@/api/system/sms/smsTemplate' +import * as SmsChannelApi from '@/api/system/sms/smsChannel' +import { CommonStatusEnum } from '@/utils/constants' + +defineOptions({ name: 'SystemSmsTemplateForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型 +const formData = ref<SmsTemplateApi.SmsTemplateVO>({ + id: undefined, + type: undefined, + status: CommonStatusEnum.ENABLE, + code: '', + name: '', + content: '', + remark: '', + apiTemplateId: '', + channelId: undefined +}) +const formRules = reactive({ + type: [{ required: true, message: '短信类型不能为空', trigger: 'change' }], + status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }], + code: [{ required: true, message: '模板编码不能为空', trigger: 'blur' }], + name: [{ required: true, message: '模板名称不能为空', trigger: 'blur' }], + content: [{ required: true, message: '模板内容不能为空', trigger: 'blur' }], + apiTemplateId: [{ required: true, message: '短信 API 的模板编号不能为空', trigger: 'blur' }], + channelId: [{ required: true, message: '短信渠道编号不能为空', trigger: 'change' }] +}) +const formRef = ref() // 表单 Ref +const channelList = ref<SmsChannelApi.SmsChannelVO[]>([]) // 短信渠道列表 + +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await SmsTemplateApi.getSmsTemplate(id) + } finally { + formLoading.value = false + } + } + // 加载渠道列表 + channelList.value = await SmsChannelApi.getSimpleSmsChannelList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + formLoading.value = true + try { + const data = formData.value as SmsTemplateApi.SmsTemplateVO + if (formType.value === 'create') { + await SmsTemplateApi.createSmsTemplate(data) + message.success(t('common.createSuccess')) + } else { + await SmsTemplateApi.updateSmsTemplate(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + type: undefined, + status: CommonStatusEnum.ENABLE, + code: '', + name: '', + content: '', + remark: '', + apiTemplateId: '', + channelId: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/sms/template/SmsTemplateSendForm.vue b/src/views/system/sms/template/SmsTemplateSendForm.vue new file mode 100644 index 0000000..b73ec41 --- /dev/null +++ b/src/views/system/sms/template/SmsTemplateSendForm.vue @@ -0,0 +1,120 @@ +<template> + <Dialog v-model="dialogVisible" title="测试"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="140px" + > + <el-form-item label="模板内容" prop="content"> + <el-input + v-model="formData.content" + placeholder="请输入模板内容" + readonly + type="textarea" + /> + </el-form-item> + <el-form-item label="手机号" prop="mobile"> + <el-input v-model="formData.mobile" placeholder="请输入手机号" /> + </el-form-item> + <el-form-item + v-for="param in formData.params" + :key="param" + :label="'参数 {' + param + '}'" + :prop="'templateParams.' + param" + > + <el-input + v-model="formData.templateParams[param]" + :placeholder="'请输入 ' + param + ' 参数'" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as SmsTemplateApi from '@/api/system/sms/smsTemplate' + +defineOptions({ name: 'SystemSmsTemplateSendForm' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 + +// 发送短信表单相关 +const formData = ref({ + content: '', + params: {}, + mobile: '', + templateCode: '', + templateParams: new Map() +}) +const formRules = reactive({ + mobile: [{ required: true, message: '手机不能为空', trigger: 'blur' }], + templateCode: [{ required: true, message: '模版编码不能为空', trigger: 'blur' }], + templateParams: {} +}) +const formRef = ref() // 表单 Ref + +const open = async (id: number) => { + dialogVisible.value = true + resetForm() + // 设置数据 + formLoading.value = true + try { + const data = await SmsTemplateApi.getSmsTemplate(id) + // 设置动态表单 + formData.value.content = data.content + formData.value.params = data.params + formData.value.templateCode = data.code + formData.value.templateParams = data.params.reduce((obj, item) => { + obj[item] = '' // 给每个动态属性赋值,避免无法读取 + return obj + }, {}) + formRules.templateParams = data.params.reduce((obj, item) => { + obj[item] = { required: true, message: '参数 ' + item + ' 不能为空', trigger: 'blur' } + return obj + }, {}) + } finally { + formLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as SmsTemplateApi.SendSmsReqVO + const logId = await SmsTemplateApi.sendSms(data) + if (logId) { + message.success('提交发送成功!发送结果,见发送日志编号:' + logId) + } + dialogVisible.value = false + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + content: '', + params: {}, + mobile: '', + templateCode: '', + templateParams: new Map() + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/sms/template/index.vue b/src/views/system/sms/template/index.vue new file mode 100644 index 0000000..a59c48d --- /dev/null +++ b/src/views/system/sms/template/index.vue @@ -0,0 +1,316 @@ +<template> + <doc-alert title="短信配置" url="https://doc.iocoder.cn/sms/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="150px" + > + <el-form-item label="短信类型" prop="type"> + <el-select + v-model="queryParams.type" + placeholder="请选择短信类型" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择开启状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="模板编码" prop="code"> + <el-input + v-model="queryParams.code" + placeholder="请输入模板编码" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="短信 API 的模板编号" prop="apiTemplateId"> + <el-input + v-model="queryParams.apiTemplateId" + placeholder="请输入短信 API 的模板编号" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="短信渠道" prop="channelId"> + <el-select + v-model="queryParams.channelId" + placeholder="请选择短信渠道" + clearable + class="!w-240px" + > + <el-option + v-for="channel in channelList" + :key="channel.id" + :value="channel.id" + :label=" + channel.signature + + `【 ${getDictLabel(DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE, channel.code)}】` + " + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + style="width: 240px" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + start-placeholder="开始日期" + end-placeholder="结束日期" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:sms-template:create']" + > + <Icon icon="ep:plus" class="mr-5px" />新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['system:sms-template:export']" + > + <Icon icon="ep:download" class="mr-5px" /> 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column + label="模板编码" + align="center" + prop="code" + width="120" + :show-overflow-tooltip="true" + /> + <el-table-column + label="模板名称" + align="center" + prop="name" + width="120" + :show-overflow-tooltip="true" + /> + <el-table-column + label="模板内容" + align="center" + prop="content" + width="200" + :show-overflow-tooltip="true" + /> + <el-table-column label="短信类型" align="center" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_TEMPLATE_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status" width="80"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column + label="短信 API 的模板编号" + align="center" + prop="apiTemplateId" + width="200" + :show-overflow-tooltip="true" + /> + <el-table-column label="短信渠道" align="center" width="120"> + <template #default="scope"> + <div> + {{ channelList.find((channel) => channel.id === scope.row.channelId)?.signature }} + </div> + <dict-tag :type="DICT_TYPE.SYSTEM_SMS_CHANNEL_CODE" :value="scope.row.channelCode" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center" width="210" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:sms-template:update']" + > + 修改 + </el-button> + <el-button + link + type="primary" + @click="openSendForm(scope.row.id)" + v-hasPermi="['system:sms-template:send-sms']" + > + 测试 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['system:sms-template:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SmsTemplateForm ref="formRef" @success="getList" /> + <!-- 表单弹窗:测试发送 --> + <SmsTemplateSendForm ref="sendFormRef" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions, getDictLabel } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as SmsTemplateApi from '@/api/system/sms/smsTemplate' +import * as SmsChannelApi from '@/api/system/sms/smsChannel' +import download from '@/utils/download' +import SmsTemplateForm from './SmsTemplateForm.vue' +import SmsTemplateSendForm from './SmsTemplateSendForm.vue' + +defineOptions({ name: 'SystemSmsTemplate' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(false) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryFormRef = ref() // 搜索的表单 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + type: undefined, + status: undefined, + code: '', + content: '', + apiTemplateId: '', + channelId: undefined, + createTime: [] +}) +const exportLoading = ref(false) // 导出的加载中 +const channelList = ref<SmsChannelApi.SmsChannelVO[]>([]) // 短信渠道列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SmsTemplateApi.getSmsTemplatePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 发送短信按钮 */ +const sendFormRef = ref() +const openSendForm = (id: number) => { + sendFormRef.value.open(id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await SmsTemplateApi.deleteSmsTemplate(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await SmsTemplateApi.exportSmsTemplate(queryParams) + download.excel(data, '短信模板.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + // 加载渠道列表 + channelList.value = await SmsChannelApi.getSimpleSmsChannelList() +}) +</script> diff --git a/src/views/system/social/client/SocialClientForm.vue b/src/views/system/social/client/SocialClientForm.vue new file mode 100644 index 0000000..e6f92bd --- /dev/null +++ b/src/views/system/social/client/SocialClientForm.vue @@ -0,0 +1,154 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="120px" + > + <el-form-item label="应用名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入应用名" /> + </el-form-item> + <el-form-item label="社交平台" prop="socialType"> + <el-radio-group v-model="formData.socialType"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="用户类型" prop="userType"> + <el-radio-group v-model="formData.userType"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="客户端编号" prop="clientId"> + <el-input v-model="formData.clientId" placeholder="请输入客户端编号,对应各平台的appKey" /> + </el-form-item> + <el-form-item label="客户端密钥" prop="clientSecret"> + <el-input + v-model="formData.clientSecret" + placeholder="请输入客户端密钥,对应各平台的appSecret" + /> + </el-form-item> + <el-form-item label="agentId" prop="agentId" v-if="formData!.socialType === 30"> + <el-input v-model="formData.agentId" placeholder="授权方的网页应用 ID,有则填" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as SocialClientApi from '@/api/system/social/client' + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + socialType: undefined, + userType: undefined, + clientId: undefined, + clientSecret: undefined, + agentId: undefined, + status: 0 +}) +const formRules = reactive({ + name: [{ required: true, message: '应用名不能为空', trigger: 'blur' }], + socialType: [{ required: true, message: '社交平台不能为空', trigger: 'blur' }], + userType: [{ required: true, message: '用户类型不能为空', trigger: 'blur' }], + clientId: [{ required: true, message: '客户端编号不能为空', trigger: 'blur' }], + clientSecret: [{ required: true, message: '客户端密钥不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await SocialClientApi.getSocialClient(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as SocialClientApi.SocialClientVO + if (formType.value === 'create') { + await SocialClientApi.createSocialClient(data) + message.success(t('common.createSuccess')) + } else { + await SocialClientApi.updateSocialClient(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + socialType: undefined, + userType: undefined, + clientId: undefined, + clientSecret: undefined, + agentId: undefined, + status: 0 + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/social/client/index.vue b/src/views/system/social/client/index.vue new file mode 100644 index 0000000..fa49018 --- /dev/null +++ b/src/views/system/social/client/index.vue @@ -0,0 +1,227 @@ +<template> + <doc-alert title="三方登录" url="https://doc.iocoder.cn/social-user/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="130px" + > + <el-form-item label="应用名" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入应用名" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="社交平台" prop="socialType"> + <el-select + v-model="queryParams.socialType" + class="!w-240px" + clearable + placeholder="请选择社交平台" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="用户类型" prop="userType"> + <el-select + v-model="queryParams.userType" + class="!w-240px" + clearable + placeholder="请选择用户类型" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="客户端编号" prop="clientId"> + <el-input + v-model="queryParams.clientId" + class="!w-240px" + clearable + placeholder="请输入客户端编号" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['system:social-client:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="编号" prop="id" /> + <el-table-column align="center" label="应用名" prop="name" /> + <el-table-column align="center" label="社交平台" prop="socialType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="scope.row.socialType" /> + </template> + </el-table-column> + <el-table-column align="center" label="用户类型" prop="userType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" /> + </template> + </el-table-column> + <el-table-column align="center" label="客户端编号" prop="clientId" width="180px" /> + <el-table-column align="center" label="状态" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['system:social-client:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['system:social-client:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SocialClientForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as SocialClientApi from '@/api/system/social/client' +import SocialClientForm from './SocialClientForm.vue' + +defineOptions({ name: 'SocialClient' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + socialType: undefined, + userType: undefined, + clientId: undefined, + status: undefined +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SocialClientApi.getSocialClientPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await SocialClientApi.deleteSocialClient(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/social/user/SocialUserDetail.vue b/src/views/system/social/user/SocialUserDetail.vue new file mode 100644 index 0000000..aef9d45 --- /dev/null +++ b/src/views/system/social/user/SocialUserDetail.vue @@ -0,0 +1,60 @@ +<template> + <Dialog v-model="dialogVisible" title="详情" width="800"> + <el-descriptions :column="1" border> + <el-descriptions-item label="社交平台" min-width="160"> + <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="detailData.type" /> + </el-descriptions-item> + <el-descriptions-item label="用户昵称" min-width="120"> + {{ detailData.nickname }} + </el-descriptions-item> + <el-descriptions label="用户头像" min-width="120"> + <el-image :src="detailData.avatar" class="h-30px w-30px" /> + </el-descriptions> + <el-descriptions-item label="社交 token" min-width="120"> + {{ detailData.token }} + </el-descriptions-item> + <el-descriptions-item label="原始 Token 数据" min-width="120"> + <el-input + v-model="detailData.rawTokenInfo" + :autosize="{ maxRows: 20 }" + :readonly="true" + type="textarea" + /> + </el-descriptions-item> + <el-descriptions-item label="原始 User 数据" min-width="120"> + <el-input + v-model="detailData.rawUserInfo" + :autosize="{ maxRows: 20 }" + :readonly="true" + type="textarea" + /> + </el-descriptions-item> + <el-descriptions-item label="最后一次的认证 code" min-width="120"> + {{ detailData.code }} + </el-descriptions-item> + <el-descriptions-item label="最后一次的认证 state" min-width="120"> + {{ detailData.state }} + </el-descriptions-item> + </el-descriptions> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE } from '@/utils/dict' +import * as SocialUserApi from '@/api/system/social/user' + +const dialogVisible = ref(false) // 弹窗的是否展示 +const detailLoading = ref(false) // 表单的加载中 +const detailData = ref({} as SocialUserApi.SocialUserVO) // 详情数据 + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + // 设置数据 + try { + detailData.value = await SocialUserApi.getSocialUser(id) + } finally { + detailLoading.value = false + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 +</script> diff --git a/src/views/system/social/user/index.vue b/src/views/system/social/user/index.vue new file mode 100644 index 0000000..dda9eb8 --- /dev/null +++ b/src/views/system/social/user/index.vue @@ -0,0 +1,187 @@ +<template> + <doc-alert title="三方登录" url="https://doc.iocoder.cn/social-user/" /> + + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="120px" + > + <el-form-item label="社交平台" prop="type"> + <el-select + v-model="queryParams.type" + class="!w-240px" + clearable + placeholder="请选择社交平台" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_SOCIAL_TYPE)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="用户昵称" prop="nickname"> + <el-input + v-model="queryParams.nickname" + class="!w-240px" + clearable + placeholder="请输入用户昵称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="社交 openid" prop="openid"> + <el-input + v-model="queryParams.openid" + class="!w-240px" + clearable + placeholder="请输入社交 openid" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="社交平台" prop="type"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.SYSTEM_SOCIAL_TYPE" :value="scope.row.type" /> + </template> + </el-table-column> + <el-table-column align="center" label="社交 openid" prop="openid" /> + <el-table-column align="center" label="用户昵称" prop="nickname" /> + <el-table-column align="center" label="用户头像" prop="avatar"> + <template #default="{ row }"> + <el-image :src="row.avatar" class="h-30px w-30px" @click="imagePreview(row.avatar)" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="更新时间" + prop="updateTime" + width="180px" + /> + <el-table-column align="center" fixed="right" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['system:social-user:query']" + link + type="primary" + @click="openDetail(scope.row.id)" + > + 详情 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:详情 --> + <SocialUserDetail ref="detailRef" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as SocialUserApi from '@/api/system/social/user' +import SocialUserDetail from './SocialUserDetail.vue' +import { createImageViewer } from '@/components/ImageViewer' + +defineOptions({ name: 'SocialUser' }) + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + type: undefined, + openid: undefined, + nickname: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await SocialUserApi.getSocialUserPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} + +/** 详情操作 */ +const detailRef = ref() +const openDetail = (id: number) => { + detailRef.value.open(id) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/tenant/TenantForm.vue b/src/views/system/tenant/TenantForm.vue new file mode 100644 index 0000000..4d5fde5 --- /dev/null +++ b/src/views/system/tenant/TenantForm.vue @@ -0,0 +1,183 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="50%"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="租户名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入租户名" /> + </el-form-item> + <el-form-item label="租户套餐" prop="packageId"> + <el-select v-model="formData.packageId" clearable placeholder="请选择租户套餐"> + <el-option + v-for="item in packageList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="联系人" prop="contactName"> + <el-input v-model="formData.contactName" placeholder="请输入联系人" /> + </el-form-item> + <el-form-item label="联系手机" prop="contactMobile"> + <el-input v-model="formData.contactMobile" placeholder="请输入联系手机" /> + </el-form-item> + <el-form-item v-if="formData.id === undefined" label="用户名称" prop="username"> + <el-input v-model="formData.username" placeholder="请输入用户名称" /> + </el-form-item> + <el-form-item v-if="formData.id === undefined" label="用户密码" prop="password"> + <el-input + v-model="formData.password" + placeholder="请输入用户密码" + show-password + type="password" + /> + </el-form-item> + <el-form-item label="账号额度" prop="accountCount"> + <el-input-number + v-model="formData.accountCount" + :min="0" + controls-position="right" + placeholder="请输入账号额度" + /> + </el-form-item> + <el-form-item label="过期时间" prop="expireTime"> + <el-date-picker + v-model="formData.expireTime" + clearable + placeholder="请选择过期时间" + type="date" + value-format="x" + /> + </el-form-item> + <el-form-item label="绑定域名" prop="website"> + <el-input v-model="formData.website" placeholder="请输入绑定域名" /> + </el-form-item> + <el-form-item label="租户状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as TenantApi from '@/api/system/tenant' +import { CommonStatusEnum } from '@/utils/constants' +import * as TenantPackageApi from '@/api/system/tenantPackage' + +defineOptions({ name: 'SystemTenantForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + packageId: undefined, + contactName: undefined, + contactMobile: undefined, + accountCount: undefined, + expireTime: undefined, + website: undefined, + status: CommonStatusEnum.ENABLE, + // 新增专属 + username: undefined, + password: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '租户名不能为空', trigger: 'blur' }], + packageId: [{ required: true, message: '租户套餐不能为空', trigger: 'blur' }], + contactName: [{ required: true, message: '联系人不能为空', trigger: 'blur' }], + status: [{ required: true, message: '租户状态不能为空', trigger: 'blur' }], + accountCount: [{ required: true, message: '账号额度不能为空', trigger: 'blur' }], + expireTime: [{ required: true, message: '过期时间不能为空', trigger: 'blur' }], + website: [{ required: true, message: '绑定域名不能为空', trigger: 'blur' }], + username: [{ required: true, message: '用户名称不能为空', trigger: 'blur' }], + password: [{ required: true, message: '用户密码不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const packageList = ref([] as TenantPackageApi.TenantPackageVO[]) // 租户套餐 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await TenantApi.getTenant(id) + } finally { + formLoading.value = false + } + } + // 加载套餐列表 + packageList.value = await TenantPackageApi.getTenantPackageList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as TenantApi.TenantVO + if (formType.value === 'create') { + await TenantApi.createTenant(data) + message.success(t('common.createSuccess')) + } else { + await TenantApi.updateTenant(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + packageId: undefined, + contactName: undefined, + contactMobile: undefined, + accountCount: undefined, + expireTime: undefined, + website: undefined, + status: CommonStatusEnum.ENABLE, + username: undefined, + password: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/tenant/index.vue b/src/views/system/tenant/index.vue new file mode 100644 index 0000000..ae9a2b7 --- /dev/null +++ b/src/views/system/tenant/index.vue @@ -0,0 +1,266 @@ +<template> + <doc-alert title="SaaS 多租户" url="https://doc.iocoder.cn/saas-tenant/" /> + + <!-- 搜索 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="租户名" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入租户名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="联系人" prop="contactName"> + <el-input + v-model="queryParams.contactName" + placeholder="请输入联系人" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="联系手机" prop="contactMobile"> + <el-input + v-model="queryParams.contactMobile" + placeholder="请输入联系手机" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="租户状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择租户状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + + <el-form-item> + <el-button @click="handleQuery"> + <Icon icon="ep:search" class="mr-5px" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon icon="ep:refresh" class="mr-5px" /> + 重置 + </el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:tenant:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> + 新增 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['system:tenant:export']" + > + <Icon icon="ep:download" class="mr-5px" /> + 导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="租户编号" align="center" prop="id" /> + <el-table-column label="租户名" align="center" prop="name" /> + <el-table-column label="租户套餐" align="center" prop="packageId"> + <template #default="scope"> + <el-tag v-if="scope.row.packageId === 0" type="danger">系统租户</el-tag> + <template v-else v-for="item in packageList"> + <el-tag type="success" :key="item.id" v-if="item.id === scope.row.packageId"> + {{ item.name }} + </el-tag> + </template> + </template> + </el-table-column> + <el-table-column label="联系人" align="center" prop="contactName" /> + <el-table-column label="联系手机" align="center" prop="contactMobile" /> + <el-table-column label="账号额度" align="center" prop="accountCount"> + <template #default="scope"> + <el-tag>{{ scope.row.accountCount }}</el-tag> + </template> + </el-table-column> + <el-table-column + label="过期时间" + align="center" + prop="expireTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="绑定域名" align="center" prop="website" width="180" /> + <el-table-column label="租户状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center" min-width="110" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:tenant:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['system:tenant:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <TenantForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import * as TenantApi from '@/api/system/tenant' +import * as TenantPackageApi from '@/api/system/tenantPackage' +import TenantForm from './TenantForm.vue' + +defineOptions({ name: 'SystemTenant' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + contactName: undefined, + contactMobile: undefined, + status: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +const packageList = ref([] as TenantPackageApi.TenantPackageVO[]) //租户套餐列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await TenantApi.getTenantPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await TenantApi.deleteTenant(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 导出按钮操作 */ +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await TenantApi.exportTenant(queryParams) + download.excel(data, '租户列表.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 初始化 **/ +onMounted(async () => { + await getList() + packageList.value = await TenantPackageApi.getTenantPackageList() +}) +</script> diff --git a/src/views/system/tenantPackage/TenantPackageForm.vue b/src/views/system/tenantPackage/TenantPackageForm.vue new file mode 100644 index 0000000..7492889 --- /dev/null +++ b/src/views/system/tenantPackage/TenantPackageForm.vue @@ -0,0 +1,194 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-form-item label="套餐名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入套餐名" /> + </el-form-item> + <el-form-item label="菜单权限"> + <el-card class="cardHeight"> + <template #header> + 全选/全不选: + <el-switch + v-model="treeNodeAll" + active-text="是" + inactive-text="否" + inline-prompt + @change="handleCheckedTreeNodeAll" + /> + 全部展开/折叠: + <el-switch + v-model="menuExpand" + active-text="展开" + inactive-text="折叠" + inline-prompt + @change="handleCheckedTreeExpand" + /> + </template> + <el-tree + ref="treeRef" + :data="menuOptions" + :props="defaultProps" + empty-text="加载中,请稍候" + node-key="id" + show-checkbox + /> + </el-card> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import { defaultProps, handleTree } from '@/utils/tree' +import * as TenantPackageApi from '@/api/system/tenantPackage' +import * as MenuApi from '@/api/system/menu' +import { ElTree } from 'element-plus' + +defineOptions({ name: 'SystemTenantPackageForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: null, + name: null, + remark: null, + menuIds: [], + status: CommonStatusEnum.ENABLE +}) +const formRules = reactive({ + name: [{ required: true, message: '套餐名不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }], + menuIds: [{ required: true, message: '关联的菜单编号不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const menuOptions = ref<any[]>([]) // 树形结构数据 +const menuExpand = ref(false) // 展开/折叠 +const treeRef = ref<InstanceType<typeof ElTree>>() // 树组件 Ref +const treeNodeAll = ref(false) // 全选/全不选 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 加载 Menu 列表。注意,必须放在前面,不然下面 setChecked 没数据节点 + menuOptions.value = handleTree(await MenuApi.getSimpleMenusList()) + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const res = await TenantPackageApi.getTenantPackage(id) + // 设置选中 + formData.value = res + // 设置选中 + res.menuIds.forEach((menuId: number) => { + treeRef.value!.setChecked(menuId, true, false) + }) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as TenantPackageApi.TenantPackageVO + data.menuIds = [ + ...(treeRef.value!.getCheckedKeys(false) as unknown as Array<number>), // 获得当前选中节点 + ...(treeRef.value!.getHalfCheckedKeys() as unknown as Array<number>) // 获得半选中的父节点 + ] + if (formType.value === 'create') { + await TenantPackageApi.createTenantPackage(data) + message.success(t('common.createSuccess')) + } else { + await TenantPackageApi.updateTenantPackage(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + // 重置选项 + treeNodeAll.value = false + menuExpand.value = false + // 重置表单 + formData.value = { + id: null, + name: null, + remark: null, + menuIds: [], + status: CommonStatusEnum.ENABLE + } + treeRef.value?.setCheckedNodes([]) + formRef.value?.resetFields() +} + +/** 全选/全不选 */ +const handleCheckedTreeNodeAll = () => { + treeRef.value!.setCheckedNodes(treeNodeAll.value ? menuOptions.value : []) +} + +/** 展开/折叠全部 */ +const handleCheckedTreeExpand = () => { + const nodes = treeRef.value?.store.nodesMap + for (let node in nodes) { + if (nodes[node].expanded === menuExpand.value) { + continue + } + nodes[node].expanded = menuExpand.value + } +} +</script> +<style lang="scss" scoped> +.cardHeight { + width: 100%; + max-height: 400px; + overflow-y: scroll; +} +</style> diff --git a/src/views/system/tenantPackage/index.vue b/src/views/system/tenantPackage/index.vue new file mode 100644 index 0000000..7ee111c --- /dev/null +++ b/src/views/system/tenantPackage/index.vue @@ -0,0 +1,180 @@ +<template> + <doc-alert title="SaaS 多租户" url="https://doc.iocoder.cn/saas-tenant/" /> + + <!-- 搜索 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="套餐名" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入套餐名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + start-placeholder="开始日期" + end-placeholder="结束日期" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:tenant-package:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="套餐编号" align="center" prop="id" width="120" /> + <el-table-column label="套餐名" align="center" prop="name" /> + <el-table-column label="状态" align="center" prop="status" width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="备注" align="center" prop="remark" /> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + width="180" + :formatter="dateFormatter" + /> + <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:tenant-package:update']" + > + 修改 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['system:tenant-package:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <TenantPackageForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as TenantPackageApi from '@/api/system/tenantPackage' +import TenantPackageForm from './TenantPackageForm.vue' + +defineOptions({ name: 'SystemTenantPackage' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + status: undefined, + remark: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await TenantPackageApi.getTenantPackagePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + getList() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await TenantPackageApi.deleteTenantPackage(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/system/user/DeptTree.vue b/src/views/system/user/DeptTree.vue new file mode 100644 index 0000000..ab8ba06 --- /dev/null +++ b/src/views/system/user/DeptTree.vue @@ -0,0 +1,63 @@ +<template> + <div class="head-container"> + <el-input v-model="deptName" class="mb-20px" clearable placeholder="请输入部门名称"> + <template #prefix> + <Icon icon="ep:search" /> + </template> + </el-input> + </div> + <div class="head-container"> + <el-tree + ref="treeRef" + :data="deptList" + :expand-on-click-node="false" + :filter-node-method="filterNode" + :props="defaultProps" + default-expand-all + highlight-current + node-key="id" + @node-click="handleNodeClick" + /> + </div> +</template> + +<script lang="ts" setup> +import { ElTree } from 'element-plus' +import * as DeptApi from '@/api/system/dept' +import { defaultProps, handleTree } from '@/utils/tree' + +defineOptions({ name: 'SystemUserDeptTree' }) + +const deptName = ref('') +const deptList = ref<Tree[]>([]) // 树形结构 +const treeRef = ref<InstanceType<typeof ElTree>>() + +/** 获得部门树 */ +const getTree = async () => { + const res = await DeptApi.getSimpleDeptList() + deptList.value = [] + deptList.value.push(...handleTree(res)) +} + +/** 基于名字过滤 */ +const filterNode = (name: string, data: Tree) => { + if (!name) return true + return data.name.includes(name) +} + +/** 处理部门被点击 */ +const handleNodeClick = async (row: { [key: string]: any }) => { + emits('node-click', row) +} +const emits = defineEmits(['node-click']) + +/** 监听deptName */ +watch(deptName, (val) => { + treeRef.value!.filter(val) +}) + +/** 初始化 */ +onMounted(async () => { + await getTree() +}) +</script> diff --git a/src/views/system/user/UserAssignRoleForm.vue b/src/views/system/user/UserAssignRoleForm.vue new file mode 100644 index 0000000..67a5ddb --- /dev/null +++ b/src/views/system/user/UserAssignRoleForm.vue @@ -0,0 +1,96 @@ +<template> + <Dialog v-model="dialogVisible" title="分配角色"> + <el-form ref="formRef" v-loading="formLoading" :model="formData" label-width="80px"> + <el-form-item label="用户名称"> + <el-input v-model="formData.username" :disabled="true" /> + </el-form-item> + <el-form-item label="用户昵称"> + <el-input v-model="formData.nickname" :disabled="true" /> + </el-form-item> + <el-form-item label="角色"> + <el-select v-model="formData.roleIds" multiple placeholder="请选择角色"> + <el-option v-for="item in roleList" :key="item.id" :label="item.name" :value="item.id" /> + </el-select> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as PermissionApi from '@/api/system/permission' +import * as UserApi from '@/api/system/user' +import * as RoleApi from '@/api/system/role' + +defineOptions({ name: 'SystemUserAssignRoleForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: -1, + nickname: '', + username: '', + roleIds: [] +}) +const formRef = ref() // 表单 Ref +const roleList = ref([] as RoleApi.RoleVO[]) // 角色的列表 + +/** 打开弹窗 */ +const open = async (row: UserApi.UserVO) => { + dialogVisible.value = true + resetForm() + // 设置数据 + formData.value.id = row.id + formData.value.username = row.username + formData.value.nickname = row.nickname + // 获得角色拥有的菜单集合 + formLoading.value = true + try { + formData.value.roleIds = await PermissionApi.getUserRoleList(row.id) + } finally { + formLoading.value = false + } + // 获得角色列表 + roleList.value = await RoleApi.getSimpleRoleList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + await PermissionApi.assignUserRole({ + userId: formData.value.id, + roleIds: formData.value.roleIds + }) + message.success(t('common.updateSuccess')) + dialogVisible.value = false + // 发送操作成功的事件 + emit('success', true) + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: -1, + nickname: '', + username: '', + roleIds: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/user/UserForm.vue b/src/views/system/user/UserForm.vue new file mode 100644 index 0000000..630688a --- /dev/null +++ b/src/views/system/user/UserForm.vue @@ -0,0 +1,219 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="80px" + > + <el-row> + <el-col :span="12"> + <el-form-item label="用户昵称" prop="nickname"> + <el-input v-model="formData.nickname" placeholder="请输入用户昵称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="归属部门" prop="deptId"> + <el-tree-select + v-model="formData.deptId" + :data="deptList" + :props="defaultProps" + check-strictly + node-key="id" + placeholder="请选择归属部门" + /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="手机号码" prop="mobile"> + <el-input v-model="formData.mobile" maxlength="11" placeholder="请输入手机号码" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="邮箱" prop="email"> + <el-input v-model="formData.email" maxlength="50" placeholder="请输入邮箱" /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item v-if="formData.id === undefined" label="用户名称" prop="username"> + <el-input v-model="formData.username" placeholder="请输入用户名称" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item v-if="formData.id === undefined" label="用户密码" prop="password"> + <el-input + v-model="formData.password" + placeholder="请输入用户密码" + show-password + type="password" + /> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="12"> + <el-form-item label="用户性别"> + <el-select v-model="formData.sex" placeholder="请选择"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="岗位"> + <el-select v-model="formData.postIds" multiple placeholder="请选择"> + <el-option + v-for="item in postList" + :key="item.id" + :label="item.name" + :value="item.id!" + /> + </el-select> + </el-form-item> + </el-col> + </el-row> + <el-row> + <el-col :span="24"> + <el-form-item label="备注"> + <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { CommonStatusEnum } from '@/utils/constants' +import { defaultProps, handleTree } from '@/utils/tree' +import * as PostApi from '@/api/system/post' +import * as DeptApi from '@/api/system/dept' +import * as UserApi from '@/api/system/user' +import { FormRules } from 'element-plus' + +defineOptions({ name: 'SystemUserForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + nickname: '', + deptId: '', + mobile: '', + email: '', + id: undefined, + username: '', + password: '', + sex: undefined, + postIds: [], + remark: '', + status: CommonStatusEnum.ENABLE, + roleIds: [] +}) +const formRules = reactive<FormRules>({ + username: [{ required: true, message: '用户名称不能为空', trigger: 'blur' }], + nickname: [{ required: true, message: '用户昵称不能为空', trigger: 'blur' }], + password: [{ required: true, message: '用户密码不能为空', trigger: 'blur' }], + email: [ + { + type: 'email', + message: '请输入正确的邮箱地址', + trigger: ['blur', 'change'] + } + ], + mobile: [ + { + pattern: /^(?:(?:\+|00)86)?1(?:3[\d]|4[5-79]|5[0-35-9]|6[5-7]|7[0-8]|8[\d]|9[189])\d{8}$/, + message: '请输入正确的手机号码', + trigger: 'blur' + } + ] +}) +const formRef = ref() // 表单 Ref +const deptList = ref<Tree[]>([]) // 树形结构 +const postList = ref([] as PostApi.PostVO[]) // 岗位列表 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await UserApi.getUser(id) + } finally { + formLoading.value = false + } + } + // 加载部门树 + deptList.value = handleTree(await DeptApi.getSimpleDeptList()) + // 加载岗位列表 + postList.value = await PostApi.getSimplePostList() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as UserApi.UserVO + if (formType.value === 'create') { + await UserApi.createUser(data) + message.success(t('common.createSuccess')) + } else { + await UserApi.updateUser(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + nickname: '', + deptId: '', + mobile: '', + email: '', + id: undefined, + username: '', + password: '', + sex: undefined, + postIds: [], + remark: '', + status: CommonStatusEnum.ENABLE, + roleIds: [] + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/system/user/UserImportForm.vue b/src/views/system/user/UserImportForm.vue new file mode 100644 index 0000000..8447b28 --- /dev/null +++ b/src/views/system/user/UserImportForm.vue @@ -0,0 +1,138 @@ +<template> + <Dialog v-model="dialogVisible" title="用户导入" width="400"> + <el-upload + ref="uploadRef" + v-model:file-list="fileList" + :action="importUrl + '?updateSupport=' + updateSupport" + :auto-upload="false" + :disabled="formLoading" + :headers="uploadHeaders" + :limit="1" + :on-error="submitFormError" + :on-exceed="handleExceed" + :on-success="submitFormSuccess" + accept=".xlsx, .xls" + drag + > + <Icon icon="ep:upload" /> + <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div> + <template #tip> + <div class="el-upload__tip text-center"> + <div class="el-upload__tip"> + <el-checkbox v-model="updateSupport" /> + 是否更新已经存在的用户数据 + </div> + <span>仅允许导入 xls、xlsx 格式文件。</span> + <el-link + :underline="false" + style="font-size: 12px; vertical-align: baseline" + type="primary" + @click="importTemplate" + > + 下载模板 + </el-link> + </div> + </template> + </el-upload> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import * as UserApi from '@/api/system/user' +import { getAccessToken, getTenantId } from '@/utils/auth' +import download from '@/utils/download' + +defineOptions({ name: 'SystemUserImportForm' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中 +const uploadRef = ref() +const importUrl = + import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/system/user/import' +const uploadHeaders = ref() // 上传 Header 头 +const fileList = ref([]) // 文件列表 +const updateSupport = ref(0) // 是否更新已经存在的用户数据 + +/** 打开弹窗 */ +const open = () => { + dialogVisible.value = true + updateSupport.value = 0 + fileList.value = [] + resetForm() +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const submitForm = async () => { + if (fileList.value.length == 0) { + message.error('请上传文件') + return + } + // 提交请求 + uploadHeaders.value = { + Authorization: 'Bearer ' + getAccessToken(), + 'tenant-id': getTenantId() + } + formLoading.value = true + uploadRef.value!.submit() +} + +/** 文件上传成功 */ +const emits = defineEmits(['success']) +const submitFormSuccess = (response: any) => { + if (response.code !== 0) { + message.error(response.msg) + formLoading.value = false + return + } + // 拼接提示语 + const data = response.data + let text = '上传成功数量:' + data.createUsernames.length + ';' + for (let username of data.createUsernames) { + text += '< ' + username + ' >' + } + text += '更新成功数量:' + data.updateUsernames.length + ';' + for (const username of data.updateUsernames) { + text += '< ' + username + ' >' + } + text += '更新失败数量:' + Object.keys(data.failureUsernames).length + ';' + for (const username in data.failureUsernames) { + text += '< ' + username + ': ' + data.failureUsernames[username] + ' >' + } + message.alert(text) + formLoading.value = false + dialogVisible.value = false + // 发送操作成功的事件 + emits('success') +} + +/** 上传错误提示 */ +const submitFormError = (): void => { + message.error('上传失败,请您重新上传!') + formLoading.value = false +} + +/** 重置表单 */ +const resetForm = async (): Promise<void> => { + // 重置上传状态和文件 + formLoading.value = false + await nextTick() + uploadRef.value?.clearFiles() +} + +/** 文件数超出提示 */ +const handleExceed = (): void => { + message.error('最多只能上传一个文件!') +} + +/** 下载模板操作 */ +const importTemplate = async () => { + const res = await UserApi.importUserTemplate() + download.excel(res, '用户导入模版.xls') +} +</script> diff --git a/src/views/system/user/index.vue b/src/views/system/user/index.vue new file mode 100644 index 0000000..4f04dff --- /dev/null +++ b/src/views/system/user/index.vue @@ -0,0 +1,362 @@ +<template> + <doc-alert title="用户体系" url="https://doc.iocoder.cn/user-center/" /> + <doc-alert title="三方登陆" url="https://doc.iocoder.cn/social-user/" /> + <doc-alert title="Excel 导入导出" url="https://doc.iocoder.cn/excel-import-and-export/" /> + + <el-row :gutter="20"> + <!-- 左侧部门树 --> + <el-col :span="4" :xs="24"> + <ContentWrap class="h-1/1"> + <DeptTree @node-click="handleDeptNodeClick" /> + </ContentWrap> + </el-col> + <el-col :span="20" :xs="24"> + <!-- 搜索 --> + <ContentWrap> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户名称" prop="username"> + <el-input + v-model="queryParams.username" + placeholder="请输入用户名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="手机号码" prop="mobile"> + <el-input + v-model="queryParams.mobile" + placeholder="请输入手机号码" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="用户状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="datetimerange" + start-placeholder="开始日期" + end-placeholder="结束日期" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['system:user:create']" + > + <Icon icon="ep:plus" /> 新增 + </el-button> + <el-button + type="warning" + plain + @click="handleImport" + v-hasPermi="['system:user:import']" + > + <Icon icon="ep:upload" /> 导入 + </el-button> + <el-button + type="success" + plain + @click="handleExport" + :loading="exportLoading" + v-hasPermi="['system:user:export']" + > + <Icon icon="ep:download" />导出 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + <ContentWrap> + <el-table v-loading="loading" :data="list"> + <el-table-column label="用户编号" align="center" key="id" prop="id" /> + <el-table-column + label="用户名称" + align="center" + prop="username" + :show-overflow-tooltip="true" + /> + <el-table-column + label="用户昵称" + align="center" + prop="nickname" + :show-overflow-tooltip="true" + /> + <el-table-column + label="部门" + align="center" + key="deptName" + prop="deptName" + :show-overflow-tooltip="true" + /> + <el-table-column label="手机号码" align="center" prop="mobile" width="120" /> + <el-table-column label="状态" key="status"> + <template #default="scope"> + <el-switch + v-model="scope.row.status" + :active-value="0" + :inactive-value="1" + @change="handleStatusChange(scope.row)" + /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180" + /> + <el-table-column label="操作" align="center" width="160"> + <template #default="scope"> + <div class="flex items-center justify-center"> + <el-button + type="primary" + link + @click="openForm('update', scope.row.id)" + v-hasPermi="['system:user:update']" + > + <Icon icon="ep:edit" />修改 + </el-button> + <el-dropdown + @command="(command) => handleCommand(command, scope.row)" + v-hasPermi="[ + 'system:user:delete', + 'system:user:update-password', + 'system:permission:assign-user-role' + ]" + > + <el-button type="primary" link><Icon icon="ep:d-arrow-right" /> 更多</el-button> + <template #dropdown> + <el-dropdown-menu> + <el-dropdown-item + command="handleDelete" + v-if="checkPermi(['system:user:delete'])" + > + <Icon icon="ep:delete" />删除 + </el-dropdown-item> + <el-dropdown-item + command="handleResetPwd" + v-if="checkPermi(['system:user:update-password'])" + > + <Icon icon="ep:key" />重置密码 + </el-dropdown-item> + <el-dropdown-item + command="handleRole" + v-if="checkPermi(['system:permission:assign-user-role'])" + > + <Icon icon="ep:circle-check" />分配角色 + </el-dropdown-item> + </el-dropdown-menu> + </template> + </el-dropdown> + </div> + </template> + </el-table-column> + </el-table> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + </el-col> + </el-row> + + <!-- 添加或修改用户对话框 --> + <UserForm ref="formRef" @success="getList" /> + <!-- 用户导入对话框 --> + <UserImportForm ref="importFormRef" @success="getList" /> + <!-- 分配角色 --> + <UserAssignRoleForm ref="assignRoleFormRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { checkPermi } from '@/utils/permission' +import { dateFormatter } from '@/utils/formatTime' +import download from '@/utils/download' +import { CommonStatusEnum } from '@/utils/constants' +import * as UserApi from '@/api/system/user' +import UserForm from './UserForm.vue' +import UserImportForm from './UserImportForm.vue' +import UserAssignRoleForm from './UserAssignRoleForm.vue' +import DeptTree from './DeptTree.vue' + +defineOptions({ name: 'SystemUser' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + username: undefined, + mobile: undefined, + status: undefined, + deptId: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await UserApi.getUserPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value?.resetFields() + handleQuery() +} + +/** 处理部门被点击 */ +const handleDeptNodeClick = async (row) => { + queryParams.deptId = row.id + await getList() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 用户导入 */ +const importFormRef = ref() +const handleImport = () => { + importFormRef.value.open() +} + +/** 修改用户状态 */ +const handleStatusChange = async (row: UserApi.UserVO) => { + try { + // 修改状态的二次确认 + const text = row.status === CommonStatusEnum.ENABLE ? '启用' : '停用' + await message.confirm('确认要"' + text + '""' + row.username + '"用户吗?') + // 发起修改状态 + await UserApi.updateUserStatus(row.id, row.status) + // 刷新列表 + await getList() + } catch { + // 取消后,进行恢复按钮 + row.status = + row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE + } +} + +/** 导出按钮操作 */ +const exportLoading = ref(false) +const handleExport = async () => { + try { + // 导出的二次确认 + await message.exportConfirm() + // 发起导出 + exportLoading.value = true + const data = await UserApi.exportUser(queryParams) + download.excel(data, '用户数据.xls') + } catch { + } finally { + exportLoading.value = false + } +} + +/** 操作分发 */ +const handleCommand = (command: string, row: UserApi.UserVO) => { + switch (command) { + case 'handleDelete': + handleDelete(row.id) + break + case 'handleResetPwd': + handleResetPwd(row) + break + case 'handleRole': + handleRole(row) + break + default: + break + } +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await UserApi.deleteUser(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 重置密码 */ +const handleResetPwd = async (row: UserApi.UserVO) => { + try { + // 重置的二次确认 + const result = await message.prompt( + '请输入"' + row.username + '"的新密码', + t('common.reminder') + ) + const password = result.value + // 发起重置 + await UserApi.resetUserPwd(row.id, password) + message.success('修改成功,新密码是:' + password) + } catch {} +} + +/** 分配角色 */ +const assignRoleFormRef = ref() +const handleRole = (row: UserApi.UserVO) => { + assignRoleFormRef.value.open(row) +} + +/** 初始化 */ +onMounted(() => { + getList() +}) +</script> diff --git a/stylelint.config.js b/stylelint.config.js new file mode 100644 index 0000000..b336785 --- /dev/null +++ b/stylelint.config.js @@ -0,0 +1,235 @@ +module.exports = { + root: true, + plugins: ['stylelint-order'], + customSyntax: 'postcss-html', + extends: ['stylelint-config-standard'], + rules: { + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['global', 'deep'] + } + ], + 'at-rule-no-unknown': [ + true, + { + ignoreAtRules: ['function', 'if', 'each', 'include', 'mixin', 'extend'] + } + ], + 'media-query-no-invalid': null, + 'function-no-unknown': null, + 'no-empty-source': null, + 'named-grid-areas-no-invalid': null, + // 'unicode-bom': 'never', + 'no-descending-specificity': null, + 'font-family-no-missing-generic-family-keyword': null, + // 'declaration-colon-space-after': 'always-single-line', + // 'declaration-colon-space-before': 'never', + // 'declaration-block-trailing-semicolon': null, + 'rule-empty-line-before': [ + 'always', + { + ignore: ['after-comment', 'first-nested'] + } + ], + 'unit-no-unknown': [ + true, + { + ignoreUnits: ['rpx'] + } + ], + 'order/order': [ + [ + 'dollar-variables', + 'custom-properties', + 'at-rules', + 'declarations', + { + type: 'at-rule', + name: 'supports' + }, + { + type: 'at-rule', + name: 'media' + }, + 'rules' + ], + { + severity: 'warning' + } + ], + // Specify the alphabetical order of the attributes in the declaration block + 'order/properties-order': [ + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'z-index', + 'display', + 'float', + 'width', + 'height', + 'max-width', + 'max-height', + 'min-width', + 'min-height', + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'margin-collapse', + 'margin-top-collapse', + 'margin-right-collapse', + 'margin-bottom-collapse', + 'margin-left-collapse', + 'overflow', + 'overflow-x', + 'overflow-y', + 'clip', + 'clear', + 'font', + 'font-family', + 'font-size', + 'font-smoothing', + 'osx-font-smoothing', + 'font-style', + 'font-weight', + 'hyphens', + 'src', + 'line-height', + 'letter-spacing', + 'word-spacing', + 'color', + 'text-align', + 'text-decoration', + 'text-indent', + 'text-overflow', + 'text-rendering', + 'text-size-adjust', + 'text-shadow', + 'text-transform', + 'word-break', + 'word-wrap', + 'white-space', + 'vertical-align', + 'list-style', + 'list-style-type', + 'list-style-position', + 'list-style-image', + 'pointer-events', + 'cursor', + 'background', + 'background-attachment', + 'background-color', + 'background-image', + 'background-position', + 'background-repeat', + 'background-size', + 'border', + 'border-collapse', + 'border-top', + 'border-right', + 'border-bottom', + 'border-left', + 'border-color', + 'border-image', + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', + 'border-spacing', + 'border-style', + 'border-top-style', + 'border-right-style', + 'border-bottom-style', + 'border-left-style', + 'border-width', + 'border-top-width', + 'border-right-width', + 'border-bottom-width', + 'border-left-width', + 'border-radius', + 'border-top-right-radius', + 'border-bottom-right-radius', + 'border-bottom-left-radius', + 'border-top-left-radius', + 'border-radius-topright', + 'border-radius-bottomright', + 'border-radius-bottomleft', + 'border-radius-topleft', + 'content', + 'quotes', + 'outline', + 'outline-offset', + 'opacity', + 'filter', + 'visibility', + 'size', + 'zoom', + 'transform', + 'box-align', + 'box-flex', + 'box-orient', + 'box-pack', + 'box-shadow', + 'box-sizing', + 'table-layout', + 'animation', + 'animation-delay', + 'animation-duration', + 'animation-iteration-count', + 'animation-name', + 'animation-play-state', + 'animation-timing-function', + 'animation-fill-mode', + 'transition', + 'transition-delay', + 'transition-duration', + 'transition-property', + 'transition-timing-function', + 'background-clip', + 'backface-visibility', + 'resize', + 'appearance', + 'user-select', + 'interpolation-mode', + 'direction', + 'marks', + 'page', + 'set-link-source', + 'unicode-bidi', + 'speak' + ] + }, + ignoreFiles: ['**/*.js', '**/*.jsx', '**/*.tsx', '**/*.ts'], + overrides: [ + { + files: ['*.vue', '**/*.vue', '*.html', '**/*.html'], + extends: ['stylelint-config-recommended', 'stylelint-config-html'], + rules: { + 'keyframes-name-pattern': null, + 'selector-class-pattern': null, + 'no-duplicate-selectors': null, + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['deep', 'global'] + } + ], + 'selector-pseudo-element-no-unknown': [ + true, + { + ignorePseudoElements: ['v-deep', 'v-global', 'v-slotted'] + } + ] + } + } + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..38376ef --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "target": "esnext", + "useDefineForClassFields": true, + "module": "esnext", + "moduleResolution": "node", + "strict": true, + "jsx": "preserve", + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "lib": ["esnext", "dom"], + "baseUrl": "./", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "strictFunctionTypes": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "experimentalDecorators": true, + "noImplicitAny": false, + "skipLibCheck": true, + "paths": { + "@/*": ["src/*"] + }, + "types": [ + // "@intlify/unplugin-vue-i18n/types", + "vite/client" + // "element-plus/global", + // "@types/qrcode", + // "vite-plugin-svg-icons/client" + ], + "outDir": "target", // 请保留这个属性,防止tsconfig.json文件报错 + "typeRoots": ["./node_modules/@types/", "./types"] + }, + "include": [ + "src", + "types/**/*.d.ts", + "src/types/auto-imports.d.ts", + "src/types/auto-components.d.ts" + ], + "exclude": ["dist", "target", "node_modules"] +} diff --git a/types/components.d.ts b/types/components.d.ts new file mode 100644 index 0000000..9d0ba09 --- /dev/null +++ b/types/components.d.ts @@ -0,0 +1,8 @@ +declare module 'vue' { + export interface GlobalComponents { + Icon: typeof import('@/components/Icon')['Icon'] + DictTag: typeof import('@/components/DictTag')['DictTag'] + } +} + +export {} diff --git a/types/custom-types.d.ts b/types/custom-types.d.ts new file mode 100644 index 0000000..3ef553c --- /dev/null +++ b/types/custom-types.d.ts @@ -0,0 +1,27 @@ +import { SlateDescendant } from '@wangeditor/editor' + +declare module 'slate' { + interface CustomTypes { + // 扩展 text + Text: { + text: string + bold?: boolean + italic?: boolean + code?: boolean + through?: boolean + underline?: boolean + sup?: boolean + sub?: boolean + color?: string + bgColor?: string + fontSize?: string + fontFamily?: string + } + + // 扩展 Element 的 type 属性 + Element: { + type: string + children: SlateDescendant[] + } + } +} diff --git a/types/env.d.ts b/types/env.d.ts new file mode 100644 index 0000000..1326e3b --- /dev/null +++ b/types/env.d.ts @@ -0,0 +1,35 @@ +/// <reference types="vite/client" /> + +declare module '*.vue' { + import { DefineComponent } from 'vue' + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types + const component: DefineComponent<{}, {}, any> + export default component +} + +interface ImportMetaEnv { + readonly VITE_APP_TITLE: string + readonly VITE_PORT: number + readonly VITE_OPEN: string + readonly VITE_DEV: string + readonly VITE_APP_CAPTCHA_ENABLE: string + readonly VITE_APP_TENANT_ENABLE: string + readonly VITE_APP_DEFAULT_LOGIN_TENANT: string + readonly VITE_APP_DEFAULT_LOGIN_USERNAME: string + readonly VITE_APP_DEFAULT_LOGIN_PASSWORD: string + readonly VITE_APP_DOCALERT_ENABLE: string + readonly VITE_BASE_URL: string + readonly VITE_UPLOAD_URL: string + readonly VITE_API_URL: string + readonly VITE_BASE_PATH: string + readonly VITE_DROP_DEBUGGER: string + readonly VITE_DROP_CONSOLE: string + readonly VITE_SOURCEMAP: string + readonly VITE_OUT_DIR: string +} + +declare global { + interface ImportMeta { + readonly env: ImportMetaEnv + } +} diff --git a/types/global.d.ts b/types/global.d.ts new file mode 100644 index 0000000..eebe9bb --- /dev/null +++ b/types/global.d.ts @@ -0,0 +1,58 @@ +export {} +declare global { + interface Fn<T = any> { + (...arg: T[]): T + } + + type Nullable<T> = T | null + + type ElRef<T extends HTMLElement = HTMLDivElement> = Nullable<T> + + type Recordable<T = any, K = string> = Record<K extends null | undefined ? string : K, T> + + type ComponentRef<T> = InstanceType<T> + + type LocaleType = 'zh-CN' | 'en' + + declare type TimeoutHandle = ReturnType<typeof setTimeout> + declare type IntervalHandle = ReturnType<typeof setInterval> + + type AxiosHeaders = + | 'application/json' + | 'application/x-www-form-urlencoded' + | 'multipart/form-data' + + type AxiosMethod = 'get' | 'post' | 'delete' | 'put' | 'GET' | 'POST' | 'DELETE' | 'PUT' + + type AxiosResponseType = 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream' + + interface AxiosConfig { + params?: any + data?: any + url?: string + method?: AxiosMethod + headersType?: string + responseType?: AxiosResponseType + } + + interface IResponse<T = any> { + code: string + data: T extends any ? T : T & any + } + + interface PageParam { + pageSize?: number + pageNo?: number + } + + interface Tree { + id: number + name: string + children?: Tree[] | any[] + } + // 分页数据公共返回 + interface PageResult<T> { + list: T // 数据 + total: number // 总量 + } +} diff --git a/types/router.d.ts b/types/router.d.ts new file mode 100644 index 0000000..9b08b80 --- /dev/null +++ b/types/router.d.ts @@ -0,0 +1,81 @@ +import type { RouteRecordRaw } from 'vue-router' +import { defineComponent } from 'vue' + +/** +* redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击 +* name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题 +* meta : { + hidden: true 当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false) + + alwaysShow: true 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式, + 只有一个时,会将那个子路由当做根路由显示在侧边栏, + 若你想不管路由下面的 children 声明的个数都显示你的根路由, + 你可以设置 alwaysShow: true,这样它就会忽略之前定义的规则, + 一直显示根路由(默认 false) + + title: 'title' 设置该路由在侧边栏和面包屑中展示的名字 + + icon: 'svg-name' 设置该路由的图标 + + noCache: true 如果设置为true,则不会被 <keep-alive> 缓存(默认 false) + + breadcrumb: false 如果设置为false,则不会在breadcrumb面包屑中显示(默认 true) + + affix: true 如果设置为true,则会一直固定在tag项中(默认 false) + + noTagsView: true 如果设置为true,则不会出现在tag中(默认 false) + + activeMenu: '/home' 显示高亮的路由路径 + + followAuth: '/home' 跟随哪个路由进行权限过滤 + + canTo: true 设置为true即使hidden为true,也依然可以进行路由跳转(默认 false) + } +**/ +declare module 'vue-router' { + interface RouteMeta extends Record<string | number | symbol, unknown> { + hidden?: boolean + alwaysShow?: boolean + title?: string + icon?: string + noCache?: boolean + breadcrumb?: boolean + affix?: boolean + activeMenu?: string + noTagsView?: boolean + followAuth?: string + canTo?: boolean + } +} + +type Component<T = any> = + | ReturnType<typeof defineComponent> + | (() => Promise<typeof import('*.vue')>) + | (() => Promise<T>) + +declare global { + interface AppRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> { + name: string + meta: RouteMeta + component?: Component | string + children?: AppRouteRecordRaw[] + props?: Recordable + fullPath?: string + keepAlive?: boolean + } + + interface AppCustomRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> { + icon: any + name: string + meta: RouteMeta + component: string + componentName?: string + path: string + redirect: string + children?: AppCustomRouteRecordRaw[] + keepAlive?: boolean + visible?: boolean + parentId?: number + alwaysShow?: boolean + } +} diff --git a/uno.config.ts b/uno.config.ts new file mode 100644 index 0000000..d146731 --- /dev/null +++ b/uno.config.ts @@ -0,0 +1,108 @@ +import { defineConfig, toEscapedSelector as e, presetUno } from 'unocss' +// import transformerVariantGroup from '@unocss/transformer-variant-group' + +export default defineConfig({ + // ...UnoCSS options + rules: [ + [ + /^custom-hover$/, + ([], { rawSelector }) => { + const selector = e(rawSelector) + return ` +${selector} { + display: flex; + height: 100%; + padding: 1px 10px 0; + cursor: pointer; + align-items: center; + transition: background var(--transition-time-02); +} +/* you can have multiple rules */ +${selector}:hover { + background-color: var(--top-header-hover-color); +} +.dark ${selector}:hover { + background-color: var(--el-bg-color-overlay); +} +` + } + ], + [ + /^layout-border__left$/, + ([], { rawSelector }) => { + const selector = e(rawSelector) + return ` +${selector}:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 100%; + background-color: var(--el-border-color); + z-index: 3; +} +` + } + ], + [ + /^layout-border__right$/, + ([], { rawSelector }) => { + const selector = e(rawSelector) + return ` +${selector}:after { + content: ""; + position: absolute; + top: 0; + right: 0; + width: 1px; + height: 100%; + background-color: var(--el-border-color); + z-index: 3; +} +` + } + ], + [ + /^layout-border__top$/, + ([], { rawSelector }) => { + const selector = e(rawSelector) + return ` +${selector}:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 1px; + background-color: var(--el-border-color); + z-index: 3; +} +` + } + ], + [ + /^layout-border__bottom$/, + ([], { rawSelector }) => { + const selector = e(rawSelector) + return ` +${selector}:after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 1px; + background-color: var(--el-border-color); + z-index: 3; +} +` + } + ] + ], + presets: [presetUno({ dark: 'class', attributify: false })], + // transformers: [transformerVariantGroup()], + shortcuts: { + 'wh-full': 'w-full h-full' + } +}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..6ee4563 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,85 @@ +import { resolve } from 'path' +import { loadEnv } from 'vite' +import type { UserConfig, ConfigEnv } from 'vite' +import { createVitePlugins } from './build/vite' +import { include, exclude } from "./build/vite/optimize" +// 当前执行node命令时文件夹的地址(工作目录) +const root = process.cwd() + +// 路径查找 +function pathResolve(dir: string) { + return resolve(root, '.', dir) +} + +// https://vitejs.dev/config/ +export default ({ command, mode }: ConfigEnv): UserConfig => { + let env = {} as any + const isBuild = command === 'build' + if (!isBuild) { + env = loadEnv((process.argv[3] === '--mode' ? process.argv[4] : process.argv[3]), root) + } else { + env = loadEnv(mode, root) + } + return { + base: env.VITE_BASE_PATH, + root: root, + // 服务端渲染 + server: { + port: env.VITE_PORT, // 端口号 + host: "0.0.0.0", + open: env.VITE_OPEN === 'true', + // 本地跨域代理. 目前注释的原因:暂时没有用途,server 端已经支持跨域 + // proxy: { + // ['/admin-api']: { + // target: env.VITE_BASE_URL, + // ws: false, + // changeOrigin: true, + // rewrite: (path) => path.replace(new RegExp(`^/admin-api`), ''), + // }, + // }, + }, + // 项目使用的vite插件。 单独提取到build/vite/plugin中管理 + plugins: createVitePlugins(), + css: { + preprocessorOptions: { + scss: { + additionalData: '@import "./src/styles/variables.scss";', + javascriptEnabled: true + } + } + }, + resolve: { + extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.scss', '.css'], + alias: [ + { + find: 'vue-i18n', + replacement: 'vue-i18n/dist/vue-i18n.cjs.js' + }, + { + find: /\@\//, + replacement: `${pathResolve('src')}/` + } + ] + }, + build: { + minify: 'terser', + outDir: env.VITE_OUT_DIR || 'dist', + sourcemap: env.VITE_SOURCEMAP === 'true' ? 'inline' : false, + // brotliSize: false, + terserOptions: { + compress: { + drop_debugger: env.VITE_DROP_DEBUGGER === 'true', + drop_console: env.VITE_DROP_CONSOLE === 'true' + } + }, + rollupOptions: { + output: { + manualChunks: { + echarts: ['echarts'] // 将 echarts 单独打包,参考 https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/IAB1SX 讨论 + } + }, + }, + }, + optimizeDeps: { include, exclude } + } +} -- libgit2 0.26.0