From 7a2efa3fea51ffba0635dad08dd58533017818af Mon Sep 17 00:00:00 2001 From: Ironsp <1522278303@qq.com> Date: Thu, 24 Sep 2026 09:09:47 +0800 Subject: [PATCH] =?UTF-8?q?feat=20=E6=90=AD=E5=BB=BA=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 2 + .env.production | 2 + .gitignore | 7 + package.json | 1 + pnpm-lock.yaml | 213 ++++++++- src/App.vue | 4 +- src/api/auth.ts | 96 ++++ src/api/cases.ts | 227 ++++++++++ src/api/contract.ts | 110 +++++ src/api/demand.ts | 25 ++ src/api/finance.ts | 316 +++++++++++++ src/api/home.ts | 180 ++++++++ src/api/message.ts | 153 +++++++ src/api/profile.ts | 418 ++++++++++++++++++ src/api/project.ts | 270 +++++++++++ src/api/service.ts | 72 +++ src/components/AppButton.vue | 103 +++++ src/components/AppFooter.vue | 146 ++++++ src/components/AppHeader.vue | 138 ++++++ src/components/CaseCard.vue | 125 ++++++ src/components/ConfirmDialog.vue | 132 ++++++ src/components/FileList.vue | 93 ++++ src/components/FilterPanel.vue | 162 +++++++ src/components/PaginationBar.vue | 108 +++++ src/components/PasswordStrength.vue | 88 ++++ src/components/QrCodePanel.vue | 114 +++++ src/components/SmsCodeInput.vue | 79 ++++ src/components/StatusTag.vue | 50 +++ src/components/StepForm.vue | 86 ++++ src/components/UploadArea.vue | 100 +++++ src/components/index.ts | 15 + src/composables/useCountDown.ts | 32 ++ src/composables/useIntersection.ts | 29 ++ src/composables/useLoading.ts | 21 + src/composables/useScroll.ts | 26 ++ src/directives/debounce.ts | 35 ++ src/directives/index.ts | 12 + src/directives/lazy.ts | 27 ++ src/directives/throttle.ts | 38 ++ src/env.d.ts | 10 + src/layout/components/FloatingActions.vue | 58 +++ src/layout/index.vue | 23 + src/main.ts | 13 +- src/router/guard.ts | 16 + src/router/index.ts | 13 + src/router/routes.ts | 65 +++ src/stores/index.ts | 5 + src/stores/modules/cases.ts | 84 ++++ src/stores/modules/contract.ts | 79 ++++ src/stores/modules/demand.ts | 126 ++++++ src/stores/modules/finance.ts | 207 +++++++++ src/stores/modules/home.ts | 46 ++ src/stores/modules/message.ts | 112 +++++ src/stores/modules/profile.ts | 258 +++++++++++ src/stores/modules/project.ts | 106 +++++ src/stores/modules/service.ts | 60 +++ src/stores/modules/site.ts | 24 + src/stores/modules/user.ts | 137 ++++++ src/styles/common.less | 25 ++ src/styles/index.less | 3 + src/styles/mixins.less | 37 ++ src/styles/reset.less | 64 +++ src/styles/variables.less | 56 +++ src/types/api.d.ts | 31 ++ src/types/business/cases.d.ts | 38 ++ src/types/business/contract.d.ts | 29 ++ src/types/business/demand.d.ts | 20 + src/types/business/finance.d.ts | 92 ++++ src/types/business/home.d.ts | 34 ++ src/types/business/message.d.ts | 27 ++ src/types/business/profile.d.ts | 87 ++++ src/types/business/project.d.ts | 79 ++++ src/types/business/service.d.ts | 17 + src/types/business/user.d.ts | 47 ++ src/types/global.d.ts | 8 + src/types/router.d.ts | 11 + src/utils/format.ts | 67 +++ src/utils/request.ts | 42 ++ src/utils/storage.ts | 34 ++ src/utils/validate.ts | 36 ++ src/views/404.vue | 35 ++ src/views/cases/cpns/CaseDetailHeader.vue | 164 +++++++ src/views/cases/cpns/CaseRichText.vue | 57 +++ src/views/cases/cpns/CasesFilter.vue | 324 ++++++++++++++ src/views/cases/cpns/CasesGrid.vue | 43 ++ src/views/cases/detail.vue | 90 ++++ src/views/cases/index.ts | 5 + src/views/cases/index.vue | 90 ++++ src/views/contract/cpns/ContractInfoCard.vue | 216 +++++++++ src/views/contract/cpns/ContractSignPanel.vue | 47 ++ src/views/contract/cpns/ContractTable.vue | 161 +++++++ src/views/contract/cpns/SignSteps.vue | 76 ++++ src/views/contract/detail.vue | 98 ++++ src/views/contract/index.ts | 5 + src/views/contract/index.vue | 95 ++++ src/views/contract/sign.vue | 78 ++++ src/views/demand/cpns/DemandStep1Business.vue | 105 +++++ src/views/demand/cpns/DemandStep2Info.vue | 229 ++++++++++ src/views/demand/cpns/DemandStep3Confirm.vue | 172 +++++++ src/views/demand/index.ts | 4 + src/views/demand/index.vue | 151 +++++++ src/views/finance/bill-detail.vue | 226 ++++++++++ src/views/finance/bills.vue | 158 +++++++ src/views/finance/cpns/BillSummaryBar.vue | 88 ++++ src/views/finance/cpns/BillTable.vue | 182 ++++++++ src/views/finance/cpns/FinanceChart.vue | 115 +++++ src/views/finance/cpns/FinanceOverview.vue | 136 ++++++ src/views/finance/cpns/InvoiceTitleCard.vue | 116 +++++ src/views/finance/cpns/InvoiceTitleDialog.vue | 192 ++++++++ src/views/finance/cpns/PayMethodDialog.vue | 179 ++++++++ src/views/finance/index.ts | 8 + src/views/finance/index.vue | 110 +++++ src/views/finance/invoice-titles.vue | 207 +++++++++ src/views/finance/invoices.vue | 364 +++++++++++++++ src/views/finance/pay-result.vue | 232 ++++++++++ src/views/finance/pay.vue | 291 ++++++++++++ src/views/home/cpns/HomeBusinessCards.vue | 113 +++++ src/views/home/cpns/HomeCaseShowcase.vue | 106 +++++ src/views/home/cpns/HomeQuickEntries.vue | 70 +++ src/views/home/cpns/HomeServiceTabs.vue | 129 ++++++ src/views/home/cpns/HomeStats.vue | 55 +++ src/views/home/cpns/HomeWelcome.vue | 74 ++++ src/views/home/detail.vue | 108 +++++ src/views/home/index.ts | 7 + src/views/home/index.vue | 58 +++ src/views/login/cpns/LoginForm.vue | 126 ++++++ src/views/login/cpns/SmsLoginForm.vue | 145 ++++++ src/views/login/index.ts | 3 + src/views/login/index.vue | 232 ++++++++++ src/views/login/register.vue | 287 ++++++++++++ src/views/login/reset.vue | 278 ++++++++++++ src/views/message/cpns/MessageItem.vue | 106 +++++ src/views/message/cpns/MessageList.vue | 44 ++ src/views/message/cpns/MessageTabs.vue | 95 ++++ src/views/message/detail.vue | 211 +++++++++ src/views/message/index.ts | 4 + src/views/message/index.vue | 127 ++++++ src/views/profile/about.vue | 37 ++ src/views/profile/address.vue | 239 ++++++++++ src/views/profile/cpns/AboutPanel.vue | 161 +++++++ src/views/profile/cpns/AddressDialog.vue | 194 ++++++++ src/views/profile/cpns/BindContactDialog.vue | 229 ++++++++++ .../profile/cpns/ChangePasswordDialog.vue | 197 +++++++++ src/views/profile/cpns/FavoritesGrid.vue | 43 ++ src/views/profile/cpns/FeedbackForm.vue | 195 ++++++++ src/views/profile/cpns/HelpFaq.vue | 120 +++++ src/views/profile/cpns/HotlinePanel.vue | 63 +++ src/views/profile/cpns/ProfileSider.vue | 97 ++++ src/views/profile/cpns/ProfileUserCard.vue | 112 +++++ src/views/profile/cpns/QualificationCard.vue | 124 ++++++ .../profile/cpns/QualificationDialog.vue | 264 +++++++++++ src/views/profile/cpns/SecurityPanel.vue | 290 ++++++++++++ src/views/profile/favorites.vue | 138 ++++++ src/views/profile/feedback.vue | 87 ++++ src/views/profile/help.vue | 56 +++ src/views/profile/hotline.vue | 15 + src/views/profile/index.ts | 14 + src/views/profile/index.vue | 73 +++ src/views/profile/qualifications.vue | 149 +++++++ src/views/profile/security.vue | 147 ++++++ src/views/project/after-sales.vue | 377 ++++++++++++++++ src/views/project/cpns/ProjectAfterSales.vue | 166 +++++++ src/views/project/cpns/ProjectFiles.vue | 134 ++++++ src/views/project/cpns/ProjectFilter.vue | 191 ++++++++ src/views/project/cpns/ProjectFinance.vue | 275 ++++++++++++ src/views/project/cpns/ProjectInfoCard.vue | 86 ++++ src/views/project/cpns/ProjectMilestones.vue | 186 ++++++++ src/views/project/cpns/ProjectTable.vue | 183 ++++++++ src/views/project/cpns/ProjectTeam.vue | 93 ++++ src/views/project/detail.vue | 214 +++++++++ src/views/project/index.ts | 9 + src/views/project/index.vue | 97 ++++ src/views/service/cpns/ChatFileCard.vue | 80 ++++ src/views/service/cpns/ChatInput.vue | 152 +++++++ src/views/service/cpns/ChatMessageItem.vue | 109 +++++ src/views/service/cpns/ChatWindow.vue | 95 ++++ src/views/service/index.ts | 5 + src/views/service/index.vue | 61 +++ tsconfig.app.json | 3 + vite.config.ts | 8 + 180 files changed, 18967 insertions(+), 9 deletions(-) create mode 100644 .env.development create mode 100644 .env.production create mode 100644 src/api/auth.ts create mode 100644 src/api/cases.ts create mode 100644 src/api/contract.ts create mode 100644 src/api/demand.ts create mode 100644 src/api/finance.ts create mode 100644 src/api/home.ts create mode 100644 src/api/message.ts create mode 100644 src/api/profile.ts create mode 100644 src/api/project.ts create mode 100644 src/api/service.ts create mode 100644 src/components/AppButton.vue create mode 100644 src/components/AppFooter.vue create mode 100644 src/components/AppHeader.vue create mode 100644 src/components/CaseCard.vue create mode 100644 src/components/ConfirmDialog.vue create mode 100644 src/components/FileList.vue create mode 100644 src/components/FilterPanel.vue create mode 100644 src/components/PaginationBar.vue create mode 100644 src/components/PasswordStrength.vue create mode 100644 src/components/QrCodePanel.vue create mode 100644 src/components/SmsCodeInput.vue create mode 100644 src/components/StatusTag.vue create mode 100644 src/components/StepForm.vue create mode 100644 src/components/UploadArea.vue create mode 100644 src/components/index.ts create mode 100644 src/composables/useCountDown.ts create mode 100644 src/composables/useIntersection.ts create mode 100644 src/composables/useLoading.ts create mode 100644 src/composables/useScroll.ts create mode 100644 src/directives/debounce.ts create mode 100644 src/directives/index.ts create mode 100644 src/directives/lazy.ts create mode 100644 src/directives/throttle.ts create mode 100644 src/env.d.ts create mode 100644 src/layout/components/FloatingActions.vue create mode 100644 src/layout/index.vue create mode 100644 src/router/guard.ts create mode 100644 src/router/index.ts create mode 100644 src/router/routes.ts create mode 100644 src/stores/index.ts create mode 100644 src/stores/modules/cases.ts create mode 100644 src/stores/modules/contract.ts create mode 100644 src/stores/modules/demand.ts create mode 100644 src/stores/modules/finance.ts create mode 100644 src/stores/modules/home.ts create mode 100644 src/stores/modules/message.ts create mode 100644 src/stores/modules/profile.ts create mode 100644 src/stores/modules/project.ts create mode 100644 src/stores/modules/service.ts create mode 100644 src/stores/modules/site.ts create mode 100644 src/stores/modules/user.ts create mode 100644 src/styles/common.less create mode 100644 src/styles/index.less create mode 100644 src/styles/mixins.less create mode 100644 src/styles/reset.less create mode 100644 src/styles/variables.less create mode 100644 src/types/api.d.ts create mode 100644 src/types/business/cases.d.ts create mode 100644 src/types/business/contract.d.ts create mode 100644 src/types/business/demand.d.ts create mode 100644 src/types/business/finance.d.ts create mode 100644 src/types/business/home.d.ts create mode 100644 src/types/business/message.d.ts create mode 100644 src/types/business/profile.d.ts create mode 100644 src/types/business/project.d.ts create mode 100644 src/types/business/service.d.ts create mode 100644 src/types/business/user.d.ts create mode 100644 src/types/global.d.ts create mode 100644 src/types/router.d.ts create mode 100644 src/utils/format.ts create mode 100644 src/utils/request.ts create mode 100644 src/utils/storage.ts create mode 100644 src/utils/validate.ts create mode 100644 src/views/404.vue create mode 100644 src/views/cases/cpns/CaseDetailHeader.vue create mode 100644 src/views/cases/cpns/CaseRichText.vue create mode 100644 src/views/cases/cpns/CasesFilter.vue create mode 100644 src/views/cases/cpns/CasesGrid.vue create mode 100644 src/views/cases/detail.vue create mode 100644 src/views/cases/index.ts create mode 100644 src/views/cases/index.vue create mode 100644 src/views/contract/cpns/ContractInfoCard.vue create mode 100644 src/views/contract/cpns/ContractSignPanel.vue create mode 100644 src/views/contract/cpns/ContractTable.vue create mode 100644 src/views/contract/cpns/SignSteps.vue create mode 100644 src/views/contract/detail.vue create mode 100644 src/views/contract/index.ts create mode 100644 src/views/contract/index.vue create mode 100644 src/views/contract/sign.vue create mode 100644 src/views/demand/cpns/DemandStep1Business.vue create mode 100644 src/views/demand/cpns/DemandStep2Info.vue create mode 100644 src/views/demand/cpns/DemandStep3Confirm.vue create mode 100644 src/views/demand/index.ts create mode 100644 src/views/demand/index.vue create mode 100644 src/views/finance/bill-detail.vue create mode 100644 src/views/finance/bills.vue create mode 100644 src/views/finance/cpns/BillSummaryBar.vue create mode 100644 src/views/finance/cpns/BillTable.vue create mode 100644 src/views/finance/cpns/FinanceChart.vue create mode 100644 src/views/finance/cpns/FinanceOverview.vue create mode 100644 src/views/finance/cpns/InvoiceTitleCard.vue create mode 100644 src/views/finance/cpns/InvoiceTitleDialog.vue create mode 100644 src/views/finance/cpns/PayMethodDialog.vue create mode 100644 src/views/finance/index.ts create mode 100644 src/views/finance/index.vue create mode 100644 src/views/finance/invoice-titles.vue create mode 100644 src/views/finance/invoices.vue create mode 100644 src/views/finance/pay-result.vue create mode 100644 src/views/finance/pay.vue create mode 100644 src/views/home/cpns/HomeBusinessCards.vue create mode 100644 src/views/home/cpns/HomeCaseShowcase.vue create mode 100644 src/views/home/cpns/HomeQuickEntries.vue create mode 100644 src/views/home/cpns/HomeServiceTabs.vue create mode 100644 src/views/home/cpns/HomeStats.vue create mode 100644 src/views/home/cpns/HomeWelcome.vue create mode 100644 src/views/home/detail.vue create mode 100644 src/views/home/index.ts create mode 100644 src/views/home/index.vue create mode 100644 src/views/login/cpns/LoginForm.vue create mode 100644 src/views/login/cpns/SmsLoginForm.vue create mode 100644 src/views/login/index.ts create mode 100644 src/views/login/index.vue create mode 100644 src/views/login/register.vue create mode 100644 src/views/login/reset.vue create mode 100644 src/views/message/cpns/MessageItem.vue create mode 100644 src/views/message/cpns/MessageList.vue create mode 100644 src/views/message/cpns/MessageTabs.vue create mode 100644 src/views/message/detail.vue create mode 100644 src/views/message/index.ts create mode 100644 src/views/message/index.vue create mode 100644 src/views/profile/about.vue create mode 100644 src/views/profile/address.vue create mode 100644 src/views/profile/cpns/AboutPanel.vue create mode 100644 src/views/profile/cpns/AddressDialog.vue create mode 100644 src/views/profile/cpns/BindContactDialog.vue create mode 100644 src/views/profile/cpns/ChangePasswordDialog.vue create mode 100644 src/views/profile/cpns/FavoritesGrid.vue create mode 100644 src/views/profile/cpns/FeedbackForm.vue create mode 100644 src/views/profile/cpns/HelpFaq.vue create mode 100644 src/views/profile/cpns/HotlinePanel.vue create mode 100644 src/views/profile/cpns/ProfileSider.vue create mode 100644 src/views/profile/cpns/ProfileUserCard.vue create mode 100644 src/views/profile/cpns/QualificationCard.vue create mode 100644 src/views/profile/cpns/QualificationDialog.vue create mode 100644 src/views/profile/cpns/SecurityPanel.vue create mode 100644 src/views/profile/favorites.vue create mode 100644 src/views/profile/feedback.vue create mode 100644 src/views/profile/help.vue create mode 100644 src/views/profile/hotline.vue create mode 100644 src/views/profile/index.ts create mode 100644 src/views/profile/index.vue create mode 100644 src/views/profile/qualifications.vue create mode 100644 src/views/profile/security.vue create mode 100644 src/views/project/after-sales.vue create mode 100644 src/views/project/cpns/ProjectAfterSales.vue create mode 100644 src/views/project/cpns/ProjectFiles.vue create mode 100644 src/views/project/cpns/ProjectFilter.vue create mode 100644 src/views/project/cpns/ProjectFinance.vue create mode 100644 src/views/project/cpns/ProjectInfoCard.vue create mode 100644 src/views/project/cpns/ProjectMilestones.vue create mode 100644 src/views/project/cpns/ProjectTable.vue create mode 100644 src/views/project/cpns/ProjectTeam.vue create mode 100644 src/views/project/detail.vue create mode 100644 src/views/project/index.ts create mode 100644 src/views/project/index.vue create mode 100644 src/views/service/cpns/ChatFileCard.vue create mode 100644 src/views/service/cpns/ChatInput.vue create mode 100644 src/views/service/cpns/ChatMessageItem.vue create mode 100644 src/views/service/cpns/ChatWindow.vue create mode 100644 src/views/service/index.ts create mode 100644 src/views/service/index.vue diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..6ff2a49 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +# TODO(api): 待接口文档提供后填写 +VITE_API_BASE_URL= diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..6ff2a49 --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +# TODO(api): 待接口文档提供后填写 +VITE_API_BASE_URL= diff --git a/.gitignore b/.gitignore index a547bf3..bf341f2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,13 @@ node_modules dist dist-ssr *.local +docs +.claude +read_prototype.js +RTK.md +.playwright-mcp +.omo +.opencode # Editor directories and files .vscode/* diff --git a/package.json b/package.json index d7c00cf..3d775b5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@types/node": "^24.13.3", "@vitejs/plugin-vue": "^6.0.8", "@vue/tsconfig": "^0.9.1", + "less": "^4.9.1", "typescript": "~6.0.2", "vite": "^8.3.0", "vue-tsc": "^3.3.11" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a7cbe6..8de4b33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,16 +29,19 @@ importers: version: 24.13.5 '@vitejs/plugin-vue': specifier: ^6.0.8 - version: 6.0.9(vite@8.3.0(@types/node@24.13.5))(vue@3.5.42(typescript@6.0.3)) + version: 6.0.9(vite@8.3.0(@types/node@24.13.5)(less@4.9.1))(vue@3.5.42(typescript@6.0.3)) '@vue/tsconfig': specifier: ^0.9.1 version: 0.9.1(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)) + less: + specifier: ^4.9.1 + version: 4.9.1 typescript: specifier: ~6.0.2 version: 6.0.3 vite: specifier: ^8.3.0 - version: 8.3.0(@types/node@24.13.5) + version: 8.3.0(@types/node@24.13.5)(less@4.9.1) vue-tsc: specifier: ^3.3.11 version: 3.3.11(typescript@6.0.3) @@ -297,12 +300,32 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + copy-anything@3.0.5: + resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} + engines: {node: '>=12.13'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} dayjs@1.11.23: resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -333,6 +356,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + errno@0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -394,6 +421,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -410,6 +440,23 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + + less@4.9.1: + resolution: {integrity: sha512-orp15PfJvvNDIqJdVWzMI9Sjpjp3VTiw3sfvbB+67LlISTEn8uVT2EdYSuyl02BLvaftv6sdk9Umnxmm5rckmg==} + engines: {node: '>=18'} + hasBin: true + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -494,12 +541,19 @@ packages: lodash: '*' lodash-es: '*' + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@5.1.0: + resolution: {integrity: sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==} + engines: {node: '>=18'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -515,6 +569,14 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -526,12 +588,26 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + needle@2.9.1: + resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==} + engines: {node: '>= 4.4.x'} + hasBin: true + + needle@3.5.0: + resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==} + engines: {node: '>= 4.4.x'} + hasBin: true + normalize-wheel-es@1.2.0: resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} nostics@1.2.0: resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} + parse-node-version@1.0.1: + resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} + engines: {node: '>= 0.10'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -556,19 +632,39 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + probe-image-size@7.4.0: + resolution: {integrity: sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + prr@1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + rolldown@1.2.8: resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stream-parser@0.3.1: + resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -746,10 +842,10 @@ snapshots: '@types/web-bluetooth@0.0.21': {} - '@vitejs/plugin-vue@6.0.9(vite@8.3.0(@types/node@24.13.5))(vue@3.5.42(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.9(vite@8.3.0(@types/node@24.13.5)(less@4.9.1))(vue@3.5.42(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.0(@types/node@24.13.5) + vite: 8.3.0(@types/node@24.13.5)(less@4.9.1) vue: 3.5.42(typescript@6.0.3) '@volar/language-core@2.4.28': @@ -879,10 +975,24 @@ snapshots: dependencies: delayed-stream: 1.0.0 + copy-anything@3.0.5: + dependencies: + is-what: 4.1.16 + csstype@3.2.3: {} dayjs@1.11.23: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + optional: true + + debug@3.2.7: + dependencies: + ms: 2.1.3 + optional: true + debug@4.4.3: dependencies: ms: 2.1.3 @@ -918,6 +1028,11 @@ snapshots: entities@7.0.1: {} + errno@0.1.8: + dependencies: + prr: 1.0.1 + optional: true + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -974,6 +1089,9 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: + optional: true + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -991,6 +1109,33 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + optional: true + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + is-what@4.1.16: {} + + less@4.9.1: + dependencies: + copy-anything: 3.0.5 + parse-node-version: 1.0.1 + optionalDependencies: + errno: 0.1.8 + graceful-fs: 4.2.11 + make-dir: 5.1.0 + mime: 1.6.0 + needle: 3.5.0 + probe-image-size: 7.4.0 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + lightningcss-android-arm64@1.33.0: optional: true @@ -1048,12 +1193,18 @@ snapshots: lodash: 4.18.1 lodash-es: 4.18.1 + lodash.merge@4.6.2: + optional: true + lodash@4.18.1: {} magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + make-dir@5.1.0: + optional: true + math-intrinsics@1.1.0: {} memoize-one@6.0.0: {} @@ -1064,16 +1215,39 @@ snapshots: dependencies: mime-db: 1.52.0 + mime@1.6.0: + optional: true + + ms@2.0.0: + optional: true + ms@2.1.3: {} muggle-string@0.4.1: {} nanoid@3.3.19: {} + needle@2.9.1: + dependencies: + debug: 3.2.7 + iconv-lite: 0.4.24 + sax: 1.6.1 + transitivePeerDependencies: + - supports-color + optional: true + + needle@3.5.0: + dependencies: + iconv-lite: 0.6.3 + sax: 1.6.1 + optional: true + normalize-wheel-es@1.2.0: {} nostics@1.2.0: {} + parse-node-version@1.0.1: {} + path-browserify@1.0.1: {} picocolors@1.1.1: {} @@ -1094,8 +1268,20 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + probe-image-size@7.4.0: + dependencies: + lodash.merge: 4.6.2 + needle: 2.9.1 + stream-parser: 0.3.1 + transitivePeerDependencies: + - supports-color + optional: true + proxy-from-env@2.1.0: {} + prr@1.0.1: + optional: true + rolldown@1.2.8: dependencies: '@oxc-project/types': 0.149.0 @@ -1117,8 +1303,24 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.8 '@rolldown/binding-win32-x64-msvc': 1.2.8 + safer-buffer@2.1.2: + optional: true + + sax@1.6.1: + optional: true + source-map-js@1.2.1: {} + source-map@0.6.1: + optional: true + + stream-parser@0.3.1: + dependencies: + debug: 2.6.9 + transitivePeerDependencies: + - supports-color + optional: true + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -1128,7 +1330,7 @@ snapshots: undici-types@7.18.2: {} - vite@8.3.0(@types/node@24.13.5): + vite@8.3.0(@types/node@24.13.5)(less@4.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1138,6 +1340,7 @@ snapshots: optionalDependencies: '@types/node': 24.13.5 fsevents: 2.3.3 + less: 4.9.1 vscode-uri@3.2.0: {} diff --git a/src/App.vue b/src/App.vue index 4b9e440..98240ae 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,3 @@ - - diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..b7b1267 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,96 @@ +import type { + LoginByAccountParams, + LoginBySmsParams, + LoginResult, + RegisterParams, + ResetPasswordParams, + User, +} from '@/types/business/user' +import { isPhone, isValidPassword } from '@/utils/validate' + +// TODO(api): 待接口文档到位后,将以下 mock 替换为 request 调用(页面 → store → api 数据流不变) + +const SIX_DIGIT_CODE = /^\d{6}$/ + +/** 发送短信验证码 */ +export function sendSmsCode(phone: string): Promise { + if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确')) + return Promise.resolve() +} + +/** 账号密码登录(mock:demo 账号可登录,其余提示先注册) */ +export function loginByAccount(params: LoginByAccountParams): Promise { + const { account, password } = params + if (!account.trim() || !password) { + return Promise.reject(new Error('请输入账号和密码')) + } + if (account === 'demo') { + const user: User = { + id: 'u1', + nickname: '王永志', + avatar: '', + phone: '186****618', + email: 'yongzh******4@163.com', + passwordModifiedAt: '', + } + return Promise.resolve({ token: `mock-token-${Date.now()}`, user }) + } + return Promise.reject(new Error('登录失败,请先注册账号')) +} + +/** 手机验证码登录(mock:验证码须为 6 位数字) */ +export function loginBySms(params: LoginBySmsParams): Promise { + const { phone, code } = params + if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确')) + if (!SIX_DIGIT_CODE.test(code)) return Promise.reject(new Error('验证码格式不正确,应为6位数字')) + const user: User = { + id: 'u1', + nickname: '王永志', + avatar: '', + phone: '186****618', + email: 'yongzh******4@163.com', + passwordModifiedAt: '', + } + return Promise.resolve({ token: `mock-token-${Date.now()}`, user }) +} + +function validateAuthForm( + phone: string, + code: string, + password: string, + confirmPassword: string, + agree: boolean, +): string | null { + if (!isPhone(phone)) return '手机号格式不正确' + if (!SIX_DIGIT_CODE.test(code)) return '验证码格式不正确,应为6位数字' + if (!isValidPassword(password)) return '密码长度应为6-20位' + if (password !== confirmPassword) return '两次输入的密码不一致' + if (!agree) return '请先阅读并同意《用户协议》和《隐私政策》' + return null +} + +/** 注册 */ +export function register(params: RegisterParams): Promise { + const message = validateAuthForm( + params.phone, + params.code, + params.password, + params.confirmPassword, + params.agree, + ) + if (message) return Promise.reject(new Error(message)) + return Promise.resolve() +} + +/** 重置密码 */ +export function resetPassword(params: ResetPasswordParams): Promise { + const message = validateAuthForm( + params.phone, + params.code, + params.password, + params.confirmPassword, + params.agree, + ) + if (message) return Promise.reject(new Error(message)) + return Promise.resolve() +} diff --git a/src/api/cases.ts b/src/api/cases.ts new file mode 100644 index 0000000..a38f337 --- /dev/null +++ b/src/api/cases.ts @@ -0,0 +1,227 @@ +import type { BusinessType, PageResult } from '@/types/api' +import type { Case, CaseQuery, ScaleRange } from '@/types/business/cases' + +// TODO(api): 待接口文档到位后,将以下 mock 替换为 request 调用 + +/** 案例 mock(可变,收藏状态在前端内存中翻转演示) */ +const MOCK_CASES: Case[] = [ + { + id: 'case-001', + title: '自动化生产线升级改造项目', + category: 'manufacturing', + cover: '', + finishDate: '2026/8/10', + teamSize: 50, + scale: '300万', + industry: '自动化', + duration: '150个工作日', + clientType: '企业', + background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。', + painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。', + requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。', + deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。', + video: '', + isFavorite: false, + }, + { + id: 'case-002', + title: '智能工厂物联网整体解决方案', + category: 'iot', + cover: '', + finishDate: '2026/8/10', + teamSize: 30, + scale: '500万', + industry: '工厂服务', + duration: '200个工作日', + clientType: '企业', + background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。', + painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。', + requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。', + deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。', + video: '', + isFavorite: false, + }, + { + id: 'case-003', + title: '智能穿戴设备PCB设计与量产', + category: 'hardware', + cover: '', + finishDate: '2026/8/10', + teamSize: 50, + scale: '10-50万', + industry: '智能设备', + duration: '30个工作日', + clientType: '企业', + background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。', + painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。', + requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。', + deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。', + video: '', + isFavorite: false, + }, + { + id: 'case-004', + title: '智能园区综合管理平台开发', + category: 'internet', + cover: '', + finishDate: '2026/8/10', + teamSize: 20, + scale: '200万', + industry: '园区服务', + duration: '200个工作日', + clientType: '企业', + background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。', + painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。', + requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。', + deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。', + video: '', + isFavorite: false, + }, + { + id: 'case-005', + title: '电商中台系统定制开发', + category: 'internet', + cover: '', + finishDate: '2026/7/20', + teamSize: 15, + scale: '150万', + industry: '电商零售', + duration: '120个工作日', + clientType: '企业', + background: '品牌方多渠道订单分散,库存与会员数据不通,需要统一电商中台。', + painPoints: '1、多平台库存不同步;2、会员体系割裂;3、营销活动上线周期长。', + requirements: '建设商品/库存/订单/会员中台,开放 API 对接主流电商平台。', + deliverables: '1、中台系统与管理后台;2、开放 API 与对接文档;3、数据迁移方案;4、上线陪跑服务。', + video: '', + isFavorite: false, + }, + { + id: 'case-006', + title: '教培机构在线学习平台', + category: 'internet', + cover: '', + finishDate: '2026/6/30', + teamSize: 12, + scale: '80万', + industry: '教育培训', + duration: '90个工作日', + clientType: '企业', + background: '教培机构需要线上授课、作业批改与学员管理一体化能力。', + painPoints: '1、授课依赖第三方工具,数据不沉淀;2、续费转化缺少数据支撑。', + requirements: '交付直播授课、题库作业、班级管理、数据分析完整平台(Web + 小程序)。', + deliverables: '1、Web 学习平台;2、学员端小程序;3、题库与直播模块;4、运维部署手册。', + video: '', + isFavorite: false, + }, + { + id: 'case-007', + title: '医院智能随访系统开发', + category: 'internet', + cover: '', + finishDate: '2026/5/18', + teamSize: 18, + scale: '250万', + industry: '医疗健康', + duration: '180个工作日', + clientType: '企业', + background: '三甲医院需要对出院患者进行结构化随访,提升复诊率与科研数据质量。', + painPoints: '1、电话随访效率低;2、随访数据非结构化,科研难复用。', + requirements: '建设随访计划引擎、患者端触达与随访数据仓,对接 HIS 系统。', + deliverables: '1、随访管理平台;2、患者端小程序;3、HIS 对接中间件;4、随访报表与数据字典。', + video: '', + isFavorite: false, + }, + { + id: 'case-008', + title: '园区光伏能耗管理系统', + category: 'iot', + cover: '', + finishDate: '2026/4/25', + teamSize: 10, + scale: '120万', + industry: '能源环保', + duration: '60个工作日', + clientType: '企业', + background: '园区部署屋顶光伏后需要实时监控发电与用电,实现削峰填谷。', + painPoints: '1、发电数据分散在多品牌逆变器;2、能耗缺少分项计量。', + requirements: '接入逆变器/电表数据,建设发电与能耗看板,输出节能策略。', + deliverables: '1、能耗采集网关适配;2、监控看板系统;3、告警与报表;4、验收测试报告。', + video: '', + isFavorite: false, + }, +] + +const SCALE_BOUNDS: Record = { + lt50w: [0, 50], + '50to100w': [50, 100], + '100to300w': [100, 300], + '300to500w': [300, 500], + gt500w: [500, Number.POSITIVE_INFINITY], +} + +/** 从展示值解析规模数值(万),如 '300万' → 300,'10-50万' → 10 */ +function parseScaleValue(scale: string): number { + const value = Number.parseFloat(scale) + return Number.isNaN(value) ? 0 : value +} + +function matchScale(item: Case, selected: ScaleRange[]): boolean { + if (selected.length === 0) return true + const value = parseScaleValue(item.scale) + return selected.some((key) => { + const [min, max] = SCALE_BOUNDS[key] + return value >= min && value < max + }) +} + +function matchDate(item: Case, range: [string, string] | null): boolean { + if (!range) return true + const time = new Date(item.finishDate).getTime() + const start = new Date(range[0]).getTime() + const end = new Date(range[1]).getTime() + return time >= start && time <= end +} + +function applyFilters(query: CaseQuery): Case[] { + return MOCK_CASES.filter((item) => { + if (query.category !== 'all' && item.category !== query.category) return false + if (query.industries.length > 0 && !query.industries.includes(item.industry)) return false + if (!matchScale(item, query.scales)) return false + if (!matchDate(item, query.dateRange)) return false + if (query.keyword && !item.title.includes(query.keyword)) return false + return true + }) +} + +/** 案例列表(筛选 + 分页) */ +export function getCaseList(query: CaseQuery): Promise> { + const filtered = applyFilters(query) + const start = (query.page - 1) * query.pageSize + return Promise.resolve({ + list: filtered.slice(start, start + query.pageSize), + total: filtered.length, + }) +} + +/** 案例详情(不存在返回 null) */ +export function getCaseDetail(id: string): Promise { + const found = MOCK_CASES.find((item) => item.id === id) ?? null + return Promise.resolve(found) +} + +/** 收藏/取消收藏,返回最新案例数据 */ +export function toggleFavorite(id: string): Promise { + const found = MOCK_CASES.find((item) => item.id === id) + if (!found) return Promise.reject(new Error('案例不存在')) + found.isFavorite = !found.isFavorite + return Promise.resolve({ ...found }) +} + +/** 业务类型计数(筛选面板角标) */ +export function getCaseCounts(): Promise>> { + const counts: Partial> = {} + for (const item of MOCK_CASES) { + counts[item.category] = (counts[item.category] ?? 0) + 1 + } + return Promise.resolve(counts) +} diff --git a/src/api/contract.ts b/src/api/contract.ts new file mode 100644 index 0000000..8ba4472 --- /dev/null +++ b/src/api/contract.ts @@ -0,0 +1,110 @@ +import type { FileInfo, PageResult } from '@/types/api' +import type { Contract, ContractQuery } from '@/types/business/contract' + +// TODO(api): 待接口文档到位后,将以下 mock 替换为 request 调用 + +/** 构造 mock 文件信息 */ +function mockFile(name: string, size: number, uploadTime: string): FileInfo { + return { name, size, uploadTime, url: '' } +} + +/** 详情 mock 所属合同 id */ +const DETAIL_ID = 'contract-001' + +/** 合同列表 mock(3 条,覆盖全部签署状态) */ +const MOCK_CONTRACTS: Contract[] = [ + { + id: 'contract-001', + contractNo: 'C8K2M202408001', + contractName: '智慧城市建设项目技术服务合同', + contractType: '互联网软件通用/app开发', + contractAmount: 201000, + relatedProjectName: '智慧城市大数据平台建设项目', + projectNo: 'P8K2M202408001', + signStatus: '履行中', + signDate: '2026-8-17 14:00', + effectiveDate: '2026-8-17 14:00', + expiryDate: '2026-8-17 14:00', + files: [mockFile('项目需求规格说明书.pdf', 2516582, '2026-8-17 14:00')], + }, + { + id: 'contract-002', + contractNo: 'C8K2M202408002', + contractName: '智慧城市建设项目硬件电路设计合同', + contractType: '硬件电路通用/电路版设计', + contractAmount: 180000, + relatedProjectName: '三甲医院指挥医疗信息化系统', + projectNo: 'P8K2M202408001', + signStatus: '已过期', + signDate: '2026-8-17 14:00', + effectiveDate: '2026-8-17 14:00', + expiryDate: '2026-8-17 14:00', + files: [mockFile('硬件电路设计合同.pdf', 1468006, '2026-8-17 14:00')], + }, + { + id: 'contract-003', + contractNo: 'C8K2M202408003', + contractName: '智慧城市建设项目物联网全案合同', + contractType: '物联网全案通用/一套门禁卡', + contractAmount: 96000, + relatedProjectName: '社区康养智能服务系统', + projectNo: 'P8K2M202408001', + signStatus: '待签署', + effectiveDate: '2026-8-17 14:00', + expiryDate: '2026-8-17 14:00', + files: [mockFile('物联网全案合同.pdf', 1258291, '2026-8-17 14:00')], + }, +] + +/** 合同详情 mock */ +const CONTRACT_DETAIL: Contract = { + id: DETAIL_ID, + contractNo: 'C8K2M202408001', + contractName: '社区康养智能服务系统开发合同', + contractType: '互联网软件开发', + contractAmount: 201000, + relatedProjectName: '社区康养智能服务系统', + projectNo: '20260813ZZ251245', + signStatus: '履行中', + signDate: '2024-10-15', + effectiveDate: '2024-10-15', + expiryDate: '2024-10-15', + files: [mockFile('项目需求规格说明书.pdf', 2516582, '2024-10-15')], +} + +/** 合同列表(keyword 筛选 + 分页) */ +export function getContractList(query: ContractQuery): Promise> { + const filtered = MOCK_CONTRACTS.filter((item) => { + if ( + query.keyword + && !item.contractName.includes(query.keyword) + && !item.contractNo.includes(query.keyword) + ) { + return false + } + return true + }) + const pageSize = query.pageSize > 0 ? query.pageSize : 8 + const start = (query.page - 1) * pageSize + return Promise.resolve({ + list: filtered.slice(start, start + pageSize), + total: filtered.length, + }) +} + +/** 合同详情(不存在返回 null) */ +export function getContractDetail(id: string): Promise { + if (id === DETAIL_ID) return Promise.resolve({ ...CONTRACT_DETAIL, files: [...CONTRACT_DETAIL.files] }) + const found = MOCK_CONTRACTS.find((item) => item.id === id) ?? null + return Promise.resolve(found ? { ...found, files: [...found.files] } : null) +} + +/** 获取签署二维码(返回有效期) */ +export function getSignQr(_id: string): Promise<{ expiresInSeconds: number }> { + return Promise.resolve({ expiresInSeconds: 272 }) +} + +/** 刷新签署二维码(返回有效期) */ +export function refreshSignQr(_id: string): Promise<{ expiresInSeconds: number }> { + return Promise.resolve({ expiresInSeconds: 272 }) +} diff --git a/src/api/demand.ts b/src/api/demand.ts new file mode 100644 index 0000000..f35d9f2 --- /dev/null +++ b/src/api/demand.ts @@ -0,0 +1,25 @@ +import type { Demand } from '@/types/business/demand' +import { storage } from '@/utils/storage' + +// TODO(api): 待接口文档到位后,将以下 mock 替换为 request 调用(页面 → store → api 数据流不变) + +const DRAFT_KEY = 'demand-draft' + +/** 提交需求(mock:返回提报单 id) */ +export function submitDemand(demand: Demand): Promise<{ id: string }> { + if (!demand.businessType || !demand.projectName || !demand.coreDescription) { + return Promise.reject(new Error('需求信息不完整,无法提交')) + } + return Promise.resolve({ id: `demand-${Date.now()}` }) +} + +/** 读取本地草稿(无草稿返回 null) */ +export function getDraftDemand(): Promise { + return Promise.resolve(storage.sessionGet(DRAFT_KEY)) +} + +/** 保存草稿到本地 */ +export function saveDraft(demand: Demand): Promise { + storage.sessionSet(DRAFT_KEY, { ...demand, status: 'draft' }) + return Promise.resolve() +} diff --git a/src/api/finance.ts b/src/api/finance.ts new file mode 100644 index 0000000..2c15a84 --- /dev/null +++ b/src/api/finance.ts @@ -0,0 +1,316 @@ +import type { PageResult } from '@/types/api' +import type { + Bill, + BillQuery, + FinanceStats, + Invoice, + InvoiceQuery, + InvoiceTitle, + InvoiceTitlePayload, + PayMethod, + Payment, + PaymentResult, +} from '@/types/business/finance' + +// TODO(api): 待接口文档到位后,将以下 mock 替换为 request 调用 + +/** 账单 mock(可变:支付后状态在前端内存中翻转演示) */ +const MOCK_BILLS: Bill[] = [ + { + id: 'bill-001', + planName: '第一期·预付款 比例33%', + billNo: '20260813ZZ251245', + tradeNo: 'T20260819153035678K2M001', + projectName: '某大型银行数字化转型方案', + amount: 20000, + status: '已支付', + createTime: '2026-8-17 14:00', + }, + { + id: 'bill-002', + planName: '第二期·进度款 比例30%', + billNo: '20260813ZZ251246', + projectName: '智慧城市大数据平台建设项目', + amount: 20100, + status: '待支付', + createTime: '2026-8-17 14:00', + }, + { + id: 'bill-003', + planName: '第三期·验收款 比例27%', + billNo: '20260813ZZ251247', + projectName: '三甲医院指挥医疗信息化系统', + amount: 20000, + status: '未到期', + createTime: '2026-8-17 14:00', + }, + { + id: 'bill-004', + planName: '第四期·尾款 比例10%', + billNo: '20260813ZZ251248', + projectName: '智慧城市大数据平台建设项目', + amount: 20100, + status: '未到期', + createTime: '2026-8-17 14:00', + }, + { + id: 'bill-005', + planName: '第二期·进度款 比例30%', + billNo: '20260813ZZ251249', + projectName: '三甲医院指挥医疗信息化系统', + amount: 20100, + status: '待支付', + createTime: '2026-8-16 10:30', + }, + { + id: 'bill-006', + planName: '第一期·预付款 比例33%', + billNo: '20260813ZZ251250', + tradeNo: 'T20260819153035678K2M002', + projectName: '某大型银行数字化转型方案', + amount: 20000, + status: '已支付', + createTime: '2026-8-15 09:12', + }, +] + +/** 发票 mock(可变:申请开票后状态在前端内存中翻转演示) */ +const MOCK_INVOICES: Invoice[] = [ + { + id: 'invoice-001', + planName: '第一期·预付款 比例33%', + billNo: '20260813ZZ251245', + tradeNo: 'T20260819153035678K2M001', + projectName: '某大型银行数字化转型方案', + amount: 20000, + status: '已开票', + applyTime: '2026-8-17 14:00', + }, + { + id: 'invoice-002', + planName: '第一期·预付款 比例33%', + billNo: '20260813ZZ251250', + tradeNo: 'T20260819153035678K2M002', + projectName: '某大型银行数字化转型方案', + amount: 20000, + status: '开票中', + applyTime: '2026-8-17 14:00', + }, + { + id: 'invoice-003', + planName: '第二期·进度款 比例30%', + billNo: '20260813ZZ251249', + projectName: '三甲医院指挥医疗信息化系统', + amount: 20100, + status: '未开票', + applyTime: '2026-8-17 14:00', + }, +] + +/** 发票抬头 mock(可变,CRUD 在前端内存中演示) */ +const MOCK_TITLES: InvoiceTitle[] = [ + { + id: 'title-001', + type: '企业单位', + title: '山东日照有限有限公司', + taxNo: '344683434513543543545535', + isDefault: true, + }, + { + id: 'title-002', + type: '企业单位', + title: '山东省一周练七次有限公司', + taxNo: '6856754615216565476846', + isDefault: false, + }, +] + +/** 支付进行单(billId → 当前二维码) */ +const MOCK_PAYMENTS = new Map() + +/** 已产生支付结果的账单(billId → 结果),预置已支付账单 */ +const MOCK_RESULTS = new Map() +MOCK_RESULTS.set('bill-001', { + method: 'wechat', + amount: 20000, + payTime: '2026-01-21 15:00:00', + tradeNo: 'T20260819153035678K2M001', + bills: [{ ...MOCK_BILLS[0] }], +}) +MOCK_RESULTS.set('bill-006', { + method: 'wechat', + amount: 20000, + payTime: '2026-01-21 15:00:00', + tradeNo: 'T20260819153035678K2M002', + bills: [{ ...MOCK_BILLS[5] }], +}) + +const DEFAULT_STATS: FinanceStats = { + totalExpense: 1726823900, + month: '2026年1', + pendingCount: 3, + distribution: [ + { name: '生产智造', value: 25, color: '#3370ff' }, + { name: '物联网全案', value: 25, color: '#34c724' }, + { name: '硬件电路', value: 25, color: '#ff8f1f' }, + { name: '互联网软件', value: 25, color: '#f54a45' }, + ], +} + +/** 财务统计(period 为展示周期参数,mock 恒返回原型数据) */ +export function getFinanceStats(period?: string): Promise { + void period + return Promise.resolve(structuredClone(DEFAULT_STATS)) +} + +function matchBillKeyword(item: Bill, keyword: string): boolean { + if (!keyword) return true + return ( + item.planName.includes(keyword) || + item.billNo.includes(keyword) || + item.projectName.includes(keyword) || + (item.tradeNo?.includes(keyword) ?? false) + ) +} + +/** 账单列表(筛选 + 分页) */ +export function getBillList(query: BillQuery): Promise> { + const filtered = MOCK_BILLS.filter((item) => { + if (query.status !== 'all' && item.status !== query.status) return false + return matchBillKeyword(item, query.keyword) + }) + const start = (query.page - 1) * query.pageSize + return Promise.resolve({ + list: filtered.slice(start, start + query.pageSize).map((item) => ({ ...item })), + total: filtered.length, + }) +} + +/** 账单详情(不存在返回 null) */ +export function getBillDetail(id: string): Promise { + const found = MOCK_BILLS.find((item) => item.id === id) ?? null + return Promise.resolve(found ? { ...found } : null) +} + +/** 创建支付单(生成二维码支付信息) */ +export function createPayment(billId: string, method: PayMethod): Promise { + const bill = MOCK_BILLS.find((item) => item.id === billId) + if (!bill) return Promise.reject(new Error('账单不存在')) + const payment: Payment = { + billId, + method, + amount: bill.amount, + qrExpiresInSeconds: 300, + } + MOCK_PAYMENTS.set(billId, payment) + return Promise.resolve({ ...payment }) +} + +/** 切换支付方式(刷新二维码) */ +export function switchPaymentMethod(billId: string, method: PayMethod): Promise { + return createPayment(billId, method) +} + +/** 查询支付结果(无支付记录且未支付返回 null;演示数据 payTime/tradeNo 固定) */ +export function getPayResult(billId: string): Promise { + const existing = MOCK_RESULTS.get(billId) + if (existing) return Promise.resolve(structuredClone(existing)) + + const payment = MOCK_PAYMENTS.get(billId) + const bill = MOCK_BILLS.find((item) => item.id === billId) + if (!payment || !bill) return Promise.resolve(null) + + // 演示:存在支付单即视为完成支付(createPayment 由「完成支付」触发) + const tradeNo = bill.tradeNo ?? 'T20260819153035678K2M001' + if (bill.status !== '已支付') { + bill.status = '已支付' + bill.tradeNo = tradeNo + } + const result: PaymentResult = { + method: payment.method, + amount: payment.amount, + payTime: '2026-01-21 15:00:00', + tradeNo, + bills: [{ ...bill }], + } + MOCK_RESULTS.set(billId, result) + return Promise.resolve(structuredClone(result)) +} + +function matchInvoiceKeyword(item: Invoice, keyword: string): boolean { + if (!keyword) return true + return ( + item.planName.includes(keyword) || + item.billNo.includes(keyword) || + item.projectName.includes(keyword) || + (item.tradeNo?.includes(keyword) ?? false) + ) +} + +/** 发票列表(筛选 + 分页) */ +export function getInvoiceList(query: InvoiceQuery): Promise> { + const filtered = MOCK_INVOICES.filter((item) => { + if (query.status !== 'all' && item.status !== query.status) return false + return matchInvoiceKeyword(item, query.keyword) + }) + const start = (query.page - 1) * query.pageSize + return Promise.resolve({ + list: filtered.slice(start, start + query.pageSize).map((item) => ({ ...item })), + total: filtered.length, + }) +} + +/** 申请开票(演示:状态置为开票中),返回最新发票 */ +export function applyInvoice(id: string): Promise { + const found = MOCK_INVOICES.find((item) => item.id === id) + if (!found) return Promise.reject(new Error('发票记录不存在')) + found.status = '开票中' + return Promise.resolve({ ...found }) +} + +/** 发票抬头列表 */ +export function getInvoiceTitles(): Promise { + return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item }))) +} + +/** 新增发票抬头 */ +export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise { + const created: InvoiceTitle = { + id: `title-${Date.now()}`, + ...payload, + isDefault: false, + } + MOCK_TITLES.push(created) + return Promise.resolve({ ...created }) +} + +/** 编辑发票抬头 */ +export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Promise { + const found = MOCK_TITLES.find((item) => item.id === id) + if (!found) return Promise.reject(new Error('抬头不存在')) + found.type = payload.type + found.title = payload.title + found.taxNo = payload.taxNo + return Promise.resolve({ ...found }) +} + +/** 删除发票抬头 */ +export function deleteInvoiceTitle(id: string): Promise { + const index = MOCK_TITLES.findIndex((item) => item.id === id) + if (index < 0) return Promise.reject(new Error('抬头不存在')) + MOCK_TITLES.splice(index, 1) + return Promise.resolve() +} + +/** 设为默认抬头(唯一默认) */ +export function setDefaultTitle(id: string): Promise { + for (const item of MOCK_TITLES) { + item.isDefault = item.id === id + } + return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item }))) +} + +/** 导出报表(演示:稍后由页面提示成功) */ +export function exportReport(): Promise { + return Promise.resolve() +} diff --git a/src/api/home.ts b/src/api/home.ts new file mode 100644 index 0000000..5adde7d --- /dev/null +++ b/src/api/home.ts @@ -0,0 +1,180 @@ +import type { BusinessType } from '@/types/api' +import type { Case } from '@/types/business/cases' +import type { BusinessLine, HomeStats, ServiceType } from '@/types/business/home' + +// TODO(api): 待接口文档到位后,将以下 mock 返回替换为 request 调用(页面 → store → api 数据流不变) + +const SERVICE_TYPES: ServiceType[] = [ + { label: 'Web应用', desc: '响应式 Web 与后台管理系统定制开发' }, + { label: '小程序', desc: '微信/支付宝小程序开发与上架' }, + { label: 'APP', desc: 'iOS/Android 移动应用定制开发' }, + { label: '二次开发', desc: '基于现有系统的功能扩展与重构' }, + { label: '桌面工具', desc: '跨平台桌面端工具与自动化软件' }, +] + +const SERVICE_CONTENT = `一、产品咨询与规划服务 +1、业务诊断与梳理:深入调研客户现有业务流程,识别数字化痛点与改进机会。 +2、竞品分析与市场调研:分析行业标杆产品,提炼差异化竞争优势。 +3、产品定位与MVP规划:明确核心用户群体,规划最小可行性产品(MVP)功能集,制定迭代路线图。 +4、技术可行性评估:评估所选技术栈的成熟度、扩展性及与现有系统的兼容性。 +二、UI/UX 设计服务 +1、用户研究与体验地图:构建用户画像,梳理用户旅程,优化关键触点体验。 +2、交互原型设计:制作高保真可交互原型,直观展示产品逻辑与操作流程,支持早期验证。 +3、视觉UI设计:遵循最新设计趋势与品牌规范,输出图标、界面、动效等视觉元素。 +4、设计规范与组件库:建立统一的设计系统(Design System),确保多端体验一致性,提升后续迭代效率。 +三、技术开发服务 +5、前端开发:基于 React/Vue/Flutter 等框架,实现响应式Web、H5、小程序及跨平台移动端应用。 +6、后端开发:采用 Spring Cloud、Node.js、Go 等微服务架构,构建高并发、高可用的业务接口与逻辑。 +7、数据库设计:根据业务特性设计关系型(MySQL)与非关系型(Redis、MongoDB)数据库集群方案。 +8、DevOps 与云服务部署:搭建 CI/CD 自动化流水线,基于 Docker/K8s 进行容器化部署,保障系统弹性伸缩。 +四、质量保障(QA)服务 +1、功能测试:覆盖单元测试、集成测试、系统测试及UAT(用户验收测试)支持。 +2、性能测试:进行压力测试、负载测试,定位系统瓶颈,优化响应速度。 +3、安全测试:实施渗透测试、漏洞扫描,保障数据安全与隐私合规。 +4、兼容性测试:覆盖主流浏览器、操作系统及移动设备型号。 +五、运维与持续运营服务 +1、7x24小时监控:实时监控系统健康度、流量及异常日志。 +2、数据运营分析:接入埋点系统,输出用户行为分析报告,指导产品优化。 +3、迭代升级:基于用户反馈与业务增长需求,提供持续的功能迭代与版本更新。` + +const BUSINESS_LINES: BusinessLine[] = [ + { + id: 'internet', + name: '互联网软件', + desc: 'Web/小程序/APP/二次开发/桌面工具等', + slogan: '专业的软件定制开发服务,从需求到上线全流程保障', + icon: '', + serviceContent: SERVICE_CONTENT, + serviceTypes: SERVICE_TYPES, + video: '', + }, + { + id: 'hardware', + name: '硬件电路', + desc: '原理图设计/PCB设计/电气效验等', + slogan: '专业的硬件电路设计服务,从方案到量产全流程保障', + icon: '', + serviceContent: SERVICE_CONTENT, + serviceTypes: SERVICE_TYPES, + video: '', + }, + { + id: 'iot', + name: '物联网全案', + desc: '软硬件开发+云端一体化/定制化开发等', + slogan: '专业的物联网软硬一体化服务,从感知到云端全流程保障', + icon: '', + serviceContent: SERVICE_CONTENT, + serviceTypes: SERVICE_TYPES, + video: '', + }, + { + id: 'manufacturing', + name: '生产制造', + desc: 'PCB制版/SMT/整机装配/定制生产等', + slogan: '专业的研发智造生产服务,从制版到交付全流程保障', + icon: '', + serviceContent: SERVICE_CONTENT, + serviceTypes: SERVICE_TYPES, + video: '', + }, +] + +const FEATURED_CASES: Case[] = [ + { + id: 'case-001', + title: '自动化生产线升级改造项目', + category: 'manufacturing', + cover: '', + finishDate: '2026/8/10', + teamSize: 50, + scale: '300万', + industry: '自动化', + duration: '150个工作日', + clientType: '企业', + background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。', + painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。', + requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。', + deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。', + video: '', + isFavorite: false, + }, + { + id: 'case-002', + title: '智能工厂物联网整体解决方案', + category: 'iot', + cover: '', + finishDate: '2026/8/10', + teamSize: 30, + scale: '500万', + industry: '工厂服务', + duration: '200个工作日', + clientType: '企业', + background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。', + painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。', + requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。', + deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。', + video: '', + isFavorite: false, + }, + { + id: 'case-003', + title: '智能穿戴设备PCB设计与量产', + category: 'hardware', + cover: '', + finishDate: '2026/8/10', + teamSize: 50, + scale: '10-50万', + industry: '智能设备', + duration: '30个工作日', + clientType: '企业', + background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。', + painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。', + requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。', + deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。', + video: '', + isFavorite: false, + }, + { + id: 'case-004', + title: '智能园区综合管理平台开发', + category: 'internet', + cover: '', + finishDate: '2026/8/10', + teamSize: 20, + scale: '200万', + industry: '园区服务', + duration: '200个工作日', + clientType: '企业', + background: '随着新型城镇化建设的加速和"数字中国"战略的深入推进,传统园区管理模式正面临严峻挑战,普遍存在"数据孤岛"现象。', + painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。', + requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒。', + deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。', + video: '', + isFavorite: false, + }, +] + +/** 首页数据看板 */ +export function getHomeStats(): Promise { + return Promise.resolve({ pending: 3, inProgress: 3, total: 6 }) +} + +/** 业务线列表(首页 4 卡片) */ +export function getBusinessLines(): Promise { + return Promise.resolve(BUSINESS_LINES) +} + +/** 业务详情(业务详情页) */ +export function getBusinessDetail(type: BusinessType): Promise { + const found = BUSINESS_LINES.find((line) => line.id === type) + return Promise.resolve(found ?? BUSINESS_LINES[0]!) +} + +/** 首页案例精选(category=all 时返回全部) */ +export function getFeaturedCases(category: BusinessType | 'all' = 'all'): Promise { + const list = category === 'all' + ? FEATURED_CASES + : FEATURED_CASES.filter((item) => item.category === category) + return Promise.resolve(list) +} diff --git a/src/api/message.ts b/src/api/message.ts new file mode 100644 index 0000000..29ef993 --- /dev/null +++ b/src/api/message.ts @@ -0,0 +1,153 @@ +import type { PageResult } from '@/types/api' +import type { Message, MessageQuery } from '@/types/business/message' + +// TODO(api): 待接口文档到位后,将以下 mock 替换为 request 调用 + +/** 消息 mock(可变,已读状态在前端内存中翻转演示) */ +const MOCK_MESSAGES: Message[] = [ + { + id: 'msg-001', + type: '系统通知', + title: '【新功能】物联网全案"整机联调"看板正式上线', + summary: '整机联调看板已上线,支持联调进度、问题闭环与交付物一览。', + content: '尊敬的用户:\n\n为了提升物联网全案项目的交付透明度,平台已正式上线"整机联调"看板功能。\n\n一、功能亮点\n1、联调进度实时同步:设备接入、协议联调、场景验证各阶段进度一目了然;\n2、问题闭环跟踪:联调过程中产生的缺陷与阻塞项统一登记、分派、闭环;\n3、交付物一览:联调报告、测试记录、验收文档集中展示,随时查阅下载。\n\n二、使用方式\n进入「项目中心 → 项目详情 → 整机联调」即可查看对应看板。已有进行中的物联网全案项目将自动迁移历史数据,无需重新配置。\n\n三、温馨提示\n如在使用过程中遇到问题,可通过「在线客服」联系我们,或联系专属项目经理协助处理。\n\n智联智造平台产品团队\n2026年8月', + publishTime: '2026-08-06 10:00', + isRead: false, + attachments: [ + { name: '物联网全案服务介绍.pdf', size: 2.5 * 1024 * 1024, uploadTime: '2026-08-06 10:00', url: '' }, + ], + }, + { + id: 'msg-002', + type: '系统通知', + title: '平台 v2.6 版本更新公告', + summary: '新增需求提报模板、案例库对比功能,并优化了消息中心体验。', + content: '尊敬的用户:\n\n平台 v2.6 版本已于近日发布,本次更新内容如下:\n\n一、新增功能\n1、需求提报模板:常用需求类型支持保存为模板,一键复用;\n2、案例库对比:最多选择 4 个案例进行横向对比,辅助决策;\n3、消息中心改版:分类标签、关键词搜索、全部已读一站式处理。\n\n二、体验优化\n1、列表页加载性能提升约 40%;\n2、移动端适配细节修复;\n3、附件上传支持 1GB 大文件。\n\n感谢您一直以来的支持,祝使用愉快!', + publishTime: '2026-07-15 14:30', + isRead: false, + attachments: [], + }, + { + id: 'msg-003', + type: '系统通知', + title: '关于平台服务时间调整的通知', + summary: '自 2026-09-01 起,在线客服服务时间调整为 9:00-21:00。', + content: '尊敬的用户:\n\n为进一步提升服务质量,自 2026 年 9 月 1 日起,在线客服服务时间调整如下:\n\n服务时间:每日 9:00 - 21:00(法定节假日照常服务)\n非服务时段提交的留言,我们将在下一个服务时段内尽快回复。\n\n紧急事务可通过专属项目经理电话联系。\n\n感谢您的理解与支持!', + publishTime: '2026-08-28 09:00', + isRead: true, + attachments: [], + }, + { + id: 'msg-004', + type: '状态通知', + title: '合同生效通知', + summary: '您的合同已正式生效,合同编号 CZ-2026-0815 已开始生效。', + content: '您好:\n\n您的合同已正式生效。\n\n合同编号:CZ-2026-0815\n合同名称:智能园区综合管理平台开发合同\n生效日期:2026-08-15\n\n合同已开始生效,可随时在「合同中心」查看详情及履约进度。\n\n如有疑问,请联系您的专属项目经理。', + publishTime: '2026-08-15 16:42', + isRead: false, + attachments: [], + }, + { + id: 'msg-005', + type: '状态通知', + title: '项目进度更新:需求确认阶段已完成', + summary: '「智能工厂物联网整体解决方案」需求确认已完成,即将进入方案设计。', + content: '您好:\n\n您的项目进度已更新:\n\n项目名称:智能工厂物联网整体解决方案\n当前阶段:需求确认 ✅ 已完成\n下一阶段:方案设计\n\n项目组将于 3 个工作日内提交初步方案,届时会再次通知您。您也可以前往「项目中心」查看详细进度与里程碑。\n\n感谢您的配合!', + publishTime: '2026-08-20 11:05', + isRead: false, + attachments: [], + }, + { + id: 'msg-006', + type: '状态通知', + title: '账单待办提醒', + summary: '您有一笔待付款账单,账单编号 BILL-202608-023,请及时处理。', + content: '您好:\n\n您有一笔待付款账单,请及时处理:\n\n账单编号:BILL-202608-023\n关联项目:电商中台系统定制开发\n账单金额:150,000.00 元\n到期日期:2026-09-10\n\n请前往「财务中心 → 账单管理」完成支付。如对账单有疑问,请通过在线客服联系我们。\n\n温馨提示:逾期支付可能影响项目排期,请留意处理时间。', + publishTime: '2026-08-25 10:00', + isRead: true, + attachments: [], + }, + { + id: 'msg-007', + type: '在线客服', + title: '您好,请问有什么可以帮您的?', + summary: '专属客服小助手已收到您的咨询,将尽快为您答复。', + content: '您好,欢迎咨询智联智造平台!\n\n我是您的专属客服小助手,请问有什么可以帮您的?\n\n您可以咨询:\n1、物联网全案开发的流程与周期\n2、项目进度查询\n3、合同与账单相关问题\n4、平台操作指引\n\n我们将在服务时间内尽快回复您。', + publishTime: '2026-08-21 10:25', + isRead: false, + attachments: [], + }, + { + id: 'msg-008', + type: '在线客服', + title: '您的咨询已有回复', + summary: '关于"整机联调周期"的咨询已回复,点击查看详细内容。', + content: '您好:\n\n关于您咨询的"整机联调周期"问题,客服回复如下:\n\n一般物联网全案项目的整机联调周期为 2-4 周,具体取决于设备数量、协议复杂度与场景验证范围。常规智能家居类项目约 2 周,工业场景多设备联调约 3-4 周。\n\n详细排期以项目计划为准,您可在「项目中心」查看最新里程碑。\n\n如还有其他问题,欢迎继续咨询!', + publishTime: '2026-08-19 15:40', + isRead: true, + attachments: [], + }, + { + id: 'msg-009', + type: '在线客服', + title: '会话满意度回访', + summary: '您上次的会话已结束,欢迎对本次服务进行评价。', + content: '您好:\n\n您于 2026-08-18 的会话已结束,感谢您的咨询!\n\n欢迎对本次服务进行评价,您的反馈将帮助我们持续改进。如问题未得到解决,可直接回复本条消息,我们将重新为您跟进。\n\n祝您使用愉快!', + publishTime: '2026-08-18 17:00', + isRead: false, + attachments: [], + }, +] + +function applyFilters(query: MessageQuery): Message[] { + return MOCK_MESSAGES.filter((item) => { + if (query.type !== 'all' && item.type !== query.type) return false + if (query.keyword && !item.title.includes(query.keyword) && !item.summary.includes(query.keyword)) return false + return true + }) +} + +/** 消息列表(筛选 + 分页) */ +export function getMessageList(query: MessageQuery): Promise> { + const filtered = applyFilters(query) + const start = (query.page - 1) * query.pageSize + return Promise.resolve({ + list: filtered.slice(start, start + query.pageSize), + total: filtered.length, + }) +} + +/** 消息详情(不存在返回 null) */ +export function getMessageDetail(id: string): Promise { + const found = MOCK_MESSAGES.find((item) => item.id === id) ?? null + return Promise.resolve(found ? { ...found } : null) +} + +/** 全部标记已读 */ +export function markAllRead(): Promise { + for (const item of MOCK_MESSAGES) { + item.isRead = true + } + return Promise.resolve() +} + +/** 单条标记已读 */ +export function markRead(id: string): Promise { + const found = MOCK_MESSAGES.find((item) => item.id === id) + if (found) found.isRead = true + return Promise.resolve() +} + +/** 各分类消息总数(tab 角标) */ +export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状态通知: number; 系统通知: number }> { + const counts = { all: MOCK_MESSAGES.length, 在线客服: 0, 状态通知: 0, 系统通知: 0 } + for (const item of MOCK_MESSAGES) { + counts[item.type] += 1 + } + return Promise.resolve(counts) +} + +/** 未读消息总数(头部角标) */ +export function getUnreadCount(): Promise { + return Promise.resolve(MOCK_MESSAGES.filter((item) => !item.isRead).length) +} diff --git a/src/api/profile.ts b/src/api/profile.ts new file mode 100644 index 0000000..06b5017 --- /dev/null +++ b/src/api/profile.ts @@ -0,0 +1,418 @@ +import type { BusinessType, PageResult } from '@/types/api' +import type { Case } from '@/types/business/cases' +import type { + Address, + ChangePasswordParams, + Feedback, + FavoritesQuery, + HelpFaqGroup, + ListQuery, + Profile, + Qualification, + QualificationInput, +} from '@/types/business/profile' + +// TODO(api): 待接口文档到位后,将以下 mock 替换为 request 调用(页面 → store → api 数据流不变) + +/** 账号资料 mock(可变,昵称/绑定等操作在前端内存中演示) */ +const MOCK_PROFILE: Profile = { + nickname: '王永志', + phone: '186****618', + email: 'yongzh******4@163.com', + passwordModifiedAt: '15天前', +} + +/** 获取账号资料 */ +export function getProfile(): Promise { + return Promise.resolve({ ...MOCK_PROFILE }) +} + +/** 修改昵称 */ +export function updateNickname(name: string): Promise { + MOCK_PROFILE.nickname = name + return Promise.resolve({ ...MOCK_PROFILE }) +} + +/** 修改密码(验证码 + 新密码) */ +export function changePassword(params: ChangePasswordParams): Promise { + MOCK_PROFILE.passwordModifiedAt = '刚刚' + void params + return Promise.resolve() +} + +/** 绑定/换绑手机号 */ +export function bindPhone(phone: string, code: string): Promise { + void code + MOCK_PROFILE.phone = phone + return Promise.resolve({ ...MOCK_PROFILE }) +} + +/** 绑定/换绑邮箱 */ +export function bindEmail(email: string, code: string): Promise { + void code + MOCK_PROFILE.email = email + return Promise.resolve({ ...MOCK_PROFILE }) +} + +/** 取消绑定邮箱 */ +export function unbindEmail(): Promise { + MOCK_PROFILE.email = '' + return Promise.resolve({ ...MOCK_PROFILE }) +} + +/** 注销前安全身份验证 */ +export function verifyIdentity(phone: string, code: string): Promise { + void phone + void code + return Promise.resolve() +} + +/** 注销账号 */ +export function deleteAccount(): Promise { + return Promise.resolve() +} + +/** 收货地址 mock(isDefault 混合) */ +const MOCK_ADDRESSES: Address[] = [ + { + id: 'addr-1', + receiverName: '王永志', + phone: '18556669516', + region: '山东省日照市东港区', + detailAddress: '凌云购物广场三楼', + isDefault: true, + }, + { + id: 'addr-2', + receiverName: '王永志', + phone: '18556669516', + region: '山东省日照市东港区', + detailAddress: '海曲东路 12 号研发中心 A 座 8 层', + isDefault: false, + }, + { + id: 'addr-3', + receiverName: '李晓婷', + phone: '13863331234', + region: '山东省青岛市市南区', + detailAddress: '香港中路 76 号 A 座 18 层', + isDefault: false, + }, + { + id: 'addr-4', + receiverName: '王永志', + phone: '18556669516', + region: '山东省济南市历下区', + detailAddress: '经十路 9999 号舜耕大厦 12 层', + isDefault: false, + }, +] + +function paginate(list: T[], query: ListQuery): PageResult { + const start = (query.page - 1) * query.pageSize + return { + list: list.slice(start, start + query.pageSize), + total: list.length, + } +} + +/** 收货地址列表(分页) */ +export function getAddresses(query: ListQuery): Promise> { + return Promise.resolve(paginate(MOCK_ADDRESSES, query)) +} + +/** 新增收货地址 */ +export function createAddress(input: Omit): Promise
{ + const created: Address = { + ...input, + id: `addr-${Date.now()}`, + isDefault: MOCK_ADDRESSES.length === 0, + } + MOCK_ADDRESSES.push(created) + return Promise.resolve({ ...created }) +} + +/** 编辑收货地址 */ +export function updateAddress(id: string, input: Omit): Promise
{ + const found = MOCK_ADDRESSES.find((item) => item.id === id) + if (!found) return Promise.reject(new Error('地址不存在')) + Object.assign(found, input) + return Promise.resolve({ ...found }) +} + +/** 删除收货地址 */ +export function deleteAddress(id: string): Promise { + const index = MOCK_ADDRESSES.findIndex((item) => item.id === id) + if (index >= 0) MOCK_ADDRESSES.splice(index, 1) + return Promise.resolve() +} + +/** 设为默认地址 */ +export function setDefaultAddress(id: string): Promise { + for (const item of MOCK_ADDRESSES) { + item.isDefault = item.id === id + } + return Promise.resolve(MOCK_ADDRESSES.map((item) => ({ ...item }))) +} + +/** 我的收藏 mock(4 条已收藏案例,字段与案例库原型一致) */ +const MOCK_FAVORITES: Case[] = [ + { + id: 'fav-1', + title: '自动化生产线升级改造项目', + category: 'manufacturing', + cover: '', + finishDate: '2026/8/10', + teamSize: 50, + scale: '300万', + industry: '自动化', + duration: '150个工作日', + clientType: '企业', + background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。', + painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。', + requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。', + deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。', + video: '', + isFavorite: true, + }, + { + id: 'fav-2', + title: '智能工厂物联网整体解决方案', + category: 'iot', + cover: '', + finishDate: '2026/8/10', + teamSize: 30, + scale: '500万', + industry: '工厂服务', + duration: '200个工作日', + clientType: '企业', + background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。', + painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。', + requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。', + deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。', + video: '', + isFavorite: true, + }, + { + id: 'fav-3', + title: '智能穿戴设备PCB设计与量产', + category: 'hardware', + cover: '', + finishDate: '2026/8/10', + teamSize: 50, + scale: '10-50万', + industry: '智能设备', + duration: '30个工作日', + clientType: '企业', + background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。', + painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。', + requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。', + deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。', + video: '', + isFavorite: true, + }, + { + id: 'fav-4', + title: '智能园区综合管理平台开发', + category: 'internet', + cover: '', + finishDate: '2026/8/10', + teamSize: 20, + scale: '200万', + industry: '园区服务', + duration: '200个工作日', + clientType: '企业', + background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。', + painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。', + requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。', + deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。', + video: '', + isFavorite: true, + }, +] + +/** 我的收藏(关键字/分类筛选 + 分页) */ +export function getFavorites(query: FavoritesQuery): Promise> { + const filtered = MOCK_FAVORITES.filter((item) => { + if (query.category !== 'all' && item.category !== query.category) return false + if (query.keyword && !item.title.includes(query.keyword)) return false + return true + }) + const start = (query.page - 1) * query.pageSize + return Promise.resolve({ + list: filtered.slice(start, start + query.pageSize).map((item) => ({ ...item })), + total: filtered.length, + }) +} + +/** 收藏分类计数(分类快捷按钮角标) */ +export function getCaseCounts(): Promise>> { + const counts: Partial> = { + internet: 1, + hardware: 1, + iot: 1, + manufacturing: 1, + } + return Promise.resolve(counts) +} + +/** 资质 mock(状态混合:已认证/审核中/未通过) */ +const MOCK_QUALIFICATIONS: Qualification[] = [ + { + id: 'qual-1', + type: '体系认证', + certName: 'ISO9001 质量管理体系认证证书', + certNo: 'ISO202609080075', + issuer: '中国质量认证中心', + validFrom: '2026-9-10', + validTo: '2027-9-10', + status: '已认证', + attachments: [ + { name: '体系认证证书', size: 1_048_576, uploadTime: '2026-09-10', url: '' }, + ], + }, + { + id: 'qual-2', + type: '营业执照', + certName: '营业执照', + certNo: '91371102MA3XX8876Q', + issuer: '日照市东港区市场监督管理局', + validFrom: '2020-3-15', + validTo: '2030-3-14', + status: '审核中', + attachments: [ + { name: '营业执照', size: 820_000, uploadTime: '2026-09-12', url: '' }, + ], + }, + { + id: 'qual-3', + type: '检测报告', + certName: '智能终端产品第三方检测报告', + certNo: 'WT20260512001', + issuer: '山东省产品质量检验研究院', + validFrom: '2026-5-12', + validTo: '2027-5-12', + status: '未通过', + attachments: [ + { name: '检测报告', size: 2_097_152, uploadTime: '2026-05-15', url: '' }, + ], + }, + { + id: 'qual-4', + type: '生产许可证', + certName: '全国工业产品生产许可证', + certNo: 'XK06-123-45678', + issuer: '山东省市场监督管理局', + validFrom: '2024-1-8', + validTo: '2029-1-7', + status: '已认证', + attachments: [ + { name: '生产许可证', size: 1_258_291, uploadTime: '2024-01-10', url: '' }, + ], + }, +] + +/** 资质列表(分页) */ +export function getQualifications(query: ListQuery): Promise> { + return Promise.resolve(paginate(MOCK_QUALIFICATIONS, query)) +} + +/** 新增资质(提交后进入审核中) */ +export function createQualification(input: QualificationInput): Promise { + const created: Qualification = { + ...input, + id: `qual-${Date.now()}`, + status: '审核中', + } + MOCK_QUALIFICATIONS.unshift(created) + return Promise.resolve({ ...created }) +} + +/** 编辑资质(未通过重新提交后回到审核中) */ +export function updateQualification(id: string, input: QualificationInput): Promise { + const found = MOCK_QUALIFICATIONS.find((item) => item.id === id) + if (!found) return Promise.reject(new Error('资质不存在')) + found.type = input.type + found.certName = input.certName + found.certNo = input.certNo + found.issuer = input.issuer + found.validFrom = input.validFrom + found.validTo = input.validTo + found.attachments = input.attachments + if (found.status === '未通过') found.status = '审核中' + return Promise.resolve({ ...found }) +} + +/** 删除资质 */ +export function deleteQualification(id: string): Promise { + const index = MOCK_QUALIFICATIONS.findIndex((item) => item.id === id) + if (index >= 0) MOCK_QUALIFICATIONS.splice(index, 1) + return Promise.resolve() +} + +/** 提交意见反馈 */ +export function submitFeedback(feedback: Feedback): Promise { + void feedback + return Promise.resolve() +} + +/** 帮助中心 FAQ */ +export function getHelpFaqs(): Promise { + return Promise.resolve([ + { + title: '1. 账户与登录', + items: [ + { + q: '如何注册账号?', + a: '点击页面右上角"注册",输入手机号并获取验证码,设置 6-20 位登录密码即可完成注册。', + }, + { + q: '忘记密码怎么办?', + a: '在登录页点击"忘记密码",通过绑定手机号验证码验证身份后即可重新设置密码。', + }, + { + q: '一个账号可以多地登录吗?', + a: '同一账号同时仅允许在 1 台设备上保持在线,超出将自动踢出最早登录的设备。', + }, + { + q: '如何修改绑定手机号?', + a: '进入 个人中心 → 账号安全 → 绑定手机,验证原手机号后即可绑定新号码。', + }, + ], + }, + { + title: '2. 功能使用指南', + items: [ + { + q: '如何提交需求?', + a: '进入"发布需求"页面,填写需求描述、预算、周期等信息并提交,专业顾问将在一个工作日内与您联系。', + }, + { + q: '如何查看我的消息?', + a: '登录后点击右上角消息图标,或进入 个人中心 → 消息通知,即可查看系统通知与顾问消息。', + }, + { + q: '如何申请售后?', + a: '在"我的订单"中找到对应项目,点击"申请售后"填写问题描述并上传附件,平台将尽快为您处理。', + }, + ], + }, + { + title: '3. 系统与权限', + items: [ + { + q: '无权限怎么办?', + a: '请确认当前账号角色是否具备该模块权限;如仍无法访问,请联系专属客服或平台管理员为您开通。', + }, + { + q: '收不到通知怎么办?', + a: '请检查浏览器通知权限与消息设置是否开启;若仍未收到,请联系客服核实账号通知配置。', + }, + { + q: '支持哪些浏览器?', + a: '推荐使用 Chrome、Edge、Firefox、Safari 等主流浏览器的最新版本访问本平台。', + }, + ], + }, + ]) +} diff --git a/src/api/project.ts b/src/api/project.ts new file mode 100644 index 0000000..fdccd00 --- /dev/null +++ b/src/api/project.ts @@ -0,0 +1,270 @@ +import type { FileInfo, PageResult } from '@/types/api' +import type { + AfterSales, + AfterSalesPayload, + Project, + ProjectQuery, +} from '@/types/business/project' +import { formatDate } from '@/utils/format' + +// TODO(api): 待接口文档到位后,将以下 mock 替换为 request 调用 + +/** 构造 mock 文件信息 */ +function mockFile(name: string, size: number, uploadTime: string): FileInfo { + return { name, size, uploadTime, url: '' } +} + +/** 详情 mock 所属项目 id */ +const DETAIL_ID = 'project-004' + +/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合) */ +const MOCK_PROJECTS: Project[] = [ + { + id: 'project-001', + projectNo: 'P8K2M202408001', + projectName: '某大型银行数字化转型方案', + businessType: 'internet', + progress: 64, + status: '待立项', + submitTime: '2026-8-17 14:00', + milestones: [], + team: [], + files: [], + paymentPlans: [], + invoiceRecords: [], + }, + { + id: 'project-002', + projectNo: 'P8K2M202408002', + projectName: '智慧城市大数据平台建设项目', + businessType: 'iot', + progress: 64, + status: '待验收', + submitTime: '2026-8-17 14:00', + milestones: [], + team: [], + files: [], + paymentPlans: [], + invoiceRecords: [], + }, + { + id: 'project-003', + projectNo: 'P8K2M202408003', + projectName: '三甲医院指挥医疗信息化系统', + businessType: 'internet', + progress: 64, + status: '已完成', + submitTime: '2026-8-17 14:00', + milestones: [], + team: [], + files: [], + paymentPlans: [], + invoiceRecords: [], + }, + { + id: DETAIL_ID, + projectNo: '20260813RJ251545', + projectName: '社区康养智能服务系统', + businessType: 'internet', + progress: 64, + status: '进行中', + submitTime: '2026-8-17 14:00', + milestones: [], + team: [], + files: [], + paymentPlans: [], + invoiceRecords: [], + }, + { + id: 'project-005', + projectNo: 'P8K2M202408005', + projectName: '智能仓储物流分拣系统', + businessType: 'iot', + progress: 64, + status: '售后中', + submitTime: '2026-8-17 14:00', + milestones: [], + team: [], + files: [], + paymentPlans: [], + invoiceRecords: [], + afterSales: { + status: '处理中', + type: 'BUG问题', + description: '分拣识别偶发误判,需要排查', + remark: '', + attachments: [], + createTime: '2026-8-19 17:00', + }, + }, + { + id: 'project-006', + projectNo: 'P8K2M202408006', + projectName: '新能源电站运维监控平台', + businessType: 'internet', + progress: 64, + status: '已取消', + submitTime: '2026-8-17 14:00', + milestones: [], + team: [], + files: [], + paymentPlans: [], + invoiceRecords: [], + }, +] + +/** 项目详情 mock(社区康养智能服务系统) */ +const PROJECT_DETAIL: Project = { + ...MOCK_PROJECTS[3], + progress: 65, + startTime: '2026-8-13 11:04:12', + endTime: '2026-10-30 18:00:00', + milestones: [ + { name: '需求确认', status: '已完成', time: '2026-8-13 11:04:12' }, + { name: '原型设计', status: '已完成', time: '2026-8-13 11:04:12' }, + { name: '开发实现', status: '进行中' }, + { name: '测试验收', status: '待开始' }, + { name: '项目交付', status: '待开始' }, + ], + team: [ + { role: '项目经理', name: '张三', avatarChar: '张' }, + { role: '项目经理', name: '张三', avatarChar: '张' }, + ], + files: [ + mockFile('项目需求规格说明书.pdf', 2516582, '2026-08-19 17:00'), + mockFile('项目需求说明书.word', 1887437, '2026-08-19 17:00'), + ], + paymentPlans: [ + { + id: 'bill-001', + planName: '第一期·预付款(比例33%)', + billNo: '20260813ZZ251245', + amount: 20100, + status: '已支付', + tradeNo: 'T20260819153035678K2M001', + projectName: '社区康养智能服务系统', + }, + { + id: 'bill-002', + planName: '第二期·付款(比例33%)', + billNo: '20260813ZZ251245', + amount: 20100, + status: '待支付', + projectName: '社区康养智能服务系统', + }, + { + id: 'bill-003', + planName: '第三期·尾款(比例33%)', + billNo: '20260813ZZ251245', + amount: 20100, + status: '未到期', + projectName: '社区康养智能服务系统', + }, + ], + invoiceRecords: [ + mockFile('增值税专用发票.pdf', 325120, '2026-08-19 17:00'), + mockFile('电子回单.jpg', 186368, '2026-08-19 17:00'), + ], + contractFile: mockFile('社区康养智能服务系统开发合同.pdf', 1039360, '2026-08-19 17:00'), + afterSales: { + status: '处理中', + type: 'BUG问题', + description: '具体请看清单', + remark: '', + attachments: [], + createTime: '2026-8-19 17:00', + }, +} + +/** 提交时间快捷区间匹配 */ +function matchTimeShortcut(submitTime: string, shortcut: ProjectQuery['timeShortcut']): boolean { + if (shortcut === 'all') return true + // mock 时间为 '2026-8-17 14:00',替换为 / 保证各环境可解析 + const time = new Date(submitTime.replaceAll('-', '/')).getTime() + const start = new Date() + if (shortcut === '3m') start.setMonth(start.getMonth() - 3) + if (shortcut === '6m') start.setMonth(start.getMonth() - 6) + if (shortcut === '1y') start.setFullYear(start.getFullYear() - 1) + return time >= start.getTime() +} + +/** 项目列表(筛选 + 分页,pageSize 固定 8) */ +export function getProjectList(query: ProjectQuery): Promise> { + const filtered = MOCK_PROJECTS.filter((item) => { + if ( + query.keyword + && !item.projectName.includes(query.keyword) + && !item.projectNo.includes(query.keyword) + ) { + return false + } + if (query.businessType !== 'all' && item.businessType !== query.businessType) return false + if (query.status !== 'all' && item.status !== query.status) return false + if (!matchTimeShortcut(item.submitTime, query.timeShortcut)) return false + return true + }) + const pageSize = query.pageSize > 0 ? query.pageSize : 8 + const start = (query.page - 1) * pageSize + return Promise.resolve({ + list: filtered.slice(start, start + pageSize), + total: filtered.length, + }) +} + +/** 项目详情(不存在返回 null;仅详情项目返回完整数据) */ +export function getProjectDetail(id: string): Promise { + if (id === DETAIL_ID) return Promise.resolve({ ...PROJECT_DETAIL }) + const found = MOCK_PROJECTS.find((item) => item.id === id) ?? null + return Promise.resolve(found ? { ...found } : null) +} + +/** 上传项目文件(追加进详情文件列表) */ +export function uploadProjectFile(file: File): Promise { + const info: FileInfo = { + name: file.name, + size: file.size, + uploadTime: formatDate(new Date()), + url: '', + } + PROJECT_DETAIL.files.push(info) + return Promise.resolve({ ...info }) +} + +/** 删除项目文件(按索引从详情文件列表移除) */ +export function deleteProjectFile(index: number): Promise { + if (index >= 0 && index < PROJECT_DETAIL.files.length) { + PROJECT_DETAIL.files.splice(index, 1) + } + return Promise.resolve() +} + +/** 提交售后工单 */ +export function submitAfterSales( + projectId: string, + payload: AfterSalesPayload, +): Promise { + const result: AfterSales = { + status: '处理中', + type: payload.type, + description: payload.description, + remark: payload.remark, + attachments: [...payload.attachments], + createTime: formatDate(new Date()), + } + if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result + return Promise.resolve({ ...result, attachments: [...result.attachments] }) +} + +/** 取消售后工单 */ +export function cancelAfterSales(projectId: string): Promise { + const result: AfterSales = { + status: '无', + type: '', + description: '', + remark: '', + attachments: [], + createTime: '', + } + if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result + return Promise.resolve({ ...result }) +} diff --git a/src/api/service.ts b/src/api/service.ts new file mode 100644 index 0000000..092286b --- /dev/null +++ b/src/api/service.ts @@ -0,0 +1,72 @@ +import type { ChatMessage } from '@/types/business/service' + +// TODO(api): 待接口文档到位后,将以下 mock 替换为 request 调用 + +let seq = 0 + +function nextId(prefix: string): string { + seq += 1 + return `${prefix}-${Date.now()}-${seq}` +} + +function nowTime(): string { + const date = new Date() + const pad = (n: number): string => (n < 10 ? `0${n}` : String(n)) + return `今天 ${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +/** 历史会话(欢迎语 + 用户提问 + 文件资料) */ +export function getChatHistory(): Promise { + return Promise.resolve([ + { + id: 'chat-001', + role: 'assistant', + content: '您好,欢迎咨询智联智造平台!我是您的专属客服小助手,请问有什么可以帮您的?', + time: '今天 10:23', + }, + { + id: 'chat-002', + role: 'user', + content: '我想咨询一下物联网全案开发的流程和周期', + time: '今天 10:23', + }, + { + id: 'chat-003', + role: 'assistant', + content: '', + file: { name: '物联网全案服务介绍.pdf', size: 2.5 * 1024 * 1024 }, + time: '今天 10:24', + }, + ]) +} + +/** 发送文本消息(echo 用户消息,携带当前时间) */ +export function sendChatMessage(content: string): Promise { + return Promise.resolve({ + id: nextId('chat'), + role: 'user', + content, + time: nowTime(), + }) +} + +/** 上传聊天文件(生成文件卡片消息) */ +export function uploadChatFile(file: File): Promise { + return Promise.resolve({ + id: nextId('chat'), + role: 'user', + content: '', + file: { name: file.name, size: file.size }, + time: nowTime(), + }) +} + +/** 客服自动回复 */ +export function mockAutoReply(): Promise { + return Promise.resolve({ + id: nextId('chat'), + role: 'assistant', + content: '收到您的消息,专属客服稍后为您答复~', + time: nowTime(), + }) +} diff --git a/src/components/AppButton.vue b/src/components/AppButton.vue new file mode 100644 index 0000000..f73f30e --- /dev/null +++ b/src/components/AppButton.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/src/components/AppFooter.vue b/src/components/AppFooter.vue new file mode 100644 index 0000000..bfc8cc8 --- /dev/null +++ b/src/components/AppFooter.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue new file mode 100644 index 0000000..45ce2f4 --- /dev/null +++ b/src/components/AppHeader.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/components/CaseCard.vue b/src/components/CaseCard.vue new file mode 100644 index 0000000..5822a97 --- /dev/null +++ b/src/components/CaseCard.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/components/ConfirmDialog.vue b/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..1e73db1 --- /dev/null +++ b/src/components/ConfirmDialog.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/src/components/FileList.vue b/src/components/FileList.vue new file mode 100644 index 0000000..680c34d --- /dev/null +++ b/src/components/FileList.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/src/components/FilterPanel.vue b/src/components/FilterPanel.vue new file mode 100644 index 0000000..9cf3028 --- /dev/null +++ b/src/components/FilterPanel.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/components/PaginationBar.vue b/src/components/PaginationBar.vue new file mode 100644 index 0000000..620e093 --- /dev/null +++ b/src/components/PaginationBar.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/components/PasswordStrength.vue b/src/components/PasswordStrength.vue new file mode 100644 index 0000000..e923ac3 --- /dev/null +++ b/src/components/PasswordStrength.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/src/components/QrCodePanel.vue b/src/components/QrCodePanel.vue new file mode 100644 index 0000000..e9f879a --- /dev/null +++ b/src/components/QrCodePanel.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/src/components/SmsCodeInput.vue b/src/components/SmsCodeInput.vue new file mode 100644 index 0000000..90eae6d --- /dev/null +++ b/src/components/SmsCodeInput.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src/components/StatusTag.vue b/src/components/StatusTag.vue new file mode 100644 index 0000000..90985e2 --- /dev/null +++ b/src/components/StatusTag.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/src/components/StepForm.vue b/src/components/StepForm.vue new file mode 100644 index 0000000..aac8ed1 --- /dev/null +++ b/src/components/StepForm.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/src/components/UploadArea.vue b/src/components/UploadArea.vue new file mode 100644 index 0000000..e75f86a --- /dev/null +++ b/src/components/UploadArea.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..7dff38a --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,15 @@ +export { default as AppHeader } from './AppHeader.vue' +export { default as AppFooter } from './AppFooter.vue' +export { default as AppButton } from './AppButton.vue' +export { default as CaseCard } from './CaseCard.vue' +export { default as FilterPanel } from './FilterPanel.vue' +export type { FilterChip } from './FilterPanel.vue' +export { default as PaginationBar } from './PaginationBar.vue' +export { default as StatusTag } from './StatusTag.vue' +export { default as FileList } from './FileList.vue' +export { default as UploadArea } from './UploadArea.vue' +export { default as StepForm } from './StepForm.vue' +export { default as SmsCodeInput } from './SmsCodeInput.vue' +export { default as PasswordStrength } from './PasswordStrength.vue' +export { default as ConfirmDialog } from './ConfirmDialog.vue' +export { default as QrCodePanel } from './QrCodePanel.vue' diff --git a/src/composables/useCountDown.ts b/src/composables/useCountDown.ts new file mode 100644 index 0000000..3e1ac66 --- /dev/null +++ b/src/composables/useCountDown.ts @@ -0,0 +1,32 @@ +import { computed, onScopeDispose, ref } from 'vue' + +/** 倒计时(验证码 60s、二维码失效 4:32 等通用) */ +export function useCountDown(defaultSeconds = 60) { + const seconds = ref(0) + let timer: ReturnType | null = null + + const isCounting = computed(() => seconds.value > 0) + + function start(total: number = defaultSeconds): void { + reset() + seconds.value = total + timer = setInterval(() => { + seconds.value -= 1 + if (seconds.value <= 0) { + reset() + } + }, 1000) + } + + function reset(): void { + if (timer !== null) { + clearInterval(timer) + timer = null + } + seconds.value = 0 + } + + onScopeDispose(reset) + + return { seconds, isCounting, start, reset } +} diff --git a/src/composables/useIntersection.ts b/src/composables/useIntersection.ts new file mode 100644 index 0000000..9465ec6 --- /dev/null +++ b/src/composables/useIntersection.ts @@ -0,0 +1,29 @@ +import { onMounted, onUnmounted, ref, type Ref } from 'vue' + +/** 元素可见性(配合图片懒加载/曝光统计) */ +export function useIntersection( + target: Ref, + options?: IntersectionObserverInit, +): { + isVisible: Ref +} { + const isVisible = ref(false) + let observer: IntersectionObserver | null = null + + onMounted(() => { + const el = target.value + if (!el) return + observer = new IntersectionObserver((entries) => { + const entry = entries[0] + if (entry) isVisible.value = entry.isIntersecting + }, options) + observer.observe(el) + }) + + onUnmounted(() => { + observer?.disconnect() + observer = null + }) + + return { isVisible } +} diff --git a/src/composables/useLoading.ts b/src/composables/useLoading.ts new file mode 100644 index 0000000..5b990d9 --- /dev/null +++ b/src/composables/useLoading.ts @@ -0,0 +1,21 @@ +import { ref, type Ref } from 'vue' + +/** 请求 loading 状态管理 */ +export function useLoading(initial = false): { + loading: Ref + withLoading: (fn: () => Promise) => Promise +} { + const loading = ref(initial) + + async function withLoading(fn: () => Promise): Promise { + loading.value = true + try { + return await fn() + } + finally { + loading.value = false + } + } + + return { loading, withLoading } +} diff --git a/src/composables/useScroll.ts b/src/composables/useScroll.ts new file mode 100644 index 0000000..0d04012 --- /dev/null +++ b/src/composables/useScroll.ts @@ -0,0 +1,26 @@ +import { computed, onMounted, onUnmounted, ref, type ComputedRef, type Ref } from 'vue' + +/** 滚动监听/吸顶/回到顶部 */ +export function useScroll(threshold = 200): { + scrollY: Ref + isAtTop: ComputedRef + showBackTop: ComputedRef + scrollToTop: (smooth?: boolean) => void +} { + const scrollY = ref(0) + const isAtTop = computed(() => scrollY.value <= 0) + const showBackTop = computed(() => scrollY.value > threshold) + + function onScroll(): void { + scrollY.value = window.scrollY + } + + function scrollToTop(smooth = true): void { + window.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' }) + } + + onMounted(() => window.addEventListener('scroll', onScroll, { passive: true })) + onUnmounted(() => window.removeEventListener('scroll', onScroll)) + + return { scrollY, isAtTop, showBackTop, scrollToTop } +} diff --git a/src/directives/debounce.ts b/src/directives/debounce.ts new file mode 100644 index 0000000..bf780e2 --- /dev/null +++ b/src/directives/debounce.ts @@ -0,0 +1,35 @@ +import type { Directive } from 'vue' + +interface DebounceBinding { + handler: (...args: unknown[]) => void + delay?: number +} + +type DebounceValue = DebounceBinding['handler'] | DebounceBinding + +const listenerMap = new WeakMap() + +function normalize(value: DebounceValue): DebounceBinding { + return typeof value === 'function' ? { handler: value } : value +} + +/** v-debounce 防抖点击:v-debounce="fn" 或 v-debounce="{ handler: fn, delay: 500 }" */ +const debounce: Directive = { + mounted(el, binding) { + const { handler, delay = 300 } = normalize(binding.value) + let timer: ReturnType | null = null + const listener: EventListener = () => { + if (timer !== null) clearTimeout(timer) + timer = setTimeout(() => handler(), delay) + } + listenerMap.set(el, listener) + el.addEventListener('click', listener) + }, + unmounted(el) { + const listener = listenerMap.get(el) + if (listener) el.removeEventListener('click', listener) + listenerMap.delete(el) + }, +} + +export default debounce diff --git a/src/directives/index.ts b/src/directives/index.ts new file mode 100644 index 0000000..5f55f82 --- /dev/null +++ b/src/directives/index.ts @@ -0,0 +1,12 @@ +import type { App } from 'vue' +import lazy from './lazy' +import debounce from './debounce' +import throttle from './throttle' + +/** main.ts 调用:统一注册自定义指令 */ +export function setupDirectives(app: App): void { + app + .directive('lazy', lazy) + .directive('debounce', debounce) + .directive('throttle', throttle) +} diff --git a/src/directives/lazy.ts b/src/directives/lazy.ts new file mode 100644 index 0000000..7898922 --- /dev/null +++ b/src/directives/lazy.ts @@ -0,0 +1,27 @@ +import type { Directive } from 'vue' + +const observerMap = new WeakMap() + +/** v-lazy 图片懒加载:v-lazy="图片地址" */ +const lazy: Directive = { + mounted(el, binding) { + if (!(el instanceof HTMLImageElement)) return + const target = el + const observer = new IntersectionObserver((entries) => { + const entry = entries[0] + if (entry?.isIntersecting) { + if (binding.value) target.src = binding.value + observer.disconnect() + observerMap.delete(el) + } + }) + observerMap.set(el, observer) + observer.observe(el) + }, + unmounted(el) { + observerMap.get(el)?.disconnect() + observerMap.delete(el) + }, +} + +export default lazy diff --git a/src/directives/throttle.ts b/src/directives/throttle.ts new file mode 100644 index 0000000..372390f --- /dev/null +++ b/src/directives/throttle.ts @@ -0,0 +1,38 @@ +import type { Directive } from 'vue' + +interface ThrottleBinding { + handler: (...args: unknown[]) => void + interval?: number +} + +type ThrottleValue = ThrottleBinding['handler'] | ThrottleBinding + +const listenerMap = new WeakMap() + +function normalize(value: ThrottleValue): ThrottleBinding { + return typeof value === 'function' ? { handler: value } : value +} + +/** v-throttle 节流点击:v-throttle="fn" 或 v-throttle="{ handler: fn, interval: 500 }" */ +const throttle: Directive = { + mounted(el, binding) { + const { handler, interval = 500 } = normalize(binding.value) + let lastTime = 0 + const listener: EventListener = () => { + const now = Date.now() + if (now - lastTime >= interval) { + lastTime = now + handler() + } + } + listenerMap.set(el, listener) + el.addEventListener('click', listener) + }, + unmounted(el) { + const listener = listenerMap.get(el) + if (listener) el.removeEventListener('click', listener) + listenerMap.delete(el) + }, +} + +export default throttle diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..9094bd0 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + /** API 基础地址 */ + readonly VITE_API_BASE_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/src/layout/components/FloatingActions.vue b/src/layout/components/FloatingActions.vue new file mode 100644 index 0000000..f85f850 --- /dev/null +++ b/src/layout/components/FloatingActions.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/src/layout/index.vue b/src/layout/index.vue new file mode 100644 index 0000000..0056906 --- /dev/null +++ b/src/layout/index.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/src/main.ts b/src/main.ts index 01433bc..60ab2b9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,15 @@ import { createApp } from 'vue' import App from './App.vue' +import pinia from '@/stores' +import router from '@/router' +import { setupDirectives } from '@/directives' +import 'element-plus/dist/index.css' +import '@/styles/index.less' -createApp(App).mount('#app') +const app = createApp(App) + +app.use(pinia) +app.use(router) +setupDirectives(app) + +app.mount('#app') diff --git a/src/router/guard.ts b/src/router/guard.ts new file mode 100644 index 0000000..cebccec --- /dev/null +++ b/src/router/guard.ts @@ -0,0 +1,16 @@ +import type { Router } from 'vue-router' +import { storage } from '@/utils/storage' + +/** 路由守卫:登录态校验 + document.title */ +export function setupGuard(router: Router): void { + router.beforeEach((to) => { + if (to.meta.requiresAuth && !storage.get('token')) { + return { path: '/login', query: { redirect: to.fullPath } } + } + return true + }) + + router.afterEach((to) => { + document.title = to.meta.title ? `${to.meta.title} - 虫子智造` : '虫子智造' + }) +} diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..2cd545e --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,13 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { routes } from './routes' +import { setupGuard } from './guard' + +const router = createRouter({ + history: createWebHistory(), + routes, + scrollBehavior: () => ({ top: 0 }), +}) + +setupGuard(router) + +export default router diff --git a/src/router/routes.ts b/src/router/routes.ts new file mode 100644 index 0000000..6bc1b08 --- /dev/null +++ b/src/router/routes.ts @@ -0,0 +1,65 @@ +import type { RouteRecordRaw } from 'vue-router' + +/** + * 公共布局(AppHeader + router-view + AppFooter + FloatingActions)下的路由 + * 完整规划见 docs/page-architecture.md 第 2 节 + * M3–M10 视图由子代理并行实现中,交付后本表即为终态 + */ +const layoutRoutes: RouteRecordRaw = { + path: '/', + component: () => import('@/layout/index.vue'), + children: [ + // ── 公开 ── + { path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页' } }, + { path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } }, + { path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } }, + { path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } }, + { path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报' } }, + + // ── 登录区 ── + { path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } }, + { path: 'project/:id', name: 'ProjectDetail', component: () => import('@/views/project/detail.vue'), meta: { title: '项目详情', requiresAuth: true } }, + { path: 'project/:id/after-sales', name: 'AfterSales', component: () => import('@/views/project/after-sales.vue'), meta: { title: '申请售后', requiresAuth: true } }, + { path: 'contract', name: 'Contract', component: () => import('@/views/contract'), meta: { title: '合同中心', requiresAuth: true } }, + { path: 'contract/:id', name: 'ContractDetail', component: () => import('@/views/contract/detail.vue'), meta: { title: '合同详情', requiresAuth: true } }, + { path: 'contract/:id/sign', name: 'ContractSign', component: () => import('@/views/contract/sign.vue'), meta: { title: '签署合同', requiresAuth: true } }, + { path: 'finance', name: 'Finance', component: () => import('@/views/finance'), meta: { title: '财务中心', requiresAuth: true } }, + { path: 'finance/bills', name: 'Bills', component: () => import('@/views/finance/bills.vue'), meta: { title: '账单明细', requiresAuth: true } }, + { path: 'finance/bills/:id', name: 'BillDetail', component: () => import('@/views/finance/bill-detail.vue'), meta: { title: '账单详情', requiresAuth: true } }, + { path: 'finance/bills/:id/pay', name: 'BillPay', component: () => import('@/views/finance/pay.vue'), meta: { title: '扫码支付', requiresAuth: true } }, + { path: 'finance/bills/:id/pay/result', name: 'PayResult', component: () => import('@/views/finance/pay-result.vue'), meta: { title: '支付结果', requiresAuth: true } }, + { path: 'finance/invoices', name: 'Invoices', component: () => import('@/views/finance/invoices.vue'), meta: { title: '发票申请', requiresAuth: true } }, + { path: 'finance/invoice-titles', name: 'InvoiceTitles', component: () => import('@/views/finance/invoice-titles.vue'), meta: { title: '发票抬头', requiresAuth: true } }, + { path: 'message', name: 'Message', component: () => import('@/views/message'), meta: { title: '消息中心', requiresAuth: true } }, + { path: 'message/:id', name: 'MessageDetail', component: () => import('@/views/message/detail.vue'), meta: { title: '通知详情', requiresAuth: true } }, + { path: 'service', name: 'Service', component: () => import('@/views/service'), meta: { title: '在线客服', requiresAuth: true } }, + + // ── 个人中心(左菜单嵌套)── + { + path: 'profile', + component: () => import('@/views/profile'), + meta: { title: '个人中心', requiresAuth: true }, + children: [ + { path: '', redirect: '/profile/security' }, + { path: 'security', name: 'ProfileSecurity', component: () => import('@/views/profile/security.vue'), meta: { title: '账号安全', requiresAuth: true } }, + { path: 'address', name: 'ProfileAddress', component: () => import('@/views/profile/address.vue'), meta: { title: '地址管理', requiresAuth: true } }, + { path: 'favorites', name: 'ProfileFavorites', component: () => import('@/views/profile/favorites.vue'), meta: { title: '收藏列表', requiresAuth: true } }, + { path: 'qualifications', name: 'ProfileQualifications', component: () => import('@/views/profile/qualifications.vue'), meta: { title: '资质列表', requiresAuth: true } }, + { path: 'feedback', name: 'ProfileFeedback', component: () => import('@/views/profile/feedback.vue'), meta: { title: '意见反馈', requiresAuth: true } }, + { path: 'help', name: 'ProfileHelp', component: () => import('@/views/profile/help.vue'), meta: { title: '帮助中心', requiresAuth: true } }, + { path: 'about', name: 'ProfileAbout', component: () => import('@/views/profile/about.vue'), meta: { title: '关于我们', requiresAuth: true } }, + { path: 'hotline', name: 'ProfileHotline', component: () => import('@/views/profile/hotline.vue'), meta: { title: '专属客服热线', requiresAuth: true } }, + ], + }, + ], +} + +/** 无全局布局的路由(登录注册独立品牌页 + 404) */ +const plainRoutes: RouteRecordRaw[] = [ + { path: '/login', name: 'Login', component: () => import('@/views/login'), meta: { title: '登录' } }, + { path: '/register', name: 'Register', component: () => import('@/views/login/register.vue'), meta: { title: '注册' } }, + { path: '/reset-password', name: 'ResetPassword', component: () => import('@/views/login/reset.vue'), meta: { title: '找回密码' } }, + { path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue'), meta: { title: '页面不存在' } }, +] + +export const routes: RouteRecordRaw[] = [layoutRoutes, ...plainRoutes] diff --git a/src/stores/index.ts b/src/stores/index.ts new file mode 100644 index 0000000..8a05980 --- /dev/null +++ b/src/stores/index.ts @@ -0,0 +1,5 @@ +import { createPinia } from 'pinia' + +const pinia = createPinia() + +export default pinia diff --git a/src/stores/modules/cases.ts b/src/stores/modules/cases.ts new file mode 100644 index 0000000..b7749c3 --- /dev/null +++ b/src/stores/modules/cases.ts @@ -0,0 +1,84 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { BusinessType, PageResult } from '@/types/api' +import type { Case, CaseQuery } from '@/types/business/cases' +import { getCaseCounts, getCaseDetail, getCaseList, toggleFavorite as toggleFavoriteApi } from '@/api/cases' + +function createDefaultQuery(): CaseQuery { + return { + category: 'all', + industries: [], + scales: [], + dateRange: null, + keyword: '', + page: 1, + pageSize: 8, + } +} + +/** 案例库数据(列表 / 筛选 / 分页 / 收藏) */ +export const useCasesStore = defineStore('cases', () => { + const query = ref(createDefaultQuery()) + const list = ref([]) + const total = ref(0) + const counts = ref>>({}) + const currentCase = ref(null) + + async function fetchCases(): Promise { + const result: PageResult = await getCaseList(query.value) + list.value = result.list + total.value = result.total + } + + async function fetchCounts(): Promise { + counts.value = await getCaseCounts() + } + + function setQuery(next: CaseQuery): void { + query.value = next + } + + /** 确定筛选(重置页码后取数) */ + async function search(): Promise { + query.value = { ...query.value, page: 1 } + await fetchCases() + } + + /** 重置全部条件 */ + async function resetQuery(): Promise { + query.value = createDefaultQuery() + await fetchCases() + } + + async function changePage(page: number): Promise { + query.value = { ...query.value, page } + await fetchCases() + } + + async function fetchCaseDetail(id: string): Promise { + currentCase.value = await getCaseDetail(id) + } + + async function toggleFavorite(id: string): Promise { + const updated = await toggleFavoriteApi(id) + const index = list.value.findIndex((item) => item.id === id) + if (index >= 0) list.value[index] = updated + if (currentCase.value?.id === id) currentCase.value = updated + } + + return { + query, + list, + total, + counts, + currentCase, + fetchCases, + fetchCounts, + setQuery, + search, + resetQuery, + changePage, + fetchCaseDetail, + toggleFavorite, + } +}) diff --git a/src/stores/modules/contract.ts b/src/stores/modules/contract.ts new file mode 100644 index 0000000..fa3a5ac --- /dev/null +++ b/src/stores/modules/contract.ts @@ -0,0 +1,79 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { PageResult } from '@/types/api' +import type { Contract, ContractQuery } from '@/types/business/contract' +import { + getContractDetail, + getContractList, + getSignQr, + refreshSignQr, +} from '@/api/contract' + +function createDefaultQuery(): ContractQuery { + return { + keyword: '', + page: 1, + pageSize: 8, + } +} + +/** 合同中心数据(列表 / 筛选 / 分页 / 详情 / 签署二维码) */ +export const useContractStore = defineStore('contract', () => { + const query = ref(createDefaultQuery()) + const list = ref([]) + const total = ref(0) + const current = ref(null) + const signExpires = ref(272) + + async function fetchList(): Promise { + const result: PageResult = await getContractList(query.value) + list.value = result.list + total.value = result.total + } + + async function fetchDetail(id: string): Promise { + current.value = await getContractDetail(id) + } + + function setQuery(next: ContractQuery): void { + query.value = next + } + + /** 确定筛选(重置页码后取数) */ + async function search(): Promise { + query.value = { ...query.value, page: 1 } + await fetchList() + } + + /** 重置全部条件 */ + async function resetQuery(): Promise { + query.value = createDefaultQuery() + await fetchList() + } + + async function changePage(page: number): Promise { + query.value = { ...query.value, page } + await fetchList() + } + + /** 获取/刷新签署二维码(refresh=true 时走刷新接口) */ + async function fetchSignQr(id: string, refresh = false): Promise { + const result = refresh ? await refreshSignQr(id) : await getSignQr(id) + signExpires.value = result.expiresInSeconds + } + + return { + query, + list, + total, + current, + signExpires, + fetchList, + fetchDetail, + setQuery, + search, + resetQuery, + changePage, + fetchSignQr, + } +}) diff --git a/src/stores/modules/demand.ts b/src/stores/modules/demand.ts new file mode 100644 index 0000000..6050737 --- /dev/null +++ b/src/stores/modules/demand.ts @@ -0,0 +1,126 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { ElMessage } from 'element-plus' +import type { Demand } from '@/types/business/demand' +import type { FileInfo } from '@/types/api' +import { formatDate } from '@/utils/format' +import { getDraftDemand, saveDraft as saveDraftApi, submitDemand } from '@/api/demand' + +function createDefaultForm(): Demand { + return { + businessType: 'internet', + projectName: '', + attachments: [], + coreDescription: '', + expectedDuration: '', + budgetRange: '', + userName: '王永志', + userPhone: '1866****9618', + remark: '', + status: 'draft', + } +} + +/** 需求提报数据(M3:表单 / 步骤 / 提交与草稿) */ +export const useDemandStore = defineStore('demand', () => { + const form = ref(createDefaultForm()) + const step = ref(1) + const loading = ref(false) + + function setStep(next: number): void { + step.value = next + } + + function patchForm(partial: Partial): void { + form.value = { ...form.value, ...partial } + } + + /** File → FileInfo(mock:url 为空,uploadTime 取当前时间) */ + function addAttachment(file: File): void { + const info: FileInfo = { + name: file.name, + size: file.size, + uploadTime: formatDate(new Date()), + url: '', + } + form.value.attachments = [...form.value.attachments, info] + } + + function removeAttachment(target: FileInfo): void { + form.value.attachments = form.value.attachments.filter( + (item) => !(item.name === target.name && item.uploadTime === target.uploadTime), + ) + } + + /** 提交需求:必填校验通过后调 api,成功返回 true(页面可据此跳转) */ + async function submit(): Promise { + if (!form.value.businessType) { + ElMessage.warning('请选择业务类型') + return false + } + if (!form.value.projectName.trim()) { + ElMessage.warning('请填写项目名称') + return false + } + if (!form.value.coreDescription.trim()) { + ElMessage.warning('请填写核心需求描述') + return false + } + loading.value = true + try { + await submitDemand(form.value) + form.value.status = 'submitted' + ElMessage.success('提交后我们的专业顾问将在一个工作日内与您联系') + return true + } + catch (error) { + ElMessage.error(error instanceof Error ? error.message : '提交失败,请稍后重试') + return false + } + finally { + loading.value = false + } + } + + /** 保存草稿到 sessionStorage */ + async function saveDraft(): Promise { + loading.value = true + try { + await saveDraftApi(form.value) + form.value.status = 'draft' + ElMessage.success('草稿已保存') + } + catch (error) { + ElMessage.error(error instanceof Error ? error.message : '草稿保存失败') + } + finally { + loading.value = false + } + } + + /** 恢复本地草稿(无草稿则保持默认表单) */ + async function loadDraft(): Promise { + const draft = await getDraftDemand() + if (draft) form.value = { ...createDefaultForm(), ...draft } + } + + /** 重置表单与步骤 */ + function reset(): void { + form.value = createDefaultForm() + step.value = 1 + } + + return { + form, + step, + loading, + setStep, + patchForm, + addAttachment, + removeAttachment, + submit, + saveDraft, + loadDraft, + reset, + } +}) diff --git a/src/stores/modules/finance.ts b/src/stores/modules/finance.ts new file mode 100644 index 0000000..0fdc0fe --- /dev/null +++ b/src/stores/modules/finance.ts @@ -0,0 +1,207 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { PageResult } from '@/types/api' +import type { + Bill, + BillQuery, + FinanceStats, + Invoice, + InvoiceQuery, + InvoiceTitle, + InvoiceTitlePayload, + PayMethod, + Payment, + PaymentResult, +} from '@/types/business/finance' +import { + applyInvoice as applyInvoiceApi, + createInvoiceTitle, + createPayment as createPaymentApi, + deleteInvoiceTitle, + exportReport as exportReportApi, + getBillDetail, + getBillList, + getFinanceStats, + getInvoiceList, + getInvoiceTitles, + getPayResult, + setDefaultTitle as setDefaultTitleApi, + switchPaymentMethod as switchPaymentMethodApi, + updateInvoiceTitle, +} from '@/api/finance' + +function createDefaultBillQuery(): BillQuery { + return { keyword: '', status: 'all', page: 1, pageSize: 6 } +} + +function createDefaultInvoiceQuery(): InvoiceQuery { + return { keyword: '', status: 'all', page: 1, pageSize: 6 } +} + +/** 财务中心数据(统计/账单/支付/发票/抬头) */ +export const useFinanceStore = defineStore('finance', () => { + // —— 统计 + const stats = ref(null) + + // —— 账单列表 + const list = ref([]) + const total = ref(0) + const query = ref(createDefaultBillQuery()) + const currentBill = ref(null) + + // —— 支付 + const payment = ref(null) + const result = ref(null) + + // —— 发票 + const invoices = ref([]) + const invoiceTotal = ref(0) + const invoiceQuery = ref(createDefaultInvoiceQuery()) + + // —— 发票抬头 + const titles = ref([]) + + // —— 统计 + async function fetchStats(period?: string): Promise { + stats.value = await getFinanceStats(period) + } + + // —— 账单 + async function fetchList(): Promise { + const pageData: PageResult = await getBillList(query.value) + list.value = pageData.list + total.value = pageData.total + } + + function setQuery(next: BillQuery): void { + query.value = next + } + + /** 确定筛选(重置页码后取数) */ + async function search(): Promise { + query.value = { ...query.value, page: 1 } + await fetchList() + } + + /** 重置全部条件 */ + async function resetQuery(): Promise { + query.value = createDefaultBillQuery() + await fetchList() + } + + async function changePage(page: number): Promise { + query.value = { ...query.value, page } + await fetchList() + } + + async function fetchDetail(id: string): Promise { + currentBill.value = await getBillDetail(id) + } + + // —— 支付 + async function createPayment(billId: string, method: PayMethod): Promise { + payment.value = await createPaymentApi(billId, method) + } + + /** 切换支付方式(刷新二维码) */ + async function switchPaymentMethod(billId: string, method: PayMethod): Promise { + payment.value = await switchPaymentMethodApi(billId, method) + } + + async function fetchResult(billId: string): Promise { + result.value = await getPayResult(billId) + } + + // —— 发票 + async function fetchInvoices(): Promise { + const pageData: PageResult = await getInvoiceList(invoiceQuery.value) + invoices.value = pageData.list + invoiceTotal.value = pageData.total + } + + function setInvoiceQuery(next: InvoiceQuery): void { + invoiceQuery.value = next + } + + /** 发票确定筛选(重置页码后取数) */ + async function searchInvoices(): Promise { + invoiceQuery.value = { ...invoiceQuery.value, page: 1 } + await fetchInvoices() + } + + async function changeInvoicePage(page: number): Promise { + invoiceQuery.value = { ...invoiceQuery.value, page } + await fetchInvoices() + } + + /** 申请开票(状态 → 开票中) */ + async function applyInvoice(id: string): Promise { + const updated = await applyInvoiceApi(id) + const index = invoices.value.findIndex((item) => item.id === id) + if (index >= 0) invoices.value[index] = updated + } + + // —— 发票抬头 + async function fetchTitles(): Promise { + titles.value = await getInvoiceTitles() + } + + async function createTitle(payload: InvoiceTitlePayload): Promise { + await createInvoiceTitle(payload) + await fetchTitles() + } + + async function updateTitle(id: string, payload: InvoiceTitlePayload): Promise { + await updateInvoiceTitle(id, payload) + await fetchTitles() + } + + async function deleteTitle(id: string): Promise { + await deleteInvoiceTitle(id) + await fetchTitles() + } + + async function setDefaultTitle(id: string): Promise { + titles.value = await setDefaultTitleApi(id) + } + + // —— 报表导出 + async function exportReport(): Promise { + await exportReportApi() + } + + return { + stats, + list, + total, + query, + currentBill, + payment, + result, + invoices, + invoiceTotal, + invoiceQuery, + titles, + fetchStats, + fetchList, + setQuery, + search, + resetQuery, + changePage, + fetchDetail, + createPayment, + switchPaymentMethod, + fetchResult, + fetchInvoices, + setInvoiceQuery, + searchInvoices, + changeInvoicePage, + applyInvoice, + fetchTitles, + createTitle, + updateTitle, + deleteTitle, + setDefaultTitle, + export: exportReport, + } +}) diff --git a/src/stores/modules/home.ts b/src/stores/modules/home.ts new file mode 100644 index 0000000..ab3d846 --- /dev/null +++ b/src/stores/modules/home.ts @@ -0,0 +1,46 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { BusinessType } from '@/types/api' +import type { Case } from '@/types/business/cases' +import type { BusinessLine, HomeStats } from '@/types/business/home' +import { getBusinessDetail, getBusinessLines, getFeaturedCases, getHomeStats } from '@/api/home' + +/** 首页数据(数据层:取数 + 状态,页面只调本 store) */ +export const useHomeStore = defineStore('home', () => { + const stats = ref({ pending: 0, inProgress: 0, total: 0 }) + const businessLines = ref([]) + const featuredCases = ref([]) + const currentBusiness = ref(null) + + /** 首页初始化:看板 + 业务线 + 案例精选 */ + async function fetchHomeData(): Promise { + const [homeStats, lines, cases] = await Promise.all([ + getHomeStats(), + getBusinessLines(), + getFeaturedCases(), + ]) + stats.value = homeStats + businessLines.value = lines + featuredCases.value = cases + } + + /** 按业务类型切换案例精选 */ + async function fetchFeaturedCases(category: BusinessType | 'all' = 'all'): Promise { + featuredCases.value = await getFeaturedCases(category) + } + + /** 业务详情 */ + async function fetchBusinessDetail(type: BusinessType): Promise { + currentBusiness.value = await getBusinessDetail(type) + } + + return { + stats, + businessLines, + featuredCases, + currentBusiness, + fetchHomeData, + fetchFeaturedCases, + fetchBusinessDetail, + } +}) diff --git a/src/stores/modules/message.ts b/src/stores/modules/message.ts new file mode 100644 index 0000000..9df92ca --- /dev/null +++ b/src/stores/modules/message.ts @@ -0,0 +1,112 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { ElMessage } from 'element-plus' +import type { PageResult } from '@/types/api' +import type { Message, MessageQuery } from '@/types/business/message' +import { + getMessageDetail, + getMessageList, + getTabCounts, + getUnreadCount, + markAllRead as markAllReadApi, + markRead as markReadApi, +} from '@/api/message' + +/** tab 计数结构 */ +export interface MessageTabCounts { + all: number + 在线客服: number + 状态通知: number + 系统通知: number +} + +function createDefaultQuery(): MessageQuery { + return { + keyword: '', + type: 'all', + page: 1, + pageSize: 10, + } +} + +/** 消息中心数据(列表 / 筛选 / 分页 / 已读) */ +export const useMessageStore = defineStore('message', () => { + const query = ref(createDefaultQuery()) + const list = ref([]) + const total = ref(0) + const current = ref(null) + const unreadCount = ref(0) + const tabCounts = ref({ all: 0, 在线客服: 0, 状态通知: 0, 系统通知: 0 }) + + async function fetchList(): Promise { + const result: PageResult = await getMessageList(query.value) + list.value = result.list + total.value = result.total + } + + async function fetchDetail(id: string): Promise { + const detail = await getMessageDetail(id) + current.value = detail + if (detail && !detail.isRead) { + await markReadApi(id) + detail.isRead = true + if (unreadCount.value > 0) unreadCount.value -= 1 + await fetchTabCounts() + } + } + + function setQuery(next: MessageQuery): void { + query.value = next + } + + /** 确定搜索(重置页码后取数) */ + async function search(): Promise { + query.value = { ...query.value, page: 1 } + await fetchList() + } + + /** 重置全部条件 */ + async function resetQuery(): Promise { + query.value = createDefaultQuery() + await fetchList() + } + + async function changePage(page: number): Promise { + query.value = { ...query.value, page } + await fetchList() + } + + /** 全部标记已读 */ + async function markAllRead(): Promise { + await markAllReadApi() + ElMessage.success('已全部标记为已读') + await Promise.all([fetchList(), fetchUnreadCount(), fetchTabCounts()]) + if (current.value) current.value.isRead = true + } + + async function fetchUnreadCount(): Promise { + unreadCount.value = await getUnreadCount() + } + + async function fetchTabCounts(): Promise { + tabCounts.value = await getTabCounts() + } + + return { + query, + list, + total, + current, + unreadCount, + tabCounts, + fetchList, + fetchDetail, + setQuery, + search, + resetQuery, + changePage, + markAllRead, + fetchUnreadCount, + fetchTabCounts, + } +}) diff --git a/src/stores/modules/profile.ts b/src/stores/modules/profile.ts new file mode 100644 index 0000000..60dbd71 --- /dev/null +++ b/src/stores/modules/profile.ts @@ -0,0 +1,258 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { ElMessage } from 'element-plus' +import type { BusinessType, PageResult } from '@/types/api' +import type { Case } from '@/types/business/cases' +import type { + Address, + ChangePasswordParams, + Feedback, + FavoritesQuery, + HelpFaqGroup, + ListQuery, + Profile, + Qualification, + QualificationInput, +} from '@/types/business/profile' +import { + bindEmail, + bindPhone, + changePassword, + createAddress, + createQualification, + deleteAccount as deleteAccountApi, + deleteAddress, + deleteQualification, + getAddresses, + getCaseCounts, + getFavorites, + getHelpFaqs, + getProfile, + getQualifications, + setDefaultAddress, + submitFeedback as submitFeedbackApi, + unbindEmail, + updateAddress, + updateNickname, + updateQualification, + verifyIdentity, +} from '@/api/profile' +import { storage } from '@/utils/storage' + +function createDefaultAddressQuery(): ListQuery { + return { page: 1, pageSize: 6 } +} + +function createDefaultQualificationQuery(): ListQuery { + return { page: 1, pageSize: 6 } +} + +function createDefaultFavoritesQuery(): FavoritesQuery { + return { page: 1, pageSize: 8, keyword: '', category: 'all' } +} + +/** 个人中心数据(资料/安全/地址/收藏/资质/反馈/帮助) */ +export const useProfileStore = defineStore('profile', () => { + // ── 账号资料 ── + const profile = ref(null) + + async function fetchProfile(): Promise { + profile.value = await getProfile() + } + + async function saveNickname(name: string): Promise { + profile.value = await updateNickname(name) + ElMessage.success('昵称修改成功') + } + + // ── 账号安全 ── + async function changePasswordAction(params: ChangePasswordParams): Promise { + await changePassword(params) + profile.value = await getProfile() + ElMessage.success('密码修改成功') + } + + async function bindPhoneAction(phone: string, code: string): Promise { + profile.value = await bindPhone(phone, code) + ElMessage.success('手机号绑定成功') + } + + async function bindEmailAction(email: string, code: string): Promise { + profile.value = await bindEmail(email, code) + ElMessage.success('邮箱绑定成功') + } + + async function unbindEmailAction(): Promise { + profile.value = await unbindEmail() + ElMessage.success('已取消邮箱绑定') + } + + async function verifyIdentityAction(phone: string, code: string): Promise { + await verifyIdentity(phone, code) + } + + async function deleteAccountAction(): Promise { + await deleteAccountApi() + storage.remove('token') + ElMessage.success('注销成功') + } + + // ── 收货地址 ── + const addresses = ref([]) + const addressTotal = ref(0) + const addressQuery = ref(createDefaultAddressQuery()) + + async function fetchAddresses(): Promise { + const result: PageResult
= await getAddresses(addressQuery.value) + addresses.value = result.list + addressTotal.value = result.total + } + + async function createAddressAction(input: Omit): Promise { + await createAddress(input) + ElMessage.success('新增地址成功') + addressQuery.value = { ...addressQuery.value, page: 1 } + await fetchAddresses() + } + + async function updateAddressAction(id: string, input: Omit): Promise { + await updateAddress(id, input) + ElMessage.success('地址保存成功') + await fetchAddresses() + } + + async function deleteAddressAction(id: string): Promise { + await deleteAddress(id) + ElMessage.success('地址删除成功') + await fetchAddresses() + } + + async function setDefaultAddressAction(id: string): Promise { + await setDefaultAddress(id) + ElMessage.success('已设为默认地址') + await fetchAddresses() + } + + async function changeAddressPage(page: number): Promise { + addressQuery.value = { ...addressQuery.value, page } + await fetchAddresses() + } + + // ── 我的收藏 ── + const favorites = ref([]) + const favoritesTotal = ref(0) + const favoritesCounts = ref>>({}) + const favoritesQuery = ref(createDefaultFavoritesQuery()) + + async function fetchFavorites(): Promise { + const result: PageResult = await getFavorites(favoritesQuery.value) + favorites.value = result.list + favoritesTotal.value = result.total + } + + async function fetchFavoritesCounts(): Promise { + favoritesCounts.value = await getCaseCounts() + } + + async function searchFavorites(patch: Partial): Promise { + favoritesQuery.value = { ...favoritesQuery.value, ...patch, page: 1 } + await fetchFavorites() + } + + async function resetFavorites(): Promise { + favoritesQuery.value = createDefaultFavoritesQuery() + await fetchFavorites() + } + + async function changeFavoritesPage(page: number): Promise { + favoritesQuery.value = { ...favoritesQuery.value, page } + await fetchFavorites() + } + + // ── 资质管理 ── + const qualifications = ref([]) + const qualificationTotal = ref(0) + const qualificationQuery = ref(createDefaultQualificationQuery()) + + async function fetchQualifications(): Promise { + const result: PageResult = await getQualifications(qualificationQuery.value) + qualifications.value = result.list + qualificationTotal.value = result.total + } + + async function createQualificationAction(input: QualificationInput): Promise { + await createQualification(input) + ElMessage.success('资质提交成功') + qualificationQuery.value = { ...qualificationQuery.value, page: 1 } + await fetchQualifications() + } + + async function updateQualificationAction(id: string, input: QualificationInput): Promise { + await updateQualification(id, input) + ElMessage.success('资质保存成功') + await fetchQualifications() + } + + async function deleteQualificationAction(id: string): Promise { + await deleteQualification(id) + ElMessage.success('资质删除成功') + await fetchQualifications() + } + + async function changeQualificationPage(page: number): Promise { + qualificationQuery.value = { ...qualificationQuery.value, page } + await fetchQualifications() + } + + // ── 意见反馈 / 帮助中心 ── + async function submitFeedbackAction(feedback: Feedback): Promise { + await submitFeedbackApi(feedback) + } + + const helpFaqs = ref([]) + + async function fetchHelpFaqs(): Promise { + helpFaqs.value = await getHelpFaqs() + } + + return { + profile, + fetchProfile, + saveNickname, + changePassword: changePasswordAction, + bindPhone: bindPhoneAction, + bindEmail: bindEmailAction, + unbindEmail: unbindEmailAction, + verifyIdentity: verifyIdentityAction, + deleteAccount: deleteAccountAction, + addresses, + addressTotal, + addressQuery, + fetchAddresses, + createAddress: createAddressAction, + updateAddress: updateAddressAction, + deleteAddress: deleteAddressAction, + setDefaultAddress: setDefaultAddressAction, + changeAddressPage, + favorites, + favoritesTotal, + favoritesCounts, + favoritesQuery, + fetchFavorites, + fetchFavoritesCounts, + searchFavorites, + resetFavorites, + changeFavoritesPage, + qualifications, + qualificationTotal, + qualificationQuery, + fetchQualifications, + createQualification: createQualificationAction, + updateQualification: updateQualificationAction, + deleteQualification: deleteQualificationAction, + changeQualificationPage, + submitFeedback: submitFeedbackAction, + helpFaqs, + fetchHelpFaqs, + } +}) diff --git a/src/stores/modules/project.ts b/src/stores/modules/project.ts new file mode 100644 index 0000000..f23da0e --- /dev/null +++ b/src/stores/modules/project.ts @@ -0,0 +1,106 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { ElMessage } from 'element-plus' +import type { PageResult } from '@/types/api' +import type { AfterSalesPayload, Project, ProjectQuery } from '@/types/business/project' +import { + cancelAfterSales as cancelAfterSalesApi, + deleteProjectFile, + getProjectDetail, + getProjectList, + submitAfterSales as submitAfterSalesApi, + uploadProjectFile, +} from '@/api/project' + +function createDefaultQuery(): ProjectQuery { + return { + keyword: '', + businessType: 'all', + status: 'all', + timeShortcut: 'all', + page: 1, + pageSize: 8, + } +} + +/** 项目中心数据(列表 / 筛选 / 分页 / 详情 / 文件 / 售后) */ +export const useProjectStore = defineStore('project', () => { + const query = ref(createDefaultQuery()) + const list = ref([]) + const total = ref(0) + const current = ref(null) + + async function fetchList(): Promise { + const result: PageResult = await getProjectList(query.value) + list.value = result.list + total.value = result.total + } + + async function fetchDetail(id: string): Promise { + current.value = await getProjectDetail(id) + } + + function setQuery(next: ProjectQuery): void { + query.value = next + } + + /** 确定筛选(重置页码后取数) */ + async function search(): Promise { + query.value = { ...query.value, page: 1 } + await fetchList() + } + + /** 重置全部条件 */ + async function resetQuery(): Promise { + query.value = createDefaultQuery() + await fetchList() + } + + async function changePage(page: number): Promise { + query.value = { ...query.value, page } + await fetchList() + } + + /** 上传项目文件(追加到当前详情) */ + async function uploadFile(file: File): Promise { + const info = await uploadProjectFile(file) + current.value?.files.push(info) + } + + /** 删除当前详情中的项目文件 */ + async function deleteFile(index: number): Promise { + await deleteProjectFile(index) + current.value?.files.splice(index, 1) + } + + /** 提交售后工单 */ + async function submitAfterSales(payload: AfterSalesPayload): Promise { + if (!current.value) return + current.value.afterSales = await submitAfterSalesApi(current.value.id, payload) + ElMessage.success('已提交售后工单') + } + + /** 取消售后工单 */ + async function cancelAfterSales(): Promise { + if (!current.value) return + current.value.afterSales = await cancelAfterSalesApi(current.value.id) + ElMessage.success('已取消售后工单') + } + + return { + query, + list, + total, + current, + fetchList, + fetchDetail, + setQuery, + search, + resetQuery, + changePage, + uploadFile, + deleteFile, + submitAfterSales, + cancelAfterSales, + } +}) diff --git a/src/stores/modules/service.ts b/src/stores/modules/service.ts new file mode 100644 index 0000000..f89d26b --- /dev/null +++ b/src/stores/modules/service.ts @@ -0,0 +1,60 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { ChatMessage } from '@/types/business/service' +import { + getChatHistory, + mockAutoReply, + sendChatMessage, + uploadChatFile, +} from '@/api/service' + +/** 在线客服会话数据(消息列表 / 发送状态) */ +export const useServiceStore = defineStore('service', () => { + const messages = ref([]) + const sending = ref(false) + + async function fetchHistory(): Promise { + messages.value = await getChatHistory() + } + + /** 发送文本:先推入用户消息,600ms 后推入客服自动回复 */ + async function send(content: string): Promise { + const trimmed = content.trim() + if (!trimmed || sending.value) return + sending.value = true + try { + const userMsg = await sendChatMessage(trimmed) + messages.value.push(userMsg) + window.setTimeout(() => { + void mockAutoReply().then((reply) => { + messages.value.push(reply) + sending.value = false + }) + }, 600) + } + catch { + sending.value = false + } + } + + /** 发送文件卡片消息 */ + async function sendFile(file: File): Promise { + if (sending.value) return + sending.value = true + try { + const fileMsg = await uploadChatFile(file) + messages.value.push(fileMsg) + } + finally { + sending.value = false + } + } + + return { + messages, + sending, + fetchHistory, + send, + sendFile, + } +}) diff --git a/src/stores/modules/site.ts b/src/stores/modules/site.ts new file mode 100644 index 0000000..e8e1390 --- /dev/null +++ b/src/stores/modules/site.ts @@ -0,0 +1,24 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +/** 站点公共数据(站点配置、全局 loading),非页面级 */ +export const useSiteStore = defineStore('site', () => { + const globalLoading = ref(false) + + // TODO(api): 站点配置接入后由 fetchSiteConfig 覆盖默认值 + const siteInfo = ref({ + appName: '虫子智造', + hotline: '400-XXX-XXXX', + businessEmail: 'business@chongzizhizao.com', + supportEmail: 'support@chongzizhizao.com', + workTime: '周一-周五 9:00-18:00', + icp: 'ICP 备 XXXXXXXX 号', + police: '公网安备 XXXXXXXXXXXX 号', + }) + + function setGlobalLoading(value: boolean): void { + globalLoading.value = value + } + + return { globalLoading, siteInfo, setGlobalLoading } +}) diff --git a/src/stores/modules/user.ts b/src/stores/modules/user.ts new file mode 100644 index 0000000..0fbceac --- /dev/null +++ b/src/stores/modules/user.ts @@ -0,0 +1,137 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { ElMessage } from 'element-plus' +import type { + LoginByAccountParams, + LoginBySmsParams, + LoginResult, + RegisterParams, + ResetPasswordParams, + User, +} from '@/types/business/user' +import { + loginByAccount as loginByAccountApi, + loginBySms as loginBySmsApi, + register as registerApi, + resetPassword as resetPasswordApi, + sendSmsCode as sendSmsCodeApi, +} from '@/api/auth' +import { storage } from '@/utils/storage' + +/** 用户登录态(M4:token 持久化 key 'token',路由守卫读取同一 key) */ +export const useUserStore = defineStore('user', () => { + const token = ref(storage.get('token')) + const user = ref(null) + const loading = ref(false) + + const isLoggedIn = computed(() => Boolean(token.value)) + + /** 发送短信验证码 */ + async function sendSmsCode(phone: string): Promise { + try { + await sendSmsCodeApi(phone) + ElMessage.success('验证码已发送') + } + catch (error) { + ElMessage.error(error instanceof Error ? error.message : '验证码发送失败') + throw error + } + } + + /** 登录成功统一落库(token 持久化) */ + function applyLogin(result: LoginResult): void { + token.value = result.token + user.value = result.user + storage.set('token', result.token) + } + + /** 账号密码登录 */ + async function loginByAccount(params: LoginByAccountParams): Promise { + loading.value = true + try { + const result = await loginByAccountApi(params) + applyLogin(result) + ElMessage.success('登录成功') + return true + } + catch (error) { + ElMessage.error(error instanceof Error ? error.message : '登录失败') + return false + } + finally { + loading.value = false + } + } + + /** 手机验证码登录 */ + async function loginBySms(params: LoginBySmsParams): Promise { + loading.value = true + try { + const result = await loginBySmsApi(params) + applyLogin(result) + ElMessage.success('登录成功') + return true + } + catch (error) { + ElMessage.error(error instanceof Error ? error.message : '登录失败') + return false + } + finally { + loading.value = false + } + } + + /** 注册 */ + async function register(params: RegisterParams): Promise { + loading.value = true + try { + await registerApi(params) + ElMessage.success('注册成功,请登录') + return true + } + catch (error) { + ElMessage.error(error instanceof Error ? error.message : '注册失败') + return false + } + finally { + loading.value = false + } + } + + /** 重置密码 */ + async function resetPassword(params: ResetPasswordParams): Promise { + loading.value = true + try { + await resetPasswordApi(params) + ElMessage.success('修改成功,请登录') + return true + } + catch (error) { + ElMessage.error(error instanceof Error ? error.message : '修改失败') + return false + } + finally { + loading.value = false + } + } + + /** 退出登录(清除持久化 token) */ + function logout(): void { + token.value = null + user.value = null + storage.remove('token') + } + + return { + token, + user, + loading, + isLoggedIn, + sendSmsCode, + loginByAccount, + loginBySms, + register, + resetPassword, + logout, + } +}) diff --git a/src/styles/common.less b/src/styles/common.less new file mode 100644 index 0000000..5ea3f1d --- /dev/null +++ b/src/styles/common.less @@ -0,0 +1,25 @@ +// 通用工具类 + +.container { + width: 100%; + max-width: @container-width; + margin: 0 auto; + padding: 0 @spacing-base; +} + +.link { + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: @primary-color; + } +} + +.text-ellipsis { + .ellipsis(); +} + +.text-secondary { + color: @text-color-secondary; +} diff --git a/src/styles/index.less b/src/styles/index.less new file mode 100644 index 0000000..52725ae --- /dev/null +++ b/src/styles/index.less @@ -0,0 +1,3 @@ +// 样式入口(main.ts 引入;variables/mixins 由 vite additionalData 自动注入) +@import './reset.less'; +@import './common.less'; diff --git a/src/styles/mixins.less b/src/styles/mixins.less new file mode 100644 index 0000000..67cca48 --- /dev/null +++ b/src/styles/mixins.less @@ -0,0 +1,37 @@ +// 混合宏(flex、文本省略、滚动条) + +.flex(@direction: row; @justify: flex-start; @align: center) { + display: flex; + flex-direction: @direction; + justify-content: @justify; + align-items: @align; +} + +.ellipsis() { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.multi-ellipsis(@lines: 2) { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: @lines; + overflow: hidden; +} + +.scrollbar(@width: 6px) { + &::-webkit-scrollbar { + width: @width; + height: @width; + } + + &::-webkit-scrollbar-thumb { + background: @border-color-dark; + border-radius: @radius-round; + } + + &::-webkit-scrollbar-track { + background: transparent; + } +} diff --git a/src/styles/reset.less b/src/styles/reset.less new file mode 100644 index 0000000..b3e689f --- /dev/null +++ b/src/styles/reset.less @@ -0,0 +1,64 @@ +// 样式重置 + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + color: @text-color; + font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, 'Helvetica Neue', Arial, sans-serif; + font-size: @font-size-base; + line-height: 1.5; + background-color: @bg-color; + -webkit-font-smoothing: antialiased; +} + +ul, +ol { + margin: 0; + padding: 0; + list-style: none; +} + +h1, +h2, +h3, +h4, +h5, +h6, +p { + margin: 0; + font-weight: normal; +} + +a { + color: inherit; + text-decoration: none; +} + +img { + display: block; + max-width: 100%; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; + border: none; + background: transparent; +} diff --git a/src/styles/variables.less b/src/styles/variables.less new file mode 100644 index 0000000..4f4b914 --- /dev/null +++ b/src/styles/variables.less @@ -0,0 +1,56 @@ +// ★ less 变量唯一来源(颜色/间距/字号/圆角) +// 注:蓝湖设计稿到位前为原型近似值,到位后按标注精确校准 + +// 品牌色 +@primary-color: #3370ff; +@primary-hover: #598bff; +@primary-light: #eaf1ff; + +// 功能色 +@success-color: #34c724; +@warning-color: #ff8f1f; +@danger-color: #f54a45; +@info-color: #8a94a6; + +// 文字色 +@heading-color: #1f2733; +@text-color: #525e71; +@text-color-secondary: #8a94a6; +@text-color-disabled: #b8c0cc; + +// 背景 / 边框 +@bg-color: #f5f6f8; +@bg-color-light: #fafbfc; +@border-color: #e4e7ec; +@border-color-dark: #cdd3dd; +@white: #ffffff; + +// 圆角 +@radius-sm: 4px; +@radius-base: 8px; +@radius-lg: 12px; +@radius-round: 999px; + +// 间距 +@spacing-xs: 4px; +@spacing-sm: 8px; +@spacing-base: 16px; +@spacing-lg: 24px; +@spacing-xl: 32px; +@spacing-xxl: 48px; + +// 字号 +@font-size-sm: 12px; +@font-size-base: 14px; +@font-size-lg: 16px; +@font-size-xl: 20px; +@font-size-xxl: 28px; + +// 布局 +@container-width: 1200px; +@header-height: 64px; +@footer-height: 240px; + +// 阴影 +@shadow-base: 0 2px 8px rgba(31, 39, 51, 0.08); +@shadow-lg: 0 4px 16px rgba(31, 39, 51, 0.12); diff --git a/src/types/api.d.ts b/src/types/api.d.ts new file mode 100644 index 0000000..868edd6 --- /dev/null +++ b/src/types/api.d.ts @@ -0,0 +1,31 @@ +// 接口通用结构(PRD 4.8) + +/** 业务类型(跨模块共享) */ +export type BusinessType = 'internet' | 'hardware' | 'iot' | 'manufacturing' | 'other' + +/** 统一响应结构 */ +export interface ApiResponse { + code: number + msg: string + data: T +} + +/** 分页结果 */ +export interface PageResult { + list: T[] + total: number +} + +/** 通用分页查询参数 */ +export interface PageQuery { + page: number + pageSize: number +} + +/** 附件/文件信息 */ +export interface FileInfo { + name: string + size: number + uploadTime: string + url: string +} diff --git a/src/types/business/cases.d.ts b/src/types/business/cases.d.ts new file mode 100644 index 0000000..d2c28f9 --- /dev/null +++ b/src/types/business/cases.d.ts @@ -0,0 +1,38 @@ +// 案例实体(PRD 4.1,home/cases/profile.favorites 共享) + +import type { BusinessType } from '@/types/api' + +/** 项目规模区间 */ +export type ScaleRange = 'lt50w' | '50to100w' | '100to300w' | '300to500w' | 'gt500w' + +/** 案例 */ +export interface Case { + id: string + title: string + category: BusinessType + cover: string + finishDate: string + teamSize: number + /** 规模展示值(如 '300万'、'10-50万'),筛选区间见 CaseQuery.scales */ + scale: string + industry: string + duration: string + clientType: string + background: string + painPoints: string + requirements: string + deliverables: string + video: string + isFavorite: boolean +} + +/** 案例筛选条件 */ +export interface CaseQuery { + category: BusinessType | 'all' + industries: string[] + scales: ScaleRange[] + dateRange: [string, string] | null + keyword: string + page: number + pageSize: number +} diff --git a/src/types/business/contract.d.ts b/src/types/business/contract.d.ts new file mode 100644 index 0000000..eb48dbe --- /dev/null +++ b/src/types/business/contract.d.ts @@ -0,0 +1,29 @@ +// 合同中心实体(PRD M6) + +import type { FileInfo } from '@/types/api' + +/** 签署状态 */ +export type SignStatus = '履行中' | '已过期' | '待签署' + +/** 合同 */ +export interface Contract { + id: string + contractNo: string + contractName: string + contractType: string + contractAmount: number + relatedProjectName: string + projectNo: string + signStatus: SignStatus + signDate?: string + effectiveDate: string + expiryDate: string + files: FileInfo[] +} + +/** 合同列表查询条件 */ +export interface ContractQuery { + keyword: string + page: number + pageSize: number +} diff --git a/src/types/business/demand.d.ts b/src/types/business/demand.d.ts new file mode 100644 index 0000000..7eaac14 --- /dev/null +++ b/src/types/business/demand.d.ts @@ -0,0 +1,20 @@ +// 需求提报实体(M3,page-architecture) + +import type { BusinessType, FileInfo } from '@/types/api' + +/** 预算范围 */ +export type BudgetRange = 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | '' + +/** 需求提报表单 */ +export interface Demand { + businessType: BusinessType + projectName: string + attachments: FileInfo[] + coreDescription: string + expectedDuration: number | '' + budgetRange: 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | '' + userName: string + userPhone: string + remark: string + status: 'draft' | 'submitted' +} diff --git a/src/types/business/finance.d.ts b/src/types/business/finance.d.ts new file mode 100644 index 0000000..e718dad --- /dev/null +++ b/src/types/business/finance.d.ts @@ -0,0 +1,92 @@ +// 财务中心实体(账单/支付/发票/抬头/统计) + +/** 账单状态 */ +export type BillStatus = '待支付' | '已支付' | '未到期' + +/** 发票状态 */ +export type InvoiceStatus = '已开票' | '开票中' | '未开票' + +/** 支付方式 */ +export type PayMethod = 'wechat' | 'alipay' + +/** 账单 */ +export interface Bill { + id: string + /** 分期计划名,如 '第一期·预付款 比例33%' */ + planName: string + billNo: string + tradeNo?: string + projectName: string + amount: number + status: BillStatus + createTime: string +} + +/** 账单查询条件 */ +export interface BillQuery { + keyword: string + status: BillStatus | 'all' + page: number + pageSize: number +} + +/** 支付单(二维码) */ +export interface Payment { + billId: string + method: PayMethod + amount: number + qrExpiresInSeconds: number +} + +/** 支付结果 */ +export interface PaymentResult { + method: PayMethod + amount: number + payTime: string + tradeNo: string + bills: Bill[] +} + +/** 发票 */ +export interface Invoice { + id: string + planName: string + billNo: string + tradeNo?: string + projectName: string + amount: number + status: InvoiceStatus + applyTime: string +} + +/** 发票查询条件 */ +export interface InvoiceQuery { + keyword: string + status: InvoiceStatus | 'all' + page: number + pageSize: number +} + +/** 发票抬头 */ +export interface InvoiceTitle { + id: string + type: '企业单位' | '非企业单位' + title: string + taxNo: string + isDefault: boolean +} + +/** 新增/编辑发票抬头入参 */ +export interface InvoiceTitlePayload { + type: InvoiceTitle['type'] + title: string + taxNo: string +} + +/** 财务统计 */ +export interface FinanceStats { + totalExpense: number + month: string + pendingCount: number + distribution: Array<{ name: string; value: number; color: string }> +} diff --git a/src/types/business/home.d.ts b/src/types/business/home.d.ts new file mode 100644 index 0000000..99edb5a --- /dev/null +++ b/src/types/business/home.d.ts @@ -0,0 +1,34 @@ +// 首页实体(PRD 3.1/3.2,page-architecture M1) + +import type { BusinessType } from '@/types/api' + +/** 数据看板 */ +export interface HomeStats { + pending: number + inProgress: number + total: number +} + +/** 快捷入口 */ +export interface QuickLink { + label: string + path: string +} + +/** 服务类型子项 */ +export interface ServiceType { + label: string + desc: string +} + +/** 业务线(首页业务卡片 + 业务详情页共用) */ +export interface BusinessLine { + id: BusinessType + name: string + desc: string + slogan: string + icon: string + serviceContent: string + serviceTypes: ServiceType[] + video: string +} diff --git a/src/types/business/message.d.ts b/src/types/business/message.d.ts new file mode 100644 index 0000000..8556c32 --- /dev/null +++ b/src/types/business/message.d.ts @@ -0,0 +1,27 @@ +// 消息实体(PRD M8 消息中心) + +import type { FileInfo } from '@/types/api' + +/** 消息类型 */ +export type MessageType = '在线客服' | '状态通知' | '系统通知' + +/** 站内消息 */ +export interface Message { + id: string + type: MessageType + title: string + summary: string + /** 正文纯文本,详情页 pre-wrap 渲染 */ + content: string + publishTime: string + isRead: boolean + attachments: FileInfo[] +} + +/** 消息查询条件 */ +export interface MessageQuery { + keyword: string + type: MessageType | 'all' + page: number + pageSize: number +} diff --git a/src/types/business/profile.d.ts b/src/types/business/profile.d.ts new file mode 100644 index 0000000..262c50d --- /dev/null +++ b/src/types/business/profile.d.ts @@ -0,0 +1,87 @@ +// 个人中心实体(M10:security/address/favorites/qualifications/feedback/help/about/hotline 共享) + +import type { BusinessType, FileInfo } from '@/types/api' + +/** 账号资料 */ +export interface Profile { + nickname: string + phone: string + email: string + /** 密码上次修改时间(展示值,如 '15天前') */ + passwordModifiedAt: string +} + +/** 修改密码入参 */ +export interface ChangePasswordParams { + code: string + password: string +} + +/** 收货地址 */ +export interface Address { + id: string + receiverName: string + phone: string + region: string + detailAddress: string + isDefault: boolean +} + +/** 资质类型 */ +export type QualificationType = + | '营业执照' + | '生产许可证' + | '体系认证' + | '检测报告' + | '授权书' + | '其他资质' + +/** 资质审核状态 */ +export type QualificationStatus = '已认证' | '审核中' | '未通过' + +/** 资质 */ +export interface Qualification { + id: string + type: QualificationType + certName: string + certNo: string + issuer: string + validFrom: string + validTo: string + status: QualificationStatus + attachments: FileInfo[] +} + +/** 资质新增/编辑提交载荷(id 存在为编辑) */ +export type QualificationInput = Omit & { id?: string } + +/** 意见反馈 */ +export interface Feedback { + title: string + description: string + attachments: FileInfo[] +} + +/** 列表分页查询 */ +export interface ListQuery { + page: number + pageSize: number +} + +/** 我的收藏查询(关键字 + 分类 + 分页) */ +export interface FavoritesQuery extends ListQuery { + keyword: string + category: BusinessType | 'all' +} + +/** 帮助中心 FAQ 条目 */ +export interface HelpFaqItem { + q: string + a: string +} + +/** 帮助中心 FAQ 分组 */ +export interface HelpFaqGroup { + title: string + items: HelpFaqItem[] +} diff --git a/src/types/business/project.d.ts b/src/types/business/project.d.ts new file mode 100644 index 0000000..627b2c3 --- /dev/null +++ b/src/types/business/project.d.ts @@ -0,0 +1,79 @@ +// 项目中心实体(PRD M5) + +import type { BusinessType, FileInfo } from '@/types/api' + +/** 项目状态 */ +export type ProjectStatus = '待立项' | '进行中' | '待验收' | '已完成' | '售后中' | '已取消' + +/** 里程碑节点 */ +export interface Milestone { + name: string + status: '待开始' | '进行中' | '已完成' + time?: string +} + +/** 团队成员 */ +export interface TeamMember { + role: string + name: string + avatarChar: string +} + +/** 项目账单(付款计划) */ +export interface ProjectBill { + id: string + planName: string + billNo: string + amount: number + status: '待支付' | '已支付' | '未到期' + tradeNo?: string + projectName: string +} + +/** 售后工单 */ +export interface AfterSales { + status: '无' | '处理中' + type: string + description: string + remark: string + attachments: FileInfo[] + createTime: string +} + +/** 售后提交入参 */ +export interface AfterSalesPayload { + type: string + description: string + remark: string + attachments: FileInfo[] +} + +/** 项目 */ +export interface Project { + id: string + projectNo: string + projectName: string + businessType: BusinessType + progress: number + status: ProjectStatus + submitTime: string + startTime?: string + endTime?: string + milestones: Milestone[] + team: TeamMember[] + files: FileInfo[] + paymentPlans: ProjectBill[] + invoiceRecords: FileInfo[] + contractFile?: FileInfo + afterSales?: AfterSales +} + +/** 项目列表查询条件 */ +export interface ProjectQuery { + keyword: string + businessType: BusinessType | 'all' + status: ProjectStatus | 'all' + timeShortcut: 'all' | '3m' | '6m' | '1y' + page: number + pageSize: number +} diff --git a/src/types/business/service.d.ts b/src/types/business/service.d.ts new file mode 100644 index 0000000..dfb3050 --- /dev/null +++ b/src/types/business/service.d.ts @@ -0,0 +1,17 @@ +// 在线客服会话实体(PRD M9 在线客服) + +/** 会话角色 */ +export type ChatRole = 'user' | 'assistant' + +/** 会话消息 */ +export interface ChatMessage { + id: string + role: ChatRole + content: string + /** 文件消息卡片(有值时按文件渲染) */ + file?: { + name: string + size: number + } + time: string +} diff --git a/src/types/business/user.d.ts b/src/types/business/user.d.ts new file mode 100644 index 0000000..c2f232f --- /dev/null +++ b/src/types/business/user.d.ts @@ -0,0 +1,47 @@ +// 用户/认证实体(M4,page-architecture) + +/** 用户 */ +export interface User { + id: string + nickname: string + avatar: string + phone: string + email: string + passwordModifiedAt: string +} + +/** 账号密码登录参数 */ +export interface LoginByAccountParams { + account: string + password: string +} + +/** 短信验证码登录参数 */ +export interface LoginBySmsParams { + phone: string + code: string +} + +/** 登录结果 */ +export interface LoginResult { + token: string + user: User +} + +/** 注册参数 */ +export interface RegisterParams { + phone: string + code: string + password: string + confirmPassword: string + agree: boolean +} + +/** 找回/重置密码参数 */ +export interface ResetPasswordParams { + phone: string + code: string + password: string + confirmPassword: string + agree: boolean +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 0000000..35a9c97 --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,8 @@ +// 全局声明(Window 扩展等,按需添加) +export {} + +declare global { + interface Window { + // 全局挂载扩展位 + } +} diff --git a/src/types/router.d.ts b/src/types/router.d.ts new file mode 100644 index 0000000..0e3ee2d --- /dev/null +++ b/src/types/router.d.ts @@ -0,0 +1,11 @@ +// RouteMeta 扩展 +export {} + +declare module 'vue-router' { + interface RouteMeta { + /** 页面标题(document.title) */ + title?: string + /** 是否需要登录 */ + requiresAuth?: boolean + } +} diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..6eb4de3 --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,67 @@ +// 格式化工具(无 Vue 依赖) + +import type { BusinessType } from '@/types/api' +import type { ScaleRange } from '@/types/business/cases' + +const BUSINESS_TYPE_LABELS: Record = { + internet: '互联网软件', + hardware: '硬件电路', + iot: '物联网全案', + manufacturing: '生产制造', + other: '其他', +} + +const SCALE_LABELS: Record = { + lt50w: '50万以下', + '50to100w': '50-100万', + '100to300w': '100-300万', + '300to500w': '300-500万', + gt500w: '500万以上', +} + +/** 业务类型展示名 */ +export function formatBusinessType(type: BusinessType): string { + return BUSINESS_TYPE_LABELS[type] +} + +/** 项目规模展示名 */ +export function formatScale(scale: ScaleRange): string { + return SCALE_LABELS[scale] +} + +/** 金额千分位(默认两位小数) */ +export function formatMoney(value: number, decimals = 2): string { + const fixed = value.toFixed(decimals) + const [int = '0', fraction] = fixed.split('.') + const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + return fraction ? `${withSep}.${fraction}` : withSep +} + +function pad(n: number): string { + return n < 10 ? `0${n}` : String(n) +} + +/** 日期格式化(YYYY-MM-DD HH:mm:ss 可裁剪) */ +export function formatDate(input: Date | string | number, pattern = 'YYYY-MM-DD HH:mm:ss'): string { + const date = input instanceof Date ? input : new Date(input) + if (Number.isNaN(date.getTime())) return '' + const map: Record = { + YYYY: String(date.getFullYear()), + MM: pad(date.getMonth() + 1), + DD: pad(date.getDate()), + HH: pad(date.getHours()), + mm: pad(date.getMinutes()), + ss: pad(date.getSeconds()), + } + return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (token) => map[token] ?? token) +} + +/** 文件大小(B/KB/MB/GB) */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb.toFixed(1)}KB` + const mb = kb / 1024 + if (mb < 1024) return `${mb.toFixed(1)}MB` + return `${(mb / 1024).toFixed(1)}GB` +} diff --git a/src/utils/request.ts b/src/utils/request.ts new file mode 100644 index 0000000..f19b0f3 --- /dev/null +++ b/src/utils/request.ts @@ -0,0 +1,42 @@ +import axios, { type AxiosRequestConfig } from 'axios' +import { ElMessage } from 'element-plus' +import type { ApiResponse } from '@/types/api' +import { storage } from '@/utils/storage' + +// TODO(api): 成功码与鉴权头格式待接口文档确认 +const SUCCESS_CODE = 0 + +/** 全项目唯一 axios 实例 */ +const service = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL, + timeout: 15000, +}) + +service.interceptors.request.use((config) => { + const token = storage.get('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +service.interceptors.response.use(undefined, (error) => { + ElMessage.error(error?.message || '网络异常,请稍后重试') + return Promise.reject(error) +}) + +/** + * 统一请求:自动解包 ApiResponse,失败统一提示并 reject + * 页面/组件禁止直接 import axios,一律经 store 调用本函数 + */ +export async function request(config: AxiosRequestConfig): Promise { + const resp = await service.request>(config) + const payload = resp.data + if (payload.code !== SUCCESS_CODE) { + ElMessage.error(payload.msg || '请求失败') + return Promise.reject(new Error(payload.msg || '请求失败')) + } + return payload.data +} + +export default service diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..ca62452 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,34 @@ +// localStorage/sessionStorage 泛型封装(无 Vue 依赖) + +const PREFIX = 'cz:' + +function parse(raw: string | null): T | null { + if (raw === null) return null + try { + return JSON.parse(raw) as T + } + catch { + return null + } +} + +export const storage = { + get(key: string): T | null { + return parse(localStorage.getItem(PREFIX + key)) + }, + set(key: string, value: T): void { + localStorage.setItem(PREFIX + key, JSON.stringify(value)) + }, + remove(key: string): void { + localStorage.removeItem(PREFIX + key) + }, + sessionGet(key: string): T | null { + return parse(sessionStorage.getItem(PREFIX + key)) + }, + sessionSet(key: string, value: T): void { + sessionStorage.setItem(PREFIX + key, JSON.stringify(value)) + }, + sessionRemove(key: string): void { + sessionStorage.removeItem(PREFIX + key) + }, +} diff --git a/src/utils/validate.ts b/src/utils/validate.ts new file mode 100644 index 0000000..23025eb --- /dev/null +++ b/src/utils/validate.ts @@ -0,0 +1,36 @@ +// 校验规则(无 Vue 依赖) + +/** 手机号 */ +export function isPhone(value: string): boolean { + return /^1[3-9]\d{9}$/.test(value) +} + +/** 邮箱 */ +export function isEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) +} + +/** URL */ +export function isUrl(value: string): boolean { + return /^https?:\/\/[^\s]+$/.test(value) +} + +export type PasswordLevel = 'weak' | 'medium' | 'strong' + +/** + * 密码强度等级(6-20 位) + * weak:仅字母或仅数字;medium:字母+数字;strong:字母+数字+特殊符号 + */ +export function getPasswordLevel(password: string): PasswordLevel { + const hasLetter = /[a-zA-Z]/.test(password) + const hasDigit = /\d/.test(password) + const hasSymbol = /[^\w\s]/.test(password) + if (hasLetter && hasDigit && hasSymbol) return 'strong' + if (hasLetter && hasDigit) return 'medium' + return 'weak' +} + +/** 登录密码合法性(6-20 位) */ +export function isValidPassword(password: string): boolean { + return password.length >= 6 && password.length <= 20 +} diff --git a/src/views/404.vue b/src/views/404.vue new file mode 100644 index 0000000..8c0cd05 --- /dev/null +++ b/src/views/404.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/views/cases/cpns/CaseDetailHeader.vue b/src/views/cases/cpns/CaseDetailHeader.vue new file mode 100644 index 0000000..d845a93 --- /dev/null +++ b/src/views/cases/cpns/CaseDetailHeader.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/src/views/cases/cpns/CaseRichText.vue b/src/views/cases/cpns/CaseRichText.vue new file mode 100644 index 0000000..a19adb5 --- /dev/null +++ b/src/views/cases/cpns/CaseRichText.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/src/views/cases/cpns/CasesFilter.vue b/src/views/cases/cpns/CasesFilter.vue new file mode 100644 index 0000000..5f55f88 --- /dev/null +++ b/src/views/cases/cpns/CasesFilter.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/src/views/cases/cpns/CasesGrid.vue b/src/views/cases/cpns/CasesGrid.vue new file mode 100644 index 0000000..a419cb4 --- /dev/null +++ b/src/views/cases/cpns/CasesGrid.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/src/views/cases/detail.vue b/src/views/cases/detail.vue new file mode 100644 index 0000000..d6f4f3a --- /dev/null +++ b/src/views/cases/detail.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/views/cases/index.ts b/src/views/cases/index.ts new file mode 100644 index 0000000..9757d40 --- /dev/null +++ b/src/views/cases/index.ts @@ -0,0 +1,5 @@ +export { default } from './index.vue' +export { default as CasesFilter } from './cpns/CasesFilter.vue' +export { default as CasesGrid } from './cpns/CasesGrid.vue' +export { default as CaseDetailHeader } from './cpns/CaseDetailHeader.vue' +export { default as CaseRichText } from './cpns/CaseRichText.vue' diff --git a/src/views/cases/index.vue b/src/views/cases/index.vue new file mode 100644 index 0000000..a6de2b9 --- /dev/null +++ b/src/views/cases/index.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/views/contract/cpns/ContractInfoCard.vue b/src/views/contract/cpns/ContractInfoCard.vue new file mode 100644 index 0000000..7a7547f --- /dev/null +++ b/src/views/contract/cpns/ContractInfoCard.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/src/views/contract/cpns/ContractSignPanel.vue b/src/views/contract/cpns/ContractSignPanel.vue new file mode 100644 index 0000000..c3f6980 --- /dev/null +++ b/src/views/contract/cpns/ContractSignPanel.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/src/views/contract/cpns/ContractTable.vue b/src/views/contract/cpns/ContractTable.vue new file mode 100644 index 0000000..7aee3c6 --- /dev/null +++ b/src/views/contract/cpns/ContractTable.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/views/contract/cpns/SignSteps.vue b/src/views/contract/cpns/SignSteps.vue new file mode 100644 index 0000000..c055240 --- /dev/null +++ b/src/views/contract/cpns/SignSteps.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/views/contract/detail.vue b/src/views/contract/detail.vue new file mode 100644 index 0000000..0413d5d --- /dev/null +++ b/src/views/contract/detail.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/views/contract/index.ts b/src/views/contract/index.ts new file mode 100644 index 0000000..f5eeed7 --- /dev/null +++ b/src/views/contract/index.ts @@ -0,0 +1,5 @@ +export { default } from './index.vue' +export { default as ContractTable } from './cpns/ContractTable.vue' +export { default as ContractInfoCard } from './cpns/ContractInfoCard.vue' +export { default as ContractSignPanel } from './cpns/ContractSignPanel.vue' +export { default as SignSteps } from './cpns/SignSteps.vue' diff --git a/src/views/contract/index.vue b/src/views/contract/index.vue new file mode 100644 index 0000000..f44b58b --- /dev/null +++ b/src/views/contract/index.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/views/contract/sign.vue b/src/views/contract/sign.vue new file mode 100644 index 0000000..70b591e --- /dev/null +++ b/src/views/contract/sign.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/views/demand/cpns/DemandStep1Business.vue b/src/views/demand/cpns/DemandStep1Business.vue new file mode 100644 index 0000000..5d11b3f --- /dev/null +++ b/src/views/demand/cpns/DemandStep1Business.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/src/views/demand/cpns/DemandStep2Info.vue b/src/views/demand/cpns/DemandStep2Info.vue new file mode 100644 index 0000000..1eb2b7f --- /dev/null +++ b/src/views/demand/cpns/DemandStep2Info.vue @@ -0,0 +1,229 @@ + + +