feat 搭建架构骨架
This commit is contained in:
2
.env.development
Normal file
2
.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# TODO(api): 待接口文档提供后填写
|
||||
VITE_API_BASE_URL=
|
||||
2
.env.production
Normal file
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
# TODO(api): 待接口文档提供后填写
|
||||
VITE_API_BASE_URL=
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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/*
|
||||
|
||||
@@ -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"
|
||||
|
||||
213
pnpm-lock.yaml
generated
213
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<h1>hello world</h1>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
96
src/api/auth.ts
Normal file
96
src/api/auth.ts
Normal file
@@ -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<T> 调用(页面 → store → api 数据流不变)
|
||||
|
||||
const SIX_DIGIT_CODE = /^\d{6}$/
|
||||
|
||||
/** 发送短信验证码 */
|
||||
export function sendSmsCode(phone: string): Promise<void> {
|
||||
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 账号密码登录(mock:demo 账号可登录,其余提示先注册) */
|
||||
export function loginByAccount(params: LoginByAccountParams): Promise<LoginResult> {
|
||||
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<LoginResult> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const message = validateAuthForm(
|
||||
params.phone,
|
||||
params.code,
|
||||
params.password,
|
||||
params.confirmPassword,
|
||||
params.agree,
|
||||
)
|
||||
if (message) return Promise.reject(new Error(message))
|
||||
return Promise.resolve()
|
||||
}
|
||||
227
src/api/cases.ts
Normal file
227
src/api/cases.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import type { BusinessType, PageResult } from '@/types/api'
|
||||
import type { Case, CaseQuery, ScaleRange } from '@/types/business/cases'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
|
||||
/** 案例 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<ScaleRange, [number, number]> = {
|
||||
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<PageResult<Case>> {
|
||||
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<Case | null> {
|
||||
const found = MOCK_CASES.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found)
|
||||
}
|
||||
|
||||
/** 收藏/取消收藏,返回最新案例数据 */
|
||||
export function toggleFavorite(id: string): Promise<Case> {
|
||||
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<Partial<Record<BusinessType, number>>> {
|
||||
const counts: Partial<Record<BusinessType, number>> = {}
|
||||
for (const item of MOCK_CASES) {
|
||||
counts[item.category] = (counts[item.category] ?? 0) + 1
|
||||
}
|
||||
return Promise.resolve(counts)
|
||||
}
|
||||
110
src/api/contract.ts
Normal file
110
src/api/contract.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { FileInfo, PageResult } from '@/types/api'
|
||||
import type { Contract, ContractQuery } from '@/types/business/contract'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
|
||||
/** 构造 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<PageResult<Contract>> {
|
||||
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<Contract | null> {
|
||||
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 })
|
||||
}
|
||||
25
src/api/demand.ts
Normal file
25
src/api/demand.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Demand } from '@/types/business/demand'
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → 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<Demand | null> {
|
||||
return Promise.resolve(storage.sessionGet<Demand>(DRAFT_KEY))
|
||||
}
|
||||
|
||||
/** 保存草稿到本地 */
|
||||
export function saveDraft(demand: Demand): Promise<void> {
|
||||
storage.sessionSet(DRAFT_KEY, { ...demand, status: 'draft' })
|
||||
return Promise.resolve()
|
||||
}
|
||||
316
src/api/finance.ts
Normal file
316
src/api/finance.ts
Normal file
@@ -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<T> 调用
|
||||
|
||||
/** 账单 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<string, Payment>()
|
||||
|
||||
/** 已产生支付结果的账单(billId → 结果),预置已支付账单 */
|
||||
const MOCK_RESULTS = new Map<string, PaymentResult>()
|
||||
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<FinanceStats> {
|
||||
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<PageResult<Bill>> {
|
||||
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<Bill | null> {
|
||||
const found = MOCK_BILLS.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found ? { ...found } : null)
|
||||
}
|
||||
|
||||
/** 创建支付单(生成二维码支付信息) */
|
||||
export function createPayment(billId: string, method: PayMethod): Promise<Payment> {
|
||||
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<Payment> {
|
||||
return createPayment(billId, method)
|
||||
}
|
||||
|
||||
/** 查询支付结果(无支付记录且未支付返回 null;演示数据 payTime/tradeNo 固定) */
|
||||
export function getPayResult(billId: string): Promise<PaymentResult | null> {
|
||||
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<PageResult<Invoice>> {
|
||||
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<Invoice> {
|
||||
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<InvoiceTitle[]> {
|
||||
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
|
||||
}
|
||||
|
||||
/** 新增发票抬头 */
|
||||
export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
|
||||
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<InvoiceTitle> {
|
||||
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<void> {
|
||||
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<InvoiceTitle[]> {
|
||||
for (const item of MOCK_TITLES) {
|
||||
item.isDefault = item.id === id
|
||||
}
|
||||
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
|
||||
}
|
||||
|
||||
/** 导出报表(演示:稍后由页面提示成功) */
|
||||
export function exportReport(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
180
src/api/home.ts
Normal file
180
src/api/home.ts
Normal file
@@ -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<T> 调用(页面 → 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<HomeStats> {
|
||||
return Promise.resolve({ pending: 3, inProgress: 3, total: 6 })
|
||||
}
|
||||
|
||||
/** 业务线列表(首页 4 卡片) */
|
||||
export function getBusinessLines(): Promise<BusinessLine[]> {
|
||||
return Promise.resolve(BUSINESS_LINES)
|
||||
}
|
||||
|
||||
/** 业务详情(业务详情页) */
|
||||
export function getBusinessDetail(type: BusinessType): Promise<BusinessLine> {
|
||||
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<Case[]> {
|
||||
const list = category === 'all'
|
||||
? FEATURED_CASES
|
||||
: FEATURED_CASES.filter((item) => item.category === category)
|
||||
return Promise.resolve(list)
|
||||
}
|
||||
153
src/api/message.ts
Normal file
153
src/api/message.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { PageResult } from '@/types/api'
|
||||
import type { Message, MessageQuery } from '@/types/business/message'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
|
||||
/** 消息 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<PageResult<Message>> {
|
||||
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<Message | null> {
|
||||
const found = MOCK_MESSAGES.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found ? { ...found } : null)
|
||||
}
|
||||
|
||||
/** 全部标记已读 */
|
||||
export function markAllRead(): Promise<void> {
|
||||
for (const item of MOCK_MESSAGES) {
|
||||
item.isRead = true
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 单条标记已读 */
|
||||
export function markRead(id: string): Promise<void> {
|
||||
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<number> {
|
||||
return Promise.resolve(MOCK_MESSAGES.filter((item) => !item.isRead).length)
|
||||
}
|
||||
418
src/api/profile.ts
Normal file
418
src/api/profile.ts
Normal file
@@ -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<T> 调用(页面 → store → api 数据流不变)
|
||||
|
||||
/** 账号资料 mock(可变,昵称/绑定等操作在前端内存中演示) */
|
||||
const MOCK_PROFILE: Profile = {
|
||||
nickname: '王永志',
|
||||
phone: '186****618',
|
||||
email: 'yongzh******4@163.com',
|
||||
passwordModifiedAt: '15天前',
|
||||
}
|
||||
|
||||
/** 获取账号资料 */
|
||||
export function getProfile(): Promise<Profile> {
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 修改昵称 */
|
||||
export function updateNickname(name: string): Promise<Profile> {
|
||||
MOCK_PROFILE.nickname = name
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 修改密码(验证码 + 新密码) */
|
||||
export function changePassword(params: ChangePasswordParams): Promise<void> {
|
||||
MOCK_PROFILE.passwordModifiedAt = '刚刚'
|
||||
void params
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 绑定/换绑手机号 */
|
||||
export function bindPhone(phone: string, code: string): Promise<Profile> {
|
||||
void code
|
||||
MOCK_PROFILE.phone = phone
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 绑定/换绑邮箱 */
|
||||
export function bindEmail(email: string, code: string): Promise<Profile> {
|
||||
void code
|
||||
MOCK_PROFILE.email = email
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 取消绑定邮箱 */
|
||||
export function unbindEmail(): Promise<Profile> {
|
||||
MOCK_PROFILE.email = ''
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 注销前安全身份验证 */
|
||||
export function verifyIdentity(phone: string, code: string): Promise<void> {
|
||||
void phone
|
||||
void code
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 注销账号 */
|
||||
export function deleteAccount(): Promise<void> {
|
||||
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<T>(list: T[], query: ListQuery): PageResult<T> {
|
||||
const start = (query.page - 1) * query.pageSize
|
||||
return {
|
||||
list: list.slice(start, start + query.pageSize),
|
||||
total: list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 收货地址列表(分页) */
|
||||
export function getAddresses(query: ListQuery): Promise<PageResult<Address>> {
|
||||
return Promise.resolve(paginate(MOCK_ADDRESSES, query))
|
||||
}
|
||||
|
||||
/** 新增收货地址 */
|
||||
export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
|
||||
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<Address, 'id' | 'isDefault'>): Promise<Address> {
|
||||
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<void> {
|
||||
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<Address[]> {
|
||||
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<PageResult<Case>> {
|
||||
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<Partial<Record<BusinessType, number>>> {
|
||||
const counts: Partial<Record<BusinessType, number>> = {
|
||||
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<PageResult<Qualification>> {
|
||||
return Promise.resolve(paginate(MOCK_QUALIFICATIONS, query))
|
||||
}
|
||||
|
||||
/** 新增资质(提交后进入审核中) */
|
||||
export function createQualification(input: QualificationInput): Promise<Qualification> {
|
||||
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<Qualification> {
|
||||
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<void> {
|
||||
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> {
|
||||
void feedback
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 帮助中心 FAQ */
|
||||
export function getHelpFaqs(): Promise<HelpFaqGroup[]> {
|
||||
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 等主流浏览器的最新版本访问本平台。',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}
|
||||
270
src/api/project.ts
Normal file
270
src/api/project.ts
Normal file
@@ -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<T> 调用
|
||||
|
||||
/** 构造 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<PageResult<Project>> {
|
||||
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<Project | null> {
|
||||
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<FileInfo> {
|
||||
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<void> {
|
||||
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<AfterSales> {
|
||||
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<AfterSales> {
|
||||
const result: AfterSales = {
|
||||
status: '无',
|
||||
type: '',
|
||||
description: '',
|
||||
remark: '',
|
||||
attachments: [],
|
||||
createTime: '',
|
||||
}
|
||||
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
|
||||
return Promise.resolve({ ...result })
|
||||
}
|
||||
72
src/api/service.ts
Normal file
72
src/api/service.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ChatMessage } from '@/types/business/service'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
|
||||
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<ChatMessage[]> {
|
||||
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<ChatMessage> {
|
||||
return Promise.resolve({
|
||||
id: nextId('chat'),
|
||||
role: 'user',
|
||||
content,
|
||||
time: nowTime(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 上传聊天文件(生成文件卡片消息) */
|
||||
export function uploadChatFile(file: File): Promise<ChatMessage> {
|
||||
return Promise.resolve({
|
||||
id: nextId('chat'),
|
||||
role: 'user',
|
||||
content: '',
|
||||
file: { name: file.name, size: file.size },
|
||||
time: nowTime(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 客服自动回复 */
|
||||
export function mockAutoReply(): Promise<ChatMessage> {
|
||||
return Promise.resolve({
|
||||
id: nextId('chat'),
|
||||
role: 'assistant',
|
||||
content: '收到您的消息,专属客服稍后为您答复~',
|
||||
time: nowTime(),
|
||||
})
|
||||
}
|
||||
103
src/components/AppButton.vue
Normal file
103
src/components/AppButton.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
type?: 'primary' | 'default' | 'text'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'default',
|
||||
size: 'md',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ click: [e: MouseEvent] }>()
|
||||
|
||||
function onClick(e: MouseEvent): void {
|
||||
if (props.disabled || props.loading) return
|
||||
emit('click', e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="app-button"
|
||||
:class="[`app-button--${type}`, `app-button--${size}`, { 'is-loading': loading, 'is-disabled': disabled }]"
|
||||
:disabled="disabled || loading"
|
||||
@click="onClick"
|
||||
>
|
||||
<span v-if="loading" class="app-button__loading">…</span>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.app-button {
|
||||
.flex(row, center);
|
||||
gap: @spacing-xs;
|
||||
border-radius: @radius-base;
|
||||
font-size: @font-size-base;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--sm {
|
||||
height: 28px;
|
||||
padding: 0 @spacing-sm;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&--md {
|
||||
height: 36px;
|
||||
padding: 0 @spacing-base;
|
||||
}
|
||||
|
||||
&--lg {
|
||||
height: 44px;
|
||||
padding: 0 @spacing-lg;
|
||||
font-size: @font-size-lg;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&--default {
|
||||
border: 1px solid @border-color-dark;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&--text {
|
||||
color: @primary-color;
|
||||
|
||||
&:hover {
|
||||
color: @primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
146
src/components/AppFooter.vue
Normal file
146
src/components/AppFooter.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { useSiteStore } from '@/stores/modules/site'
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
const quickLinks = [
|
||||
{ label: '首页', path: '/' },
|
||||
{ label: '案例中心', path: '/cases' },
|
||||
{ label: '需求提报', path: '/demand' },
|
||||
{ label: '下载 APP', path: '/' },
|
||||
{ label: '帮助中心', path: '/profile/help' },
|
||||
]
|
||||
|
||||
const coreBusiness = [
|
||||
'互联网软件开发',
|
||||
'硬件电路开发',
|
||||
'物联网软硬一体化全案',
|
||||
'PCB 制版 & SMT 贴片生产',
|
||||
'元器件集采与整机装配',
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<div class="app-footer__inner container">
|
||||
<div class="app-footer__about">
|
||||
<div class="app-footer__brand">
|
||||
<!-- TODO(蓝湖): 替换 logo 切图 -->
|
||||
<img src="@/assets/vue.svg" class="app-footer__logo" alt="虫子智造 logo">
|
||||
<span class="app-footer__name">虫子智造</span>
|
||||
</div>
|
||||
<p class="app-footer__desc">
|
||||
研发智造一体化自营平台,打通研发‑仿真‑设计‑PCB‑SMT‑整机装配‑售后迭代全链路,为科技企业、硬件厂商、创客团队提供软硬一体化定制与量产落地服务。平台自营不开放第三方服务商入驻,对项目质量全权兜底。
|
||||
</p>
|
||||
<p class="app-footer__legal">
|
||||
©2026 虫子智造 研发智造一体化平台 版权所有|自营研发生产平台,所有项目源码、图纸受平台知识产权保护,禁止盗用、复制、二次分发
|
||||
</p>
|
||||
<p class="app-footer__legal">
|
||||
{{ siteStore.siteInfo.icp }} | {{ siteStore.siteInfo.police }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col">
|
||||
<h4 class="app-footer__title">
|
||||
快速导航
|
||||
</h4>
|
||||
<RouterLink
|
||||
v-for="link in quickLinks"
|
||||
:key="link.label"
|
||||
:to="link.path"
|
||||
class="app-footer__link"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col">
|
||||
<h4 class="app-footer__title">
|
||||
核心业务
|
||||
</h4>
|
||||
<span
|
||||
v-for="item in coreBusiness"
|
||||
:key="item"
|
||||
class="app-footer__link"
|
||||
>{{ item }}</span>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col">
|
||||
<h4 class="app-footer__title">
|
||||
联系我们
|
||||
</h4>
|
||||
<span class="app-footer__contact">商务合作:{{ siteStore.siteInfo.businessEmail }}</span>
|
||||
<span class="app-footer__contact">技术支持:{{ siteStore.siteInfo.supportEmail }}</span>
|
||||
<span class="app-footer__contact">客服热线:{{ siteStore.siteInfo.hotline }}</span>
|
||||
<span class="app-footer__contact">工作时间:{{ siteStore.siteInfo.workTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.app-footer {
|
||||
background: @heading-color;
|
||||
color: @text-color-secondary;
|
||||
|
||||
&__inner {
|
||||
.flex(row, space-between, flex-start);
|
||||
gap: @spacing-xl;
|
||||
padding-top: @spacing-xxl;
|
||||
padding-bottom: @spacing-xxl;
|
||||
}
|
||||
|
||||
&__about {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
&__brand {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
margin-bottom: @spacing-base;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&__desc,
|
||||
&__legal {
|
||||
margin-top: @spacing-sm;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__col {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: @spacing-sm;
|
||||
font-size: @font-size-base;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: @font-size-sm;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__contact {
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
138
src/components/AppHeader.vue
Normal file
138
src/components/AppHeader.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMessageStore } from '@/stores/modules/message'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: '首页', path: '/' },
|
||||
{ label: '案例库', path: '/cases' },
|
||||
{ label: '需求提报', path: '/demand' },
|
||||
{ label: '项目中心', path: '/project' },
|
||||
{ label: '合同中心', path: '/contract' },
|
||||
{ label: '财务中心', path: '/finance' },
|
||||
{ label: '个人中心', path: '/profile' },
|
||||
]
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
const { unreadCount } = storeToRefs(messageStore)
|
||||
|
||||
onMounted(() => void messageStore.fetchUnreadCount())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="app-header__inner container">
|
||||
<RouterLink to="/" class="app-header__logo">
|
||||
<!-- TODO(蓝湖): 替换 logo 切图 -->
|
||||
<img src="@/assets/vue.svg" class="app-header__logo-icon" alt="虫子智造 logo">
|
||||
<span class="app-header__logo-text">虫子智造</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="app-header__nav">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="app-header__nav-item"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="app-header__actions">
|
||||
<RouterLink to="/message" class="app-header__bell" aria-label="消息中心">
|
||||
<span class="app-header__bell-icon">🔔</span>
|
||||
<span v-if="unreadCount > 0" class="app-header__badge">{{ unreadCount }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/login" class="app-header__login link">
|
||||
登录
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: @header-height;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__inner {
|
||||
.flex(row, space-between);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
|
||||
&-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__nav {
|
||||
.flex(row, center);
|
||||
gap: @spacing-lg;
|
||||
|
||||
&-item {
|
||||
font-size: @font-size-base;
|
||||
color: @text-color;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.router-link-active {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, flex-end);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__bell {
|
||||
position: relative;
|
||||
|
||||
&-icon {
|
||||
font-size: @font-size-lg;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -8px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: @radius-round;
|
||||
background: @danger-color;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__login {
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
125
src/components/CaseCard.vue
Normal file
125
src/components/CaseCard.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { formatBusinessType } from '@/utils/format'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
item: Case
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ view: [id: string] }>()
|
||||
|
||||
function onView(id: string): void {
|
||||
emit('view', id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="case-card" @click="onView(item.id)">
|
||||
<div class="case-card__cover">
|
||||
<!-- TODO(蓝湖): 替换案例封面切图,接入 v-lazy -->
|
||||
<img v-if="item.cover" v-lazy="item.cover" class="case-card__img" :alt="item.title">
|
||||
<div v-else class="case-card__img case-card__img--placeholder" />
|
||||
<span class="case-card__tag">{{ formatBusinessType(item.category) }}</span>
|
||||
<span class="case-card__more">查看详情 ></span>
|
||||
</div>
|
||||
<div class="case-card__body">
|
||||
<h3 class="case-card__title">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<ul class="case-card__meta">
|
||||
<li>完成时间:{{ item.finishDate }}</li>
|
||||
<li>服务团队:{{ item.teamSize }}人</li>
|
||||
<li>规模:{{ item.scale }}</li>
|
||||
<li>所属行业:{{ item.industry }}</li>
|
||||
<li>项目周期:{{ item.duration }}</li>
|
||||
<li>客户类型:{{ item.clientType }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-card {
|
||||
overflow: hidden;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-lg;
|
||||
transform: translateY(-2px);
|
||||
|
||||
.case-card__more {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__cover {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
|
||||
&--placeholder {
|
||||
background: linear-gradient(135deg, @primary-light, @bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__tag {
|
||||
position: absolute;
|
||||
top: @spacing-sm;
|
||||
left: @spacing-sm;
|
||||
padding: 2px @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__more {
|
||||
position: absolute;
|
||||
right: @spacing-sm;
|
||||
bottom: @spacing-sm;
|
||||
padding: 2px @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: @spacing-base;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: @spacing-sm;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
.multi-ellipsis(1);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px @spacing-sm;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
|
||||
li {
|
||||
.ellipsis();
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
132
src/components/ConfirmDialog.vue
Normal file
132
src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title?: string
|
||||
message?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: '提示',
|
||||
message: '',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
danger: false,
|
||||
})
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
const emit = defineEmits<{ confirm: []; cancel: [] }>()
|
||||
|
||||
function onConfirm(): void {
|
||||
emit('confirm')
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function onCancel(): void {
|
||||
emit('cancel')
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="confirm-dialog" @click.self="onCancel">
|
||||
<div class="confirm-dialog__panel">
|
||||
<h3 class="confirm-dialog__title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="confirm-dialog__body">
|
||||
<slot>{{ message }}</slot>
|
||||
</div>
|
||||
<div class="confirm-dialog__footer">
|
||||
<button class="confirm-dialog__btn" @click="onCancel">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="confirm-dialog__btn confirm-dialog__btn--confirm"
|
||||
:class="{ 'is-danger': danger }"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.confirm-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
.flex(row, center);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
|
||||
&__panel {
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: @spacing-lg;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-lg;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: @spacing-base;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-bottom: @spacing-lg;
|
||||
font-size: @font-size-base;
|
||||
color: @text-color;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
.flex(row, flex-end);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 34px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
border-color: @primary-color;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
border-color: @danger-color;
|
||||
background: @danger-color;
|
||||
|
||||
&:hover {
|
||||
background: fade(@danger-color, 85%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
93
src/components/FileList.vue
Normal file
93
src/components/FileList.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileInfo } from '@/types/api'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
files: FileInfo[]
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), { showActions: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
preview: [file: FileInfo]
|
||||
download: [file: FileInfo]
|
||||
delete: [file: FileInfo]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul v-if="files.length > 0" class="file-list">
|
||||
<li v-for="(file, index) in files" :key="`${file.name}-${index}`" class="file-list__item">
|
||||
<div class="file-list__info">
|
||||
<span class="file-list__name" :title="file.name">{{ file.name }}</span>
|
||||
<span class="file-list__meta text-secondary">
|
||||
{{ formatFileSize(file.size) }} · 上传于{{ file.uploadTime }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showActions" class="file-list__actions">
|
||||
<button class="file-list__btn" @click="emit('preview', file)">
|
||||
预览
|
||||
</button>
|
||||
<button class="file-list__btn" @click="emit('download', file)">
|
||||
下载
|
||||
</button>
|
||||
<button class="file-list__btn file-list__btn--danger" @click="emit('delete', file)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.file-list {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-sm;
|
||||
|
||||
&__item {
|
||||
.flex(row, space-between);
|
||||
gap: @spacing-base;
|
||||
padding: @spacing-sm @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
}
|
||||
|
||||
&__info {
|
||||
.flex(column, flex-start);
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, center);
|
||||
gap: @spacing-sm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
font-size: @font-size-sm;
|
||||
color: @primary-color;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: @danger-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
162
src/components/FilterPanel.vue
Normal file
162
src/components/FilterPanel.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
export interface FilterChip {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
chips?: FilterChip[]
|
||||
keywordPlaceholder?: string
|
||||
searchLabel?: string
|
||||
showResultCount?: boolean
|
||||
resultCount?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
chips: () => [],
|
||||
keywordPlaceholder: '关键字搜索',
|
||||
searchLabel: '搜索',
|
||||
showResultCount: false,
|
||||
resultCount: 0,
|
||||
})
|
||||
|
||||
const keyword = defineModel<string>('keyword', { default: '' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: []
|
||||
reset: []
|
||||
removeChip: [key: string]
|
||||
clearChips: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-panel">
|
||||
<div class="filter-panel__bar">
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="filter-panel__input"
|
||||
:placeholder="keywordPlaceholder"
|
||||
@keyup.enter="emit('search')"
|
||||
>
|
||||
<button class="filter-panel__btn filter-panel__btn--primary" @click="emit('search')">
|
||||
{{ searchLabel }}<template v-if="showResultCount">({{ resultCount }}个结果)</template>
|
||||
</button>
|
||||
<button class="filter-panel__btn" @click="emit('reset')">
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="chips.length > 0" class="filter-panel__chips">
|
||||
<span class="filter-panel__chips-label text-secondary">已选条件({{ chips.length }}个)</span>
|
||||
<button
|
||||
v-for="chip in chips"
|
||||
:key="chip.key"
|
||||
class="filter-panel__chip"
|
||||
:title="`移除 ${chip.label}`"
|
||||
@click="emit('removeChip', chip.key)"
|
||||
>
|
||||
{{ chip.label }} ×
|
||||
</button>
|
||||
<button class="filter-panel__chip filter-panel__chip--clear" @click="emit('clearChips')">
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-panel__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.filter-panel {
|
||||
padding: @spacing-base @spacing-lg;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__bar {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 240px;
|
||||
height: 34px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
font-size: @font-size-base;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 34px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
border-color: @primary-color;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
color: @white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__chips {
|
||||
.flex(row, flex-start, center);
|
||||
flex-wrap: wrap;
|
||||
gap: @spacing-sm;
|
||||
margin-top: @spacing-base;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
height: 26px;
|
||||
padding: 0 @spacing-sm;
|
||||
border: 1px solid @primary-light;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-light;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-sm;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
|
||||
&--clear {
|
||||
border-color: @border-color;
|
||||
background: @white;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-top: @spacing-base;
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
src/components/PaginationBar.vue
Normal file
108
src/components/PaginationBar.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ pageChange: [page: number] }>()
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||
|
||||
/** 页码序列:≤7 全部展示,否则首尾 + 当前页窗口(… 省略) */
|
||||
const pages = computed<Array<number | '...'>>(() => {
|
||||
const n = pageCount.value
|
||||
const { page } = props
|
||||
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||
const window: Array<number | '...'> = []
|
||||
for (let p = page - 1; p <= page + 1; p++) {
|
||||
if (p >= 1 && p <= n) window.push(p)
|
||||
}
|
||||
if (page > 3) window.unshift('...')
|
||||
if (page < n - 2) window.push('...')
|
||||
return [1, ...window.filter((item) => item !== 1 && item !== n), n].filter(
|
||||
(item, index, arr) => arr.indexOf(item) === index,
|
||||
)
|
||||
})
|
||||
|
||||
function go(page: number): void {
|
||||
if (page < 1 || page > pageCount.value || page === props.page) return
|
||||
emit('pageChange', page)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="pagination" aria-label="分页">
|
||||
<button
|
||||
class="pagination__btn"
|
||||
:disabled="page <= 1"
|
||||
@click="go(page - 1)"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
v-for="(item, index) in pages"
|
||||
:key="`${item}-${index}`"
|
||||
class="pagination__btn"
|
||||
:class="{ 'is-active': item === page, 'is-ellipsis': item === '...' }"
|
||||
:disabled="item === '...'"
|
||||
@click="go(item as number)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
<button
|
||||
class="pagination__btn"
|
||||
:disabled="page >= pageCount"
|
||||
@click="go(page + 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.pagination {
|
||||
.flex(row, center);
|
||||
gap: @spacing-xs;
|
||||
padding: @spacing-base 0;
|
||||
|
||||
&__btn {
|
||||
.flex(row, center);
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 @spacing-xs;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled):not(.is-ellipsis) {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @primary-color;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&.is-ellipsis {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:disabled:not(.is-ellipsis) {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
88
src/components/PasswordStrength.vue
Normal file
88
src/components/PasswordStrength.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { getPasswordLevel } from '@/utils/validate'
|
||||
|
||||
interface Props {
|
||||
password: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const level = computed(() => getPasswordLevel(props.password))
|
||||
|
||||
const levelText = computed(() => {
|
||||
if (!props.password) return ''
|
||||
return level.value === 'strong' ? '强' : level.value === 'medium' ? '中' : '弱'
|
||||
})
|
||||
|
||||
const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value === 'medium' ? 2 : 1))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="password" class="pwd-strength">
|
||||
<div class="pwd-strength__bars">
|
||||
<span
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="pwd-strength__bar"
|
||||
:class="{ 'is-on': i <= levelIndex, [`is-${level}`]: i <= levelIndex }"
|
||||
/>
|
||||
</div>
|
||||
<span class="pwd-strength__label" :class="`is-${level}`">密码强度:{{ levelText }}</span>
|
||||
<p v-if="level === 'weak'" class="pwd-strength__tip">
|
||||
密码等级过低,请重新输入(建议字母+数字+符号组合)
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.pwd-strength {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
|
||||
&__bars {
|
||||
.flex(row, center);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
border-radius: @radius-round;
|
||||
background: @border-color;
|
||||
|
||||
&.is-on.is-weak {
|
||||
background: @danger-color;
|
||||
}
|
||||
|
||||
&.is-on.is-medium {
|
||||
background: @warning-color;
|
||||
}
|
||||
|
||||
&.is-on.is-strong {
|
||||
background: @success-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-sm;
|
||||
|
||||
&.is-weak {
|
||||
color: @danger-color;
|
||||
}
|
||||
|
||||
&.is-medium {
|
||||
color: @warning-color;
|
||||
}
|
||||
|
||||
&.is-strong {
|
||||
color: @success-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__tip {
|
||||
font-size: @font-size-sm;
|
||||
color: @danger-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
114
src/components/QrCodePanel.vue
Normal file
114
src/components/QrCodePanel.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useCountDown } from '@/composables/useCountDown'
|
||||
|
||||
interface Props {
|
||||
/** 二维码有效期(秒),默认 4:32 */
|
||||
expiresIn?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
expiresIn: 272,
|
||||
size: 200,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ refresh: [] }>()
|
||||
|
||||
const { seconds, start } = useCountDown(props.expiresIn)
|
||||
const started = ref(false)
|
||||
|
||||
const expired = computed(() => started.value && seconds.value <= 0)
|
||||
|
||||
const countdownText = computed(() => {
|
||||
const m = Math.floor(seconds.value / 60)
|
||||
const s = seconds.value % 60
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
function refresh(): void {
|
||||
start(props.expiresIn)
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
start(props.expiresIn)
|
||||
started.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="qr-panel">
|
||||
<div
|
||||
class="qr-panel__box"
|
||||
:class="{ 'is-expired': expired }"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click="refresh"
|
||||
>
|
||||
<!-- TODO(api): 二维码内容由支付/签署接口返回后渲染 -->
|
||||
<template v-if="!expired">
|
||||
<span class="qr-panel__icon">▦</span>
|
||||
<span class="qr-panel__tip">扫码支付/签署</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="qr-panel__icon">⟳</span>
|
||||
<span class="qr-panel__tip">二维码已过期<br>点击刷新</span>
|
||||
</template>
|
||||
</div>
|
||||
<p class="qr-panel__status text-secondary">
|
||||
<template v-if="!expired">
|
||||
二维码将在 <strong class="qr-panel__count">{{ countdownText }}</strong> 后失效(过期可点击二维码刷新)
|
||||
</template>
|
||||
<template v-else>
|
||||
二维码已过期,请点击刷新
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.qr-panel {
|
||||
.flex(column, center);
|
||||
gap: @spacing-sm;
|
||||
|
||||
&__box {
|
||||
.flex(column, center);
|
||||
gap: @spacing-xs;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-expired {
|
||||
background: @bg-color;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 56px;
|
||||
color: @heading-color;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__count {
|
||||
color: @danger-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
src/components/SmsCodeInput.vue
Normal file
79
src/components/SmsCodeInput.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { useCountDown } from '@/composables/useCountDown'
|
||||
|
||||
const code = defineModel<string>('code', { default: '' })
|
||||
|
||||
const emit = defineEmits<{ getCode: [] }>()
|
||||
|
||||
const { seconds, isCounting, start } = useCountDown(60)
|
||||
|
||||
function onGetCode(): void {
|
||||
if (isCounting.value) return
|
||||
start(60)
|
||||
emit('getCode')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sms-code">
|
||||
<input
|
||||
v-model="code"
|
||||
class="sms-code__input"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
>
|
||||
<button
|
||||
class="sms-code__btn"
|
||||
:disabled="isCounting"
|
||||
@click="onGetCode"
|
||||
>
|
||||
{{ isCounting ? `${seconds}秒后重新获取` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.sms-code {
|
||||
.flex(row, stretch);
|
||||
gap: @spacing-sm;
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
font-size: @font-size-base;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex-shrink: 0;
|
||||
width: 132px;
|
||||
height: 40px;
|
||||
border: 1px solid @primary-color;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-sm;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: @primary-light;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
border-color: @border-color-dark;
|
||||
color: @text-color-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
50
src/components/StatusTag.vue
Normal file
50
src/components/StatusTag.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), { variant: 'info' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-tag" :class="`status-tag--${variant}`">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px @spacing-sm;
|
||||
border-radius: @radius-sm;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
|
||||
&--primary {
|
||||
background: @primary-light;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: fade(@success-color, 12%);
|
||||
color: @success-color;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: fade(@warning-color, 12%);
|
||||
color: @warning-color;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: fade(@danger-color, 12%);
|
||||
color: @danger-color;
|
||||
}
|
||||
|
||||
&--info {
|
||||
background: @bg-color;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
86
src/components/StepForm.vue
Normal file
86
src/components/StepForm.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
titles: string[]
|
||||
current: number
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="step-form">
|
||||
<div class="step-form__header">
|
||||
<template v-for="(title, index) in titles" :key="title">
|
||||
<div
|
||||
class="step-form__step"
|
||||
:class="{ 'is-active': index === current, 'is-done': index < current }"
|
||||
>
|
||||
<span class="step-form__dot">{{ index + 1 }}</span>
|
||||
<span class="step-form__label">{{ title }}</span>
|
||||
</div>
|
||||
<div v-if="index < titles.length - 1" class="step-form__line" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="step-form__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.step-form {
|
||||
padding: @spacing-lg @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__header {
|
||||
.flex(row, center);
|
||||
gap: @spacing-sm;
|
||||
margin-bottom: @spacing-lg;
|
||||
padding-bottom: @spacing-base;
|
||||
border-bottom: 1px solid @border-color;
|
||||
}
|
||||
|
||||
&__step {
|
||||
.flex(row, center);
|
||||
gap: @spacing-xs;
|
||||
|
||||
&.is-active .step-form__dot {
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&.is-done .step-form__dot {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-active .step-form__label {
|
||||
color: @primary-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
.flex(row, center);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-round;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
&__line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: @border-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
100
src/components/UploadArea.vue
Normal file
100
src/components/UploadArea.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
hint?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
hint: '支持图片/文档/压缩包,大小不超过1G',
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ upload: [file: File] }>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
|
||||
const MAX_SIZE = 1024 * 1024 * 1024
|
||||
|
||||
function pick(): void {
|
||||
inputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFiles(list: FileList | null): void {
|
||||
if (!list) return
|
||||
for (const file of Array.from(list)) {
|
||||
if (file.size > MAX_SIZE) {
|
||||
ElMessage.warning(`「${file.name}」超过1G,未添加`)
|
||||
continue
|
||||
}
|
||||
emit('upload', file)
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): void {
|
||||
dragging.value = false
|
||||
handleFiles(event.dataTransfer?.files ?? null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="upload-area"
|
||||
:class="{ 'is-dragging': dragging, 'is-disabled': disabled }"
|
||||
@click="pick"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<span class="upload-area__icon">⬆</span>
|
||||
<span class="upload-area__hint text-secondary">{{ hint }}</span>
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
multiple
|
||||
class="upload-area__input"
|
||||
:disabled="disabled"
|
||||
@change="handleFiles(($event.target as HTMLInputElement).files)"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.upload-area {
|
||||
.flex(column, center);
|
||||
gap: @spacing-xs;
|
||||
padding: @spacing-lg;
|
||||
border: 1px dashed @border-color-dark;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.is-dragging {
|
||||
border-color: @primary-color;
|
||||
background: @primary-light;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: @font-size-xl;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
src/components/index.ts
Normal file
15
src/components/index.ts
Normal file
@@ -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'
|
||||
32
src/composables/useCountDown.ts
Normal file
32
src/composables/useCountDown.ts
Normal file
@@ -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<typeof setInterval> | 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 }
|
||||
}
|
||||
29
src/composables/useIntersection.ts
Normal file
29
src/composables/useIntersection.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { onMounted, onUnmounted, ref, type Ref } from 'vue'
|
||||
|
||||
/** 元素可见性(配合图片懒加载/曝光统计) */
|
||||
export function useIntersection(
|
||||
target: Ref<Element | null | undefined>,
|
||||
options?: IntersectionObserverInit,
|
||||
): {
|
||||
isVisible: Ref<boolean>
|
||||
} {
|
||||
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 }
|
||||
}
|
||||
21
src/composables/useLoading.ts
Normal file
21
src/composables/useLoading.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
/** 请求 loading 状态管理 */
|
||||
export function useLoading(initial = false): {
|
||||
loading: Ref<boolean>
|
||||
withLoading: <T>(fn: () => Promise<T>) => Promise<T>
|
||||
} {
|
||||
const loading = ref(initial)
|
||||
|
||||
async function withLoading<T>(fn: () => Promise<T>): Promise<T> {
|
||||
loading.value = true
|
||||
try {
|
||||
return await fn()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, withLoading }
|
||||
}
|
||||
26
src/composables/useScroll.ts
Normal file
26
src/composables/useScroll.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { computed, onMounted, onUnmounted, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
/** 滚动监听/吸顶/回到顶部 */
|
||||
export function useScroll(threshold = 200): {
|
||||
scrollY: Ref<number>
|
||||
isAtTop: ComputedRef<boolean>
|
||||
showBackTop: ComputedRef<boolean>
|
||||
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 }
|
||||
}
|
||||
35
src/directives/debounce.ts
Normal file
35
src/directives/debounce.ts
Normal file
@@ -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<Element, EventListener>()
|
||||
|
||||
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<Element, DebounceValue> = {
|
||||
mounted(el, binding) {
|
||||
const { handler, delay = 300 } = normalize(binding.value)
|
||||
let timer: ReturnType<typeof setTimeout> | 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
|
||||
12
src/directives/index.ts
Normal file
12
src/directives/index.ts
Normal file
@@ -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)
|
||||
}
|
||||
27
src/directives/lazy.ts
Normal file
27
src/directives/lazy.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
const observerMap = new WeakMap<Element, IntersectionObserver>()
|
||||
|
||||
/** v-lazy 图片懒加载:v-lazy="图片地址" */
|
||||
const lazy: Directive<Element, string> = {
|
||||
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
|
||||
38
src/directives/throttle.ts
Normal file
38
src/directives/throttle.ts
Normal file
@@ -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<Element, EventListener>()
|
||||
|
||||
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<Element, ThrottleValue> = {
|
||||
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
|
||||
10
src/env.d.ts
vendored
Normal file
10
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** API 基础地址 */
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
58
src/layout/components/FloatingActions.vue
Normal file
58
src/layout/components/FloatingActions.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
// 全局浮动入口:快捷提报 / 在线客服(PRD 1.2)
|
||||
const entries = [
|
||||
{ label: '快捷提报', icon: '📝', path: '/demand' },
|
||||
{ label: '在线客服', icon: '💬', path: '/service' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="floating-actions">
|
||||
<RouterLink
|
||||
v-for="entry in entries"
|
||||
:key="entry.path"
|
||||
:to="entry.path"
|
||||
class="floating-actions__item"
|
||||
:title="entry.label"
|
||||
>
|
||||
<span class="floating-actions__icon">{{ entry.icon }}</span>
|
||||
<span class="floating-actions__label">{{ entry.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.floating-actions {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 96px;
|
||||
z-index: 90;
|
||||
.flex(column, center);
|
||||
gap: @spacing-sm;
|
||||
|
||||
&__item {
|
||||
.flex(column, center);
|
||||
gap: 2px;
|
||||
width: 56px;
|
||||
padding: @spacing-sm 0;
|
||||
border-radius: @radius-lg;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
box-shadow: @shadow-lg;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
23
src/layout/index.vue
Normal file
23
src/layout/index.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { AppFooter, AppHeader } from '@/components'
|
||||
import FloatingActions from './components/FloatingActions.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<AppHeader />
|
||||
<main class="layout__main">
|
||||
<router-view />
|
||||
</main>
|
||||
<AppFooter />
|
||||
<FloatingActions />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.layout {
|
||||
.layout__main {
|
||||
min-height: calc(100vh - @header-height - @footer-height);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
13
src/main.ts
13
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')
|
||||
|
||||
16
src/router/guard.ts
Normal file
16
src/router/guard.ts
Normal file
@@ -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<string>('token')) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} - 虫子智造` : '虫子智造'
|
||||
})
|
||||
}
|
||||
13
src/router/index.ts
Normal file
13
src/router/index.ts
Normal file
@@ -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
|
||||
65
src/router/routes.ts
Normal file
65
src/router/routes.ts
Normal file
@@ -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]
|
||||
5
src/stores/index.ts
Normal file
5
src/stores/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
84
src/stores/modules/cases.ts
Normal file
84
src/stores/modules/cases.ts
Normal file
@@ -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<CaseQuery>(createDefaultQuery())
|
||||
const list = ref<Case[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<Partial<Record<BusinessType, number>>>({})
|
||||
const currentCase = ref<Case | null>(null)
|
||||
|
||||
async function fetchCases(): Promise<void> {
|
||||
const result: PageResult<Case> = await getCaseList(query.value)
|
||||
list.value = result.list
|
||||
total.value = result.total
|
||||
}
|
||||
|
||||
async function fetchCounts(): Promise<void> {
|
||||
counts.value = await getCaseCounts()
|
||||
}
|
||||
|
||||
function setQuery(next: CaseQuery): void {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchCases()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchCases()
|
||||
}
|
||||
|
||||
async function changePage(page: number): Promise<void> {
|
||||
query.value = { ...query.value, page }
|
||||
await fetchCases()
|
||||
}
|
||||
|
||||
async function fetchCaseDetail(id: string): Promise<void> {
|
||||
currentCase.value = await getCaseDetail(id)
|
||||
}
|
||||
|
||||
async function toggleFavorite(id: string): Promise<void> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
79
src/stores/modules/contract.ts
Normal file
79
src/stores/modules/contract.ts
Normal file
@@ -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<ContractQuery>(createDefaultQuery())
|
||||
const list = ref<Contract[]>([])
|
||||
const total = ref(0)
|
||||
const current = ref<Contract | null>(null)
|
||||
const signExpires = ref(272)
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
const result: PageResult<Contract> = await getContractList(query.value)
|
||||
list.value = result.list
|
||||
total.value = result.total
|
||||
}
|
||||
|
||||
async function fetchDetail(id: string): Promise<void> {
|
||||
current.value = await getContractDetail(id)
|
||||
}
|
||||
|
||||
function setQuery(next: ContractQuery): void {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
async function changePage(page: number): Promise<void> {
|
||||
query.value = { ...query.value, page }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 获取/刷新签署二维码(refresh=true 时走刷新接口) */
|
||||
async function fetchSignQr(id: string, refresh = false): Promise<void> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
126
src/stores/modules/demand.ts
Normal file
126
src/stores/modules/demand.ts
Normal file
@@ -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<Demand>(createDefaultForm())
|
||||
const step = ref(1)
|
||||
const loading = ref(false)
|
||||
|
||||
function setStep(next: number): void {
|
||||
step.value = next
|
||||
}
|
||||
|
||||
function patchForm(partial: Partial<Demand>): 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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
207
src/stores/modules/finance.ts
Normal file
207
src/stores/modules/finance.ts
Normal file
@@ -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<FinanceStats | null>(null)
|
||||
|
||||
// —— 账单列表
|
||||
const list = ref<Bill[]>([])
|
||||
const total = ref(0)
|
||||
const query = ref<BillQuery>(createDefaultBillQuery())
|
||||
const currentBill = ref<Bill | null>(null)
|
||||
|
||||
// —— 支付
|
||||
const payment = ref<Payment | null>(null)
|
||||
const result = ref<PaymentResult | null>(null)
|
||||
|
||||
// —— 发票
|
||||
const invoices = ref<Invoice[]>([])
|
||||
const invoiceTotal = ref(0)
|
||||
const invoiceQuery = ref<InvoiceQuery>(createDefaultInvoiceQuery())
|
||||
|
||||
// —— 发票抬头
|
||||
const titles = ref<InvoiceTitle[]>([])
|
||||
|
||||
// —— 统计
|
||||
async function fetchStats(period?: string): Promise<void> {
|
||||
stats.value = await getFinanceStats(period)
|
||||
}
|
||||
|
||||
// —— 账单
|
||||
async function fetchList(): Promise<void> {
|
||||
const pageData: PageResult<Bill> = await getBillList(query.value)
|
||||
list.value = pageData.list
|
||||
total.value = pageData.total
|
||||
}
|
||||
|
||||
function setQuery(next: BillQuery): void {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultBillQuery()
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
async function changePage(page: number): Promise<void> {
|
||||
query.value = { ...query.value, page }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
async function fetchDetail(id: string): Promise<void> {
|
||||
currentBill.value = await getBillDetail(id)
|
||||
}
|
||||
|
||||
// —— 支付
|
||||
async function createPayment(billId: string, method: PayMethod): Promise<void> {
|
||||
payment.value = await createPaymentApi(billId, method)
|
||||
}
|
||||
|
||||
/** 切换支付方式(刷新二维码) */
|
||||
async function switchPaymentMethod(billId: string, method: PayMethod): Promise<void> {
|
||||
payment.value = await switchPaymentMethodApi(billId, method)
|
||||
}
|
||||
|
||||
async function fetchResult(billId: string): Promise<void> {
|
||||
result.value = await getPayResult(billId)
|
||||
}
|
||||
|
||||
// —— 发票
|
||||
async function fetchInvoices(): Promise<void> {
|
||||
const pageData: PageResult<Invoice> = await getInvoiceList(invoiceQuery.value)
|
||||
invoices.value = pageData.list
|
||||
invoiceTotal.value = pageData.total
|
||||
}
|
||||
|
||||
function setInvoiceQuery(next: InvoiceQuery): void {
|
||||
invoiceQuery.value = next
|
||||
}
|
||||
|
||||
/** 发票确定筛选(重置页码后取数) */
|
||||
async function searchInvoices(): Promise<void> {
|
||||
invoiceQuery.value = { ...invoiceQuery.value, page: 1 }
|
||||
await fetchInvoices()
|
||||
}
|
||||
|
||||
async function changeInvoicePage(page: number): Promise<void> {
|
||||
invoiceQuery.value = { ...invoiceQuery.value, page }
|
||||
await fetchInvoices()
|
||||
}
|
||||
|
||||
/** 申请开票(状态 → 开票中) */
|
||||
async function applyInvoice(id: string): Promise<void> {
|
||||
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<void> {
|
||||
titles.value = await getInvoiceTitles()
|
||||
}
|
||||
|
||||
async function createTitle(payload: InvoiceTitlePayload): Promise<void> {
|
||||
await createInvoiceTitle(payload)
|
||||
await fetchTitles()
|
||||
}
|
||||
|
||||
async function updateTitle(id: string, payload: InvoiceTitlePayload): Promise<void> {
|
||||
await updateInvoiceTitle(id, payload)
|
||||
await fetchTitles()
|
||||
}
|
||||
|
||||
async function deleteTitle(id: string): Promise<void> {
|
||||
await deleteInvoiceTitle(id)
|
||||
await fetchTitles()
|
||||
}
|
||||
|
||||
async function setDefaultTitle(id: string): Promise<void> {
|
||||
titles.value = await setDefaultTitleApi(id)
|
||||
}
|
||||
|
||||
// —— 报表导出
|
||||
async function exportReport(): Promise<void> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
46
src/stores/modules/home.ts
Normal file
46
src/stores/modules/home.ts
Normal file
@@ -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<HomeStats>({ pending: 0, inProgress: 0, total: 0 })
|
||||
const businessLines = ref<BusinessLine[]>([])
|
||||
const featuredCases = ref<Case[]>([])
|
||||
const currentBusiness = ref<BusinessLine | null>(null)
|
||||
|
||||
/** 首页初始化:看板 + 业务线 + 案例精选 */
|
||||
async function fetchHomeData(): Promise<void> {
|
||||
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<void> {
|
||||
featuredCases.value = await getFeaturedCases(category)
|
||||
}
|
||||
|
||||
/** 业务详情 */
|
||||
async function fetchBusinessDetail(type: BusinessType): Promise<void> {
|
||||
currentBusiness.value = await getBusinessDetail(type)
|
||||
}
|
||||
|
||||
return {
|
||||
stats,
|
||||
businessLines,
|
||||
featuredCases,
|
||||
currentBusiness,
|
||||
fetchHomeData,
|
||||
fetchFeaturedCases,
|
||||
fetchBusinessDetail,
|
||||
}
|
||||
})
|
||||
112
src/stores/modules/message.ts
Normal file
112
src/stores/modules/message.ts
Normal file
@@ -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<MessageQuery>(createDefaultQuery())
|
||||
const list = ref<Message[]>([])
|
||||
const total = ref(0)
|
||||
const current = ref<Message | null>(null)
|
||||
const unreadCount = ref(0)
|
||||
const tabCounts = ref<MessageTabCounts>({ all: 0, 在线客服: 0, 状态通知: 0, 系统通知: 0 })
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
const result: PageResult<Message> = await getMessageList(query.value)
|
||||
list.value = result.list
|
||||
total.value = result.total
|
||||
}
|
||||
|
||||
async function fetchDetail(id: string): Promise<void> {
|
||||
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<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
async function changePage(page: number): Promise<void> {
|
||||
query.value = { ...query.value, page }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 全部标记已读 */
|
||||
async function markAllRead(): Promise<void> {
|
||||
await markAllReadApi()
|
||||
ElMessage.success('已全部标记为已读')
|
||||
await Promise.all([fetchList(), fetchUnreadCount(), fetchTabCounts()])
|
||||
if (current.value) current.value.isRead = true
|
||||
}
|
||||
|
||||
async function fetchUnreadCount(): Promise<void> {
|
||||
unreadCount.value = await getUnreadCount()
|
||||
}
|
||||
|
||||
async function fetchTabCounts(): Promise<void> {
|
||||
tabCounts.value = await getTabCounts()
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
list,
|
||||
total,
|
||||
current,
|
||||
unreadCount,
|
||||
tabCounts,
|
||||
fetchList,
|
||||
fetchDetail,
|
||||
setQuery,
|
||||
search,
|
||||
resetQuery,
|
||||
changePage,
|
||||
markAllRead,
|
||||
fetchUnreadCount,
|
||||
fetchTabCounts,
|
||||
}
|
||||
})
|
||||
258
src/stores/modules/profile.ts
Normal file
258
src/stores/modules/profile.ts
Normal file
@@ -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<Profile | null>(null)
|
||||
|
||||
async function fetchProfile(): Promise<void> {
|
||||
profile.value = await getProfile()
|
||||
}
|
||||
|
||||
async function saveNickname(name: string): Promise<void> {
|
||||
profile.value = await updateNickname(name)
|
||||
ElMessage.success('昵称修改成功')
|
||||
}
|
||||
|
||||
// ── 账号安全 ──
|
||||
async function changePasswordAction(params: ChangePasswordParams): Promise<void> {
|
||||
await changePassword(params)
|
||||
profile.value = await getProfile()
|
||||
ElMessage.success('密码修改成功')
|
||||
}
|
||||
|
||||
async function bindPhoneAction(phone: string, code: string): Promise<void> {
|
||||
profile.value = await bindPhone(phone, code)
|
||||
ElMessage.success('手机号绑定成功')
|
||||
}
|
||||
|
||||
async function bindEmailAction(email: string, code: string): Promise<void> {
|
||||
profile.value = await bindEmail(email, code)
|
||||
ElMessage.success('邮箱绑定成功')
|
||||
}
|
||||
|
||||
async function unbindEmailAction(): Promise<void> {
|
||||
profile.value = await unbindEmail()
|
||||
ElMessage.success('已取消邮箱绑定')
|
||||
}
|
||||
|
||||
async function verifyIdentityAction(phone: string, code: string): Promise<void> {
|
||||
await verifyIdentity(phone, code)
|
||||
}
|
||||
|
||||
async function deleteAccountAction(): Promise<void> {
|
||||
await deleteAccountApi()
|
||||
storage.remove('token')
|
||||
ElMessage.success('注销成功')
|
||||
}
|
||||
|
||||
// ── 收货地址 ──
|
||||
const addresses = ref<Address[]>([])
|
||||
const addressTotal = ref(0)
|
||||
const addressQuery = ref<ListQuery>(createDefaultAddressQuery())
|
||||
|
||||
async function fetchAddresses(): Promise<void> {
|
||||
const result: PageResult<Address> = await getAddresses(addressQuery.value)
|
||||
addresses.value = result.list
|
||||
addressTotal.value = result.total
|
||||
}
|
||||
|
||||
async function createAddressAction(input: Omit<Address, 'id' | 'isDefault'>): Promise<void> {
|
||||
await createAddress(input)
|
||||
ElMessage.success('新增地址成功')
|
||||
addressQuery.value = { ...addressQuery.value, page: 1 }
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
async function updateAddressAction(id: string, input: Omit<Address, 'id' | 'isDefault'>): Promise<void> {
|
||||
await updateAddress(id, input)
|
||||
ElMessage.success('地址保存成功')
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
async function deleteAddressAction(id: string): Promise<void> {
|
||||
await deleteAddress(id)
|
||||
ElMessage.success('地址删除成功')
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
async function setDefaultAddressAction(id: string): Promise<void> {
|
||||
await setDefaultAddress(id)
|
||||
ElMessage.success('已设为默认地址')
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
async function changeAddressPage(page: number): Promise<void> {
|
||||
addressQuery.value = { ...addressQuery.value, page }
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
// ── 我的收藏 ──
|
||||
const favorites = ref<Case[]>([])
|
||||
const favoritesTotal = ref(0)
|
||||
const favoritesCounts = ref<Partial<Record<BusinessType, number>>>({})
|
||||
const favoritesQuery = ref<FavoritesQuery>(createDefaultFavoritesQuery())
|
||||
|
||||
async function fetchFavorites(): Promise<void> {
|
||||
const result: PageResult<Case> = await getFavorites(favoritesQuery.value)
|
||||
favorites.value = result.list
|
||||
favoritesTotal.value = result.total
|
||||
}
|
||||
|
||||
async function fetchFavoritesCounts(): Promise<void> {
|
||||
favoritesCounts.value = await getCaseCounts()
|
||||
}
|
||||
|
||||
async function searchFavorites(patch: Partial<FavoritesQuery>): Promise<void> {
|
||||
favoritesQuery.value = { ...favoritesQuery.value, ...patch, page: 1 }
|
||||
await fetchFavorites()
|
||||
}
|
||||
|
||||
async function resetFavorites(): Promise<void> {
|
||||
favoritesQuery.value = createDefaultFavoritesQuery()
|
||||
await fetchFavorites()
|
||||
}
|
||||
|
||||
async function changeFavoritesPage(page: number): Promise<void> {
|
||||
favoritesQuery.value = { ...favoritesQuery.value, page }
|
||||
await fetchFavorites()
|
||||
}
|
||||
|
||||
// ── 资质管理 ──
|
||||
const qualifications = ref<Qualification[]>([])
|
||||
const qualificationTotal = ref(0)
|
||||
const qualificationQuery = ref<ListQuery>(createDefaultQualificationQuery())
|
||||
|
||||
async function fetchQualifications(): Promise<void> {
|
||||
const result: PageResult<Qualification> = await getQualifications(qualificationQuery.value)
|
||||
qualifications.value = result.list
|
||||
qualificationTotal.value = result.total
|
||||
}
|
||||
|
||||
async function createQualificationAction(input: QualificationInput): Promise<void> {
|
||||
await createQualification(input)
|
||||
ElMessage.success('资质提交成功')
|
||||
qualificationQuery.value = { ...qualificationQuery.value, page: 1 }
|
||||
await fetchQualifications()
|
||||
}
|
||||
|
||||
async function updateQualificationAction(id: string, input: QualificationInput): Promise<void> {
|
||||
await updateQualification(id, input)
|
||||
ElMessage.success('资质保存成功')
|
||||
await fetchQualifications()
|
||||
}
|
||||
|
||||
async function deleteQualificationAction(id: string): Promise<void> {
|
||||
await deleteQualification(id)
|
||||
ElMessage.success('资质删除成功')
|
||||
await fetchQualifications()
|
||||
}
|
||||
|
||||
async function changeQualificationPage(page: number): Promise<void> {
|
||||
qualificationQuery.value = { ...qualificationQuery.value, page }
|
||||
await fetchQualifications()
|
||||
}
|
||||
|
||||
// ── 意见反馈 / 帮助中心 ──
|
||||
async function submitFeedbackAction(feedback: Feedback): Promise<void> {
|
||||
await submitFeedbackApi(feedback)
|
||||
}
|
||||
|
||||
const helpFaqs = ref<HelpFaqGroup[]>([])
|
||||
|
||||
async function fetchHelpFaqs(): Promise<void> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
106
src/stores/modules/project.ts
Normal file
106
src/stores/modules/project.ts
Normal file
@@ -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<ProjectQuery>(createDefaultQuery())
|
||||
const list = ref<Project[]>([])
|
||||
const total = ref(0)
|
||||
const current = ref<Project | null>(null)
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
const result: PageResult<Project> = await getProjectList(query.value)
|
||||
list.value = result.list
|
||||
total.value = result.total
|
||||
}
|
||||
|
||||
async function fetchDetail(id: string): Promise<void> {
|
||||
current.value = await getProjectDetail(id)
|
||||
}
|
||||
|
||||
function setQuery(next: ProjectQuery): void {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
async function changePage(page: number): Promise<void> {
|
||||
query.value = { ...query.value, page }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 上传项目文件(追加到当前详情) */
|
||||
async function uploadFile(file: File): Promise<void> {
|
||||
const info = await uploadProjectFile(file)
|
||||
current.value?.files.push(info)
|
||||
}
|
||||
|
||||
/** 删除当前详情中的项目文件 */
|
||||
async function deleteFile(index: number): Promise<void> {
|
||||
await deleteProjectFile(index)
|
||||
current.value?.files.splice(index, 1)
|
||||
}
|
||||
|
||||
/** 提交售后工单 */
|
||||
async function submitAfterSales(payload: AfterSalesPayload): Promise<void> {
|
||||
if (!current.value) return
|
||||
current.value.afterSales = await submitAfterSalesApi(current.value.id, payload)
|
||||
ElMessage.success('已提交售后工单')
|
||||
}
|
||||
|
||||
/** 取消售后工单 */
|
||||
async function cancelAfterSales(): Promise<void> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
60
src/stores/modules/service.ts
Normal file
60
src/stores/modules/service.ts
Normal file
@@ -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<ChatMessage[]>([])
|
||||
const sending = ref(false)
|
||||
|
||||
async function fetchHistory(): Promise<void> {
|
||||
messages.value = await getChatHistory()
|
||||
}
|
||||
|
||||
/** 发送文本:先推入用户消息,600ms 后推入客服自动回复 */
|
||||
async function send(content: string): Promise<void> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
24
src/stores/modules/site.ts
Normal file
24
src/stores/modules/site.ts
Normal file
@@ -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 }
|
||||
})
|
||||
137
src/stores/modules/user.ts
Normal file
137
src/stores/modules/user.ts
Normal file
@@ -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<string | null>(storage.get<string>('token'))
|
||||
const user = ref<User | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const isLoggedIn = computed(() => Boolean(token.value))
|
||||
|
||||
/** 发送短信验证码 */
|
||||
async function sendSmsCode(phone: string): Promise<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
25
src/styles/common.less
Normal file
25
src/styles/common.less
Normal file
@@ -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;
|
||||
}
|
||||
3
src/styles/index.less
Normal file
3
src/styles/index.less
Normal file
@@ -0,0 +1,3 @@
|
||||
// 样式入口(main.ts 引入;variables/mixins 由 vite additionalData 自动注入)
|
||||
@import './reset.less';
|
||||
@import './common.less';
|
||||
37
src/styles/mixins.less
Normal file
37
src/styles/mixins.less
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
64
src/styles/reset.less
Normal file
64
src/styles/reset.less
Normal file
@@ -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;
|
||||
}
|
||||
56
src/styles/variables.less
Normal file
56
src/styles/variables.less
Normal file
@@ -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);
|
||||
31
src/types/api.d.ts
vendored
Normal file
31
src/types/api.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
// 接口通用结构(PRD 4.8)
|
||||
|
||||
/** 业务类型(跨模块共享) */
|
||||
export type BusinessType = 'internet' | 'hardware' | 'iot' | 'manufacturing' | 'other'
|
||||
|
||||
/** 统一响应结构 */
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
/** 分页结果 */
|
||||
export interface PageResult<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 通用分页查询参数 */
|
||||
export interface PageQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 附件/文件信息 */
|
||||
export interface FileInfo {
|
||||
name: string
|
||||
size: number
|
||||
uploadTime: string
|
||||
url: string
|
||||
}
|
||||
38
src/types/business/cases.d.ts
vendored
Normal file
38
src/types/business/cases.d.ts
vendored
Normal file
@@ -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
|
||||
}
|
||||
29
src/types/business/contract.d.ts
vendored
Normal file
29
src/types/business/contract.d.ts
vendored
Normal file
@@ -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
|
||||
}
|
||||
20
src/types/business/demand.d.ts
vendored
Normal file
20
src/types/business/demand.d.ts
vendored
Normal file
@@ -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'
|
||||
}
|
||||
92
src/types/business/finance.d.ts
vendored
Normal file
92
src/types/business/finance.d.ts
vendored
Normal file
@@ -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 }>
|
||||
}
|
||||
34
src/types/business/home.d.ts
vendored
Normal file
34
src/types/business/home.d.ts
vendored
Normal file
@@ -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
|
||||
}
|
||||
27
src/types/business/message.d.ts
vendored
Normal file
27
src/types/business/message.d.ts
vendored
Normal file
@@ -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
|
||||
}
|
||||
87
src/types/business/profile.d.ts
vendored
Normal file
87
src/types/business/profile.d.ts
vendored
Normal file
@@ -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<Qualification, 'id' | 'status'> & { 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[]
|
||||
}
|
||||
79
src/types/business/project.d.ts
vendored
Normal file
79
src/types/business/project.d.ts
vendored
Normal file
@@ -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
|
||||
}
|
||||
17
src/types/business/service.d.ts
vendored
Normal file
17
src/types/business/service.d.ts
vendored
Normal file
@@ -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
|
||||
}
|
||||
47
src/types/business/user.d.ts
vendored
Normal file
47
src/types/business/user.d.ts
vendored
Normal file
@@ -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
|
||||
}
|
||||
8
src/types/global.d.ts
vendored
Normal file
8
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// 全局声明(Window 扩展等,按需添加)
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// 全局挂载扩展位
|
||||
}
|
||||
}
|
||||
11
src/types/router.d.ts
vendored
Normal file
11
src/types/router.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
// RouteMeta 扩展
|
||||
export {}
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/** 页面标题(document.title) */
|
||||
title?: string
|
||||
/** 是否需要登录 */
|
||||
requiresAuth?: boolean
|
||||
}
|
||||
}
|
||||
67
src/utils/format.ts
Normal file
67
src/utils/format.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// 格式化工具(无 Vue 依赖)
|
||||
|
||||
import type { BusinessType } from '@/types/api'
|
||||
import type { ScaleRange } from '@/types/business/cases'
|
||||
|
||||
const BUSINESS_TYPE_LABELS: Record<BusinessType, string> = {
|
||||
internet: '互联网软件',
|
||||
hardware: '硬件电路',
|
||||
iot: '物联网全案',
|
||||
manufacturing: '生产制造',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
const SCALE_LABELS: Record<ScaleRange, string> = {
|
||||
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<string, string> = {
|
||||
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`
|
||||
}
|
||||
42
src/utils/request.ts
Normal file
42
src/utils/request.ts
Normal file
@@ -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<string>('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<T>,失败统一提示并 reject
|
||||
* 页面/组件禁止直接 import axios,一律经 store 调用本函数
|
||||
*/
|
||||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
const resp = await service.request<ApiResponse<T>>(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
|
||||
34
src/utils/storage.ts
Normal file
34
src/utils/storage.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// localStorage/sessionStorage 泛型封装(无 Vue 依赖)
|
||||
|
||||
const PREFIX = 'cz:'
|
||||
|
||||
function parse<T>(raw: string | null): T | null {
|
||||
if (raw === null) return null
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const storage = {
|
||||
get<T>(key: string): T | null {
|
||||
return parse<T>(localStorage.getItem(PREFIX + key))
|
||||
},
|
||||
set<T>(key: string, value: T): void {
|
||||
localStorage.setItem(PREFIX + key, JSON.stringify(value))
|
||||
},
|
||||
remove(key: string): void {
|
||||
localStorage.removeItem(PREFIX + key)
|
||||
},
|
||||
sessionGet<T>(key: string): T | null {
|
||||
return parse<T>(sessionStorage.getItem(PREFIX + key))
|
||||
},
|
||||
sessionSet<T>(key: string, value: T): void {
|
||||
sessionStorage.setItem(PREFIX + key, JSON.stringify(value))
|
||||
},
|
||||
sessionRemove(key: string): void {
|
||||
sessionStorage.removeItem(PREFIX + key)
|
||||
},
|
||||
}
|
||||
36
src/utils/validate.ts
Normal file
36
src/utils/validate.ts
Normal file
@@ -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
|
||||
}
|
||||
35
src/views/404.vue
Normal file
35
src/views/404.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
function goHome(): void {
|
||||
window.location.href = '/'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="not-found">
|
||||
<h1>404</h1>
|
||||
<p class="text-secondary">
|
||||
页面不存在或已下线
|
||||
</p>
|
||||
<button class="not-found__back link" @click="goHome">
|
||||
返回首页
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.not-found {
|
||||
.flex(column, center);
|
||||
gap: @spacing-base;
|
||||
min-height: 100vh;
|
||||
|
||||
h1 {
|
||||
font-size: 64px;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__back {
|
||||
color: @primary-color;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
164
src/views/cases/cpns/CaseDetailHeader.vue
Normal file
164
src/views/cases/cpns/CaseDetailHeader.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { formatBusinessType } from '@/utils/format'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
item: Case
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
favorite: [id: string]
|
||||
share: [id: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="case-header">
|
||||
<div class="case-header__main">
|
||||
<div class="case-header__head">
|
||||
<h1 class="case-header__title">
|
||||
{{ item.title }}
|
||||
</h1>
|
||||
<span class="case-header__tag">{{ formatBusinessType(item.category) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="case-header__actions">
|
||||
<button
|
||||
class="case-header__btn"
|
||||
:class="{ 'is-favorite': item.isFavorite }"
|
||||
@click="emit('favorite', item.id)"
|
||||
>
|
||||
{{ item.isFavorite ? '★ 已收藏' : '☆ 收藏' }}
|
||||
</button>
|
||||
<button class="case-header__btn" @click="emit('share', item.id)">
|
||||
分享
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="case-header__meta">
|
||||
<div class="case-header__meta-item">
|
||||
<span class="text-secondary">完成时间</span>
|
||||
<span>{{ item.finishDate }}</span>
|
||||
</div>
|
||||
<div class="case-header__meta-item">
|
||||
<span class="text-secondary">服务团队</span>
|
||||
<span>{{ item.teamSize }}人</span>
|
||||
</div>
|
||||
<div class="case-header__meta-item">
|
||||
<span class="text-secondary">规模</span>
|
||||
<span>{{ item.scale }}</span>
|
||||
</div>
|
||||
<div class="case-header__meta-item">
|
||||
<span class="text-secondary">所属行业</span>
|
||||
<span>{{ item.industry }}</span>
|
||||
</div>
|
||||
<div class="case-header__meta-item">
|
||||
<span class="text-secondary">项目周期</span>
|
||||
<span>{{ item.duration }}</span>
|
||||
</div>
|
||||
<div class="case-header__meta-item">
|
||||
<span class="text-secondary">客户类型</span>
|
||||
<span>{{ item.clientType }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="case-header__video">
|
||||
<!-- TODO(蓝湖): 接入真实视频源 -->
|
||||
<span>视频将在预览时播放</span>
|
||||
<span class="case-header__video-time">0:00 / 0:00</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-header {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-lg;
|
||||
padding: @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__head {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
margin-bottom: @spacing-base;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
padding: 2px @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-light;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
margin-bottom: @spacing-base;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 32px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: @font-size-sm;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-favorite {
|
||||
border-color: @warning-color;
|
||||
color: @warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
.flex(column, flex-start);
|
||||
gap: 4px;
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__video {
|
||||
.flex(column, center);
|
||||
gap: @spacing-sm;
|
||||
width: 320px;
|
||||
height: 200px;
|
||||
border-radius: @radius-base;
|
||||
background: @heading-color;
|
||||
color: @text-color-secondary;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__video-time {
|
||||
color: @text-color-disabled;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
src/views/cases/cpns/CaseRichText.vue
Normal file
57
src/views/cases/cpns/CaseRichText.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type { Case } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
item: Case
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const sections = [
|
||||
{ key: 'background', title: '项目背景', field: 'background' },
|
||||
{ key: 'painPoints', title: '客户痛点', field: 'painPoints' },
|
||||
{ key: 'requirements', title: '需求描述', field: 'requirements' },
|
||||
{ key: 'deliverables', title: '交付物', field: 'deliverables' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="case-rich">
|
||||
<section v-for="section in sections" :key="section.key" class="case-rich__section">
|
||||
<h3 class="case-rich__title">
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
<p class="case-rich__text">{{ item[section.field] }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-rich {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
|
||||
&__section {
|
||||
padding: @spacing-lg @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: @spacing-sm;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
padding-left: @spacing-sm;
|
||||
border-left: 3px solid @primary-color;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: @font-size-base;
|
||||
line-height: 1.8;
|
||||
color: @text-color;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
324
src/views/cases/cpns/CasesFilter.vue
Normal file
324
src/views/cases/cpns/CasesFilter.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElDatePicker } from 'element-plus'
|
||||
import { FilterPanel, type FilterChip } from '@/components'
|
||||
import { formatBusinessType, formatDate, formatScale } from '@/utils/format'
|
||||
import type { BusinessType } from '@/types/api'
|
||||
import type { CaseQuery, ScaleRange } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
query: CaseQuery
|
||||
counts: Partial<Record<BusinessType, number>>
|
||||
total: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:query': [query: CaseQuery]
|
||||
search: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom'
|
||||
|
||||
const activeShortcut = ref<TimeShortcut>('all')
|
||||
|
||||
watch(
|
||||
() => props.query.dateRange,
|
||||
(value) => {
|
||||
if (!value) activeShortcut.value = 'all'
|
||||
},
|
||||
)
|
||||
|
||||
const categoryOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '互联网软件', value: 'internet' },
|
||||
{ label: '硬件电路', value: 'hardware' },
|
||||
{ label: '物联网全案', value: 'iot' },
|
||||
{ label: '生产制造', value: 'manufacturing' },
|
||||
]
|
||||
|
||||
const industryOptions = [
|
||||
'电商零售', '教育培训', '医疗健康', '硬件电路', '能源环保',
|
||||
'房地产', '自动化', '工厂服务', '智能设备', '园区服务',
|
||||
]
|
||||
|
||||
const scaleOptions: Array<{ label: string; value: ScaleRange }> = [
|
||||
{ label: '50万以下', value: 'lt50w' },
|
||||
{ label: '50-100万', value: '50to100w' },
|
||||
{ label: '100-300万', value: '100to300w' },
|
||||
{ label: '300-500万', value: '300to500w' },
|
||||
{ label: '500万以上', value: 'gt500w' },
|
||||
]
|
||||
|
||||
const timeShortcuts: Array<{ label: string; value: TimeShortcut }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '近3个月', value: '3m' },
|
||||
{ label: '近半年', value: '6m' },
|
||||
{ label: '近一年', value: '1y' },
|
||||
]
|
||||
|
||||
const keyword = computed({
|
||||
get: () => props.query.keyword,
|
||||
set: (value: string) => emit('update:query', { ...props.query, keyword: value }),
|
||||
})
|
||||
|
||||
const chips = computed<FilterChip[]>(() => {
|
||||
const list: FilterChip[] = []
|
||||
if (props.query.category !== 'all') {
|
||||
list.push({ key: 'category', label: formatBusinessType(props.query.category) })
|
||||
}
|
||||
for (const industry of props.query.industries) {
|
||||
list.push({ key: `industry:${industry}`, label: industry })
|
||||
}
|
||||
for (const scale of props.query.scales) {
|
||||
list.push({ key: `scale:${scale}`, label: formatScale(scale) })
|
||||
}
|
||||
if (props.query.dateRange) {
|
||||
const shortcutLabel = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
|
||||
list.push({
|
||||
key: 'time',
|
||||
label: shortcutLabel && shortcutLabel !== '全部'
|
||||
? shortcutLabel
|
||||
: `${props.query.dateRange[0]}—${props.query.dateRange[1]}`,
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
function patch(partial: Partial<CaseQuery>): void {
|
||||
emit('update:query', { ...props.query, ...partial })
|
||||
}
|
||||
|
||||
function setCategory(value: BusinessType | 'all'): void {
|
||||
patch({ category: value })
|
||||
}
|
||||
|
||||
function toggleIndustry(industry: string): void {
|
||||
const next = props.query.industries.includes(industry)
|
||||
? props.query.industries.filter((item) => item !== industry)
|
||||
: [...props.query.industries, industry]
|
||||
patch({ industries: next })
|
||||
}
|
||||
|
||||
function toggleScale(scale: ScaleRange): void {
|
||||
const next = props.query.scales.includes(scale)
|
||||
? props.query.scales.filter((item) => item !== scale)
|
||||
: [...props.query.scales, scale]
|
||||
patch({ scales: next })
|
||||
}
|
||||
|
||||
function setShortcut(value: TimeShortcut): void {
|
||||
activeShortcut.value = value
|
||||
if (value === 'all') {
|
||||
patch({ dateRange: null })
|
||||
return
|
||||
}
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
if (value === '3m') start.setMonth(start.getMonth() - 3)
|
||||
if (value === '6m') start.setMonth(start.getMonth() - 6)
|
||||
if (value === '1y') start.setFullYear(start.getFullYear() - 1)
|
||||
patch({
|
||||
dateRange: [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')],
|
||||
})
|
||||
}
|
||||
|
||||
function onDateRange(value: [Date, Date] | null): void {
|
||||
activeShortcut.value = 'custom'
|
||||
patch({
|
||||
dateRange: value
|
||||
? [formatDate(value[0], 'YYYY-MM-DD'), formatDate(value[1], 'YYYY-MM-DD')]
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
function onRemoveChip(key: string): void {
|
||||
if (key === 'category') {
|
||||
setCategory('all')
|
||||
}
|
||||
else if (key === 'time') {
|
||||
activeShortcut.value = 'all'
|
||||
patch({ dateRange: null })
|
||||
}
|
||||
else if (key.startsWith('industry:')) {
|
||||
const industry = key.slice('industry:'.length)
|
||||
patch({ industries: props.query.industries.filter((item) => item !== industry) })
|
||||
}
|
||||
else if (key.startsWith('scale:')) {
|
||||
const scale = key.slice('scale:'.length) as ScaleRange
|
||||
patch({ scales: props.query.scales.filter((item) => item !== scale) })
|
||||
}
|
||||
}
|
||||
|
||||
function onClearChips(): void {
|
||||
activeShortcut.value = 'all'
|
||||
emit('update:query', {
|
||||
...props.query,
|
||||
category: 'all',
|
||||
industries: [],
|
||||
scales: [],
|
||||
dateRange: null,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FilterPanel
|
||||
v-model:keyword="keyword"
|
||||
:chips="chips"
|
||||
:show-result-count="true"
|
||||
:result-count="total"
|
||||
search-label="确定筛选"
|
||||
@search="emit('search')"
|
||||
@reset="emit('reset')"
|
||||
@remove-chip="onRemoveChip"
|
||||
@clear-chips="onClearChips"
|
||||
>
|
||||
<!-- 业务类型(单选 + 计数) -->
|
||||
<div class="cases-filter__group">
|
||||
<span class="cases-filter__group-title">业务类型</span>
|
||||
<div class="cases-filter__options">
|
||||
<button
|
||||
v-for="option in categoryOptions"
|
||||
:key="option.value"
|
||||
class="cases-filter__option"
|
||||
:class="{ 'is-active': query.category === option.value }"
|
||||
@click="setCategory(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span class="cases-filter__count">
|
||||
{{ option.value === 'all' ? total : (counts[option.value] ?? 0) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 所属行业(多选) -->
|
||||
<div class="cases-filter__group">
|
||||
<span class="cases-filter__group-title">所属行业</span>
|
||||
<div class="cases-filter__options">
|
||||
<button
|
||||
class="cases-filter__option"
|
||||
:class="{ 'is-active': query.industries.length === 0 }"
|
||||
@click="patch({ industries: [] })"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
v-for="industry in industryOptions"
|
||||
:key="industry"
|
||||
class="cases-filter__option"
|
||||
:class="{ 'is-active': query.industries.includes(industry) }"
|
||||
@click="toggleIndustry(industry)"
|
||||
>
|
||||
{{ industry }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目规模(多选) -->
|
||||
<div class="cases-filter__group">
|
||||
<span class="cases-filter__group-title">项目规模</span>
|
||||
<div class="cases-filter__options">
|
||||
<button
|
||||
class="cases-filter__option"
|
||||
:class="{ 'is-active': query.scales.length === 0 }"
|
||||
@click="patch({ scales: [] })"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
v-for="scale in scaleOptions"
|
||||
:key="scale.value"
|
||||
class="cases-filter__option"
|
||||
:class="{ 'is-active': query.scales.includes(scale.value) }"
|
||||
@click="toggleScale(scale.value)"
|
||||
>
|
||||
{{ scale.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 完成时间(快捷区间 + 自定义) -->
|
||||
<div class="cases-filter__group">
|
||||
<span class="cases-filter__group-title">完成时间</span>
|
||||
<div class="cases-filter__options">
|
||||
<button
|
||||
v-for="shortcut in timeShortcuts"
|
||||
:key="shortcut.value"
|
||||
class="cases-filter__option"
|
||||
:class="{ 'is-active': activeShortcut === shortcut.value }"
|
||||
@click="setShortcut(shortcut.value)"
|
||||
>
|
||||
{{ shortcut.label }}
|
||||
</button>
|
||||
<ElDatePicker
|
||||
v-model="query.dateRange"
|
||||
type="daterange"
|
||||
range-separator="—"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="cases-filter__date"
|
||||
@change="onDateRange"
|
||||
/> </div>
|
||||
</div>
|
||||
</FilterPanel>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.cases-filter {
|
||||
&__group {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__group-title {
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
padding-top: 5px;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__options {
|
||||
.flex(row, flex-start, center);
|
||||
flex-wrap: wrap;
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__option {
|
||||
height: 28px;
|
||||
padding: 0 @spacing-sm;
|
||||
border: 1px solid transparent;
|
||||
border-radius: @radius-sm;
|
||||
background: @bg-color;
|
||||
color: @text-color;
|
||||
font-size: @font-size-sm;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @primary-color;
|
||||
background: @primary-light;
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: 4px;
|
||||
color: @text-color-secondary;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__date {
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
43
src/views/cases/cpns/CasesGrid.vue
Normal file
43
src/views/cases/cpns/CasesGrid.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { CaseCard } from '@/components'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
cases: Case[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ view: [id: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cases-grid">
|
||||
<template v-if="cases.length > 0">
|
||||
<CaseCard
|
||||
v-for="item in cases"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@view="emit('view', $event)"
|
||||
/>
|
||||
</template>
|
||||
<p v-else class="cases-grid__empty text-secondary">
|
||||
暂无符合条件的案例,请调整筛选条件
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.cases-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: @spacing-base;
|
||||
|
||||
&__empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: @spacing-xxl 0;
|
||||
text-align: center;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
90
src/views/cases/detail.vue
Normal file
90
src/views/cases/detail.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { AppButton } from '@/components'
|
||||
// 页面容器:数据只经 store,不直接 import api
|
||||
import { useCasesStore } from '@/stores/modules/cases'
|
||||
import CaseDetailHeader from './cpns/CaseDetailHeader.vue'
|
||||
import CaseRichText from './cpns/CaseRichText.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const casesStore = useCasesStore()
|
||||
|
||||
const item = computed(() => casesStore.currentCase)
|
||||
const caseId = computed(() => String(route.params.id))
|
||||
|
||||
function load(): void {
|
||||
void casesStore.fetchCaseDetail(caseId.value)
|
||||
}
|
||||
|
||||
async function onFavorite(id: string): Promise<void> {
|
||||
await casesStore.toggleFavorite(id)
|
||||
ElMessage.success(item.value?.isFavorite ? '已加入收藏' : '已取消收藏')
|
||||
}
|
||||
|
||||
async function onShare(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
ElMessage.success('链接已复制,快去分享吧')
|
||||
}
|
||||
catch {
|
||||
ElMessage.error('复制失败,请手动复制地址栏链接')
|
||||
}
|
||||
}
|
||||
|
||||
watch(caseId, load)
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="case-detail">
|
||||
<nav class="case-detail__breadcrumb text-secondary">
|
||||
<RouterLink to="/cases" class="link">
|
||||
案例库
|
||||
</RouterLink>
|
||||
<span> > </span>
|
||||
<span>案例详情</span>
|
||||
</nav>
|
||||
|
||||
<template v-if="item">
|
||||
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
|
||||
<CaseRichText :item="item" />
|
||||
|
||||
<div class="case-detail__cta">
|
||||
<AppButton type="primary" size="lg" @click="router.push('/demand')">
|
||||
一键填报类似案例
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-else class="case-detail__empty text-secondary">
|
||||
案例不存在或已下线
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-detail {
|
||||
padding-top: @spacing-lg;
|
||||
padding-bottom: @spacing-xxl;
|
||||
|
||||
&__breadcrumb {
|
||||
margin-bottom: @spacing-base;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
|
||||
&__cta {
|
||||
.flex(row, center);
|
||||
padding-top: @spacing-base;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: @spacing-xxl 0;
|
||||
text-align: center;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
src/views/cases/index.ts
Normal file
5
src/views/cases/index.ts
Normal file
@@ -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'
|
||||
90
src/views/cases/index.vue
Normal file
90
src/views/cases/index.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { PaginationBar } from '@/components'
|
||||
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
|
||||
import { useCasesStore } from '@/stores/modules/cases'
|
||||
import type { CaseQuery } from '@/types/business/cases'
|
||||
import CasesFilter from './cpns/CasesFilter.vue'
|
||||
import CasesGrid from './cpns/CasesGrid.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const casesStore = useCasesStore()
|
||||
const { query, list, total, counts } = storeToRefs(casesStore)
|
||||
|
||||
function onUpdateQuery(next: CaseQuery): void {
|
||||
casesStore.setQuery(next)
|
||||
}
|
||||
|
||||
function onSearch(): void {
|
||||
void casesStore.search()
|
||||
}
|
||||
|
||||
function onReset(): void {
|
||||
void casesStore.resetQuery()
|
||||
}
|
||||
|
||||
function onPageChange(page: number): void {
|
||||
void casesStore.changePage(page)
|
||||
}
|
||||
|
||||
function onViewDetail(id: string): void {
|
||||
void router.push(`/cases/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void Promise.all([casesStore.fetchCases(), casesStore.fetchCounts()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cases">
|
||||
<header class="cases__head">
|
||||
<h1 class="cases__title">
|
||||
成功案例库
|
||||
</h1>
|
||||
<p class="cases__slogan text-secondary">
|
||||
海量软硬件研发与量产落地真实案例,借鉴成熟实践,降低项目试错成本。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<CasesFilter
|
||||
:query="query"
|
||||
:counts="counts"
|
||||
:total="total"
|
||||
@update:query="onUpdateQuery"
|
||||
@search="onSearch"
|
||||
@reset="onReset"
|
||||
/>
|
||||
|
||||
<CasesGrid :cases="list" @view="onViewDetail" />
|
||||
|
||||
<PaginationBar
|
||||
:page="query.page"
|
||||
:page-size="query.pageSize"
|
||||
:total="total"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.cases {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
padding-top: @spacing-xl;
|
||||
padding-bottom: @spacing-xxl;
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-xxl;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__slogan {
|
||||
margin-top: @spacing-xs;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
216
src/views/contract/cpns/ContractInfoCard.vue
Normal file
216
src/views/contract/cpns/ContractInfoCard.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { StatusTag } from '@/components'
|
||||
import type { FileInfo } from '@/types/api'
|
||||
import type { Contract, SignStatus } from '@/types/business/contract'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
contract: Contract
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewProject: []
|
||||
preview: [file: FileInfo]
|
||||
download: [file: FileInfo]
|
||||
}>()
|
||||
|
||||
const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
'履行中': 'success',
|
||||
'已过期': 'danger',
|
||||
'待签署': 'warning',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contract-info">
|
||||
<!-- 只读信息网格 -->
|
||||
<div class="contract-info__grid">
|
||||
<div class="contract-info__field">
|
||||
<span class="contract-info__label text-secondary">合同名称</span>
|
||||
<span class="contract-info__value">{{ contract.contractName }}</span>
|
||||
</div>
|
||||
<div class="contract-info__field">
|
||||
<span class="contract-info__label text-secondary">合同类型</span>
|
||||
<span class="contract-info__value">{{ contract.contractType }}</span>
|
||||
</div>
|
||||
<div class="contract-info__field">
|
||||
<span class="contract-info__label text-secondary">合同编号</span>
|
||||
<span class="contract-info__value">{{ contract.contractNo }}</span>
|
||||
</div>
|
||||
<div class="contract-info__field">
|
||||
<span class="contract-info__label text-secondary">签订日期</span>
|
||||
<span class="contract-info__value">{{ contract.signDate || '—' }}</span>
|
||||
</div>
|
||||
<div class="contract-info__field">
|
||||
<span class="contract-info__label text-secondary">生效日期</span>
|
||||
<span class="contract-info__value">{{ contract.effectiveDate }}</span>
|
||||
</div>
|
||||
<div class="contract-info__field">
|
||||
<span class="contract-info__label text-secondary">到期日期</span>
|
||||
<span class="contract-info__value">{{ contract.expiryDate }}</span>
|
||||
</div>
|
||||
<div class="contract-info__field">
|
||||
<span class="contract-info__label text-secondary">合同状态</span>
|
||||
<StatusTag :label="contract.signStatus" :variant="SIGN_VARIANTS[contract.signStatus]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联项目 -->
|
||||
<div class="contract-info__related">
|
||||
<h3 class="contract-info__section-title">
|
||||
关联项目
|
||||
</h3>
|
||||
<div class="contract-info__related-body">
|
||||
<div class="contract-info__field">
|
||||
<span class="contract-info__label text-secondary">项目编号</span>
|
||||
<span class="contract-info__value">{{ contract.projectNo }}</span>
|
||||
</div>
|
||||
<div class="contract-info__field">
|
||||
<span class="contract-info__label text-secondary">项目名称</span>
|
||||
<span class="contract-info__value">{{ contract.relatedProjectName }}</span>
|
||||
</div>
|
||||
<button class="contract-info__link" @click="emit('viewProject')">
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 合同文件 -->
|
||||
<div class="contract-info__files-section">
|
||||
<h3 class="contract-info__section-title">
|
||||
合同文件
|
||||
</h3>
|
||||
<ul class="contract-info__files">
|
||||
<li
|
||||
v-for="(file, index) in contract.files"
|
||||
:key="`${file.name}-${index}`"
|
||||
class="contract-info__file"
|
||||
>
|
||||
<div class="contract-info__file-info">
|
||||
<span class="contract-info__file-name" :title="file.name">{{ file.name }}</span>
|
||||
<span class="contract-info__file-meta text-secondary">
|
||||
{{ formatFileSize(file.size) }} · {{ file.uploadTime }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="contract-info__file-actions">
|
||||
<button class="contract-info__link" @click="emit('preview', file)">
|
||||
预览
|
||||
</button>
|
||||
<button class="contract-info__link" @click="emit('download', file)">
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="contract.files.length === 0" class="contract-info__empty text-secondary">
|
||||
暂无合同文件
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.contract-info {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-lg;
|
||||
padding: @spacing-lg @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: @spacing-base @spacing-xl;
|
||||
}
|
||||
|
||||
&__field {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-sm;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin-bottom: @spacing-base;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__related,
|
||||
&__files-section {
|
||||
padding-top: @spacing-base;
|
||||
border-top: 1px solid @border-color;
|
||||
}
|
||||
|
||||
&__related-body {
|
||||
.flex(row, flex-start, center);
|
||||
flex-wrap: wrap;
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: @font-size-base;
|
||||
color: @primary-color;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
&__files {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__file {
|
||||
.flex(row, space-between);
|
||||
gap: @spacing-base;
|
||||
padding: @spacing-sm @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
}
|
||||
|
||||
&__file-info {
|
||||
.flex(column, flex-start);
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__file-name {
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__file-meta {
|
||||
font-size: @font-size-sm;
|
||||
}
|
||||
|
||||
&__file-actions {
|
||||
.flex(row, center);
|
||||
gap: @spacing-sm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
src/views/contract/cpns/ContractSignPanel.vue
Normal file
47
src/views/contract/cpns/ContractSignPanel.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { QrCodePanel } from '@/components'
|
||||
import SignSteps from './SignSteps.vue'
|
||||
|
||||
interface Props {
|
||||
expiresIn?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), { expiresIn: 272 })
|
||||
|
||||
const emit = defineEmits<{ refresh: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sign-panel">
|
||||
<div class="sign-panel__left">
|
||||
<QrCodePanel :expires-in="expiresIn" @refresh="emit('refresh')" />
|
||||
</div>
|
||||
<div class="sign-panel__right">
|
||||
<SignSteps />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.sign-panel {
|
||||
.flex(row, flex-start, stretch);
|
||||
gap: @spacing-lg;
|
||||
padding: @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__left {
|
||||
.flex(row, center);
|
||||
flex-shrink: 0;
|
||||
padding: @spacing-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
}
|
||||
|
||||
&__right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
161
src/views/contract/cpns/ContractTable.vue
Normal file
161
src/views/contract/cpns/ContractTable.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { StatusTag } from '@/components'
|
||||
import type { Contract, SignStatus } from '@/types/business/contract'
|
||||
import { formatMoney } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
contracts: Contract[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
sign: [id: string]
|
||||
view: [id: string]
|
||||
}>()
|
||||
|
||||
const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
'履行中': 'success',
|
||||
'已过期': 'danger',
|
||||
'待签署': 'warning',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contract-table">
|
||||
<table class="contract-table__grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>合同编号</th>
|
||||
<th>合同名称</th>
|
||||
<th>合同类型</th>
|
||||
<th>合同金额</th>
|
||||
<th>关联项目</th>
|
||||
<th>项目编号</th>
|
||||
<th>签署状态</th>
|
||||
<th>生效时间</th>
|
||||
<th>到期时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in contracts" :key="item.id">
|
||||
<td>{{ item.contractNo }}</td>
|
||||
<td class="contract-table__name" :title="item.contractName">
|
||||
{{ item.contractName }}
|
||||
</td>
|
||||
<td>{{ item.contractType }}</td>
|
||||
<td>¥{{ formatMoney(item.contractAmount) }}</td>
|
||||
<td class="contract-table__name" :title="item.relatedProjectName">
|
||||
{{ item.relatedProjectName }}
|
||||
</td>
|
||||
<td>{{ item.projectNo }}</td>
|
||||
<td>
|
||||
<StatusTag :label="item.signStatus" :variant="SIGN_VARIANTS[item.signStatus]" />
|
||||
</td>
|
||||
<td>{{ item.effectiveDate }}</td>
|
||||
<td>{{ item.expiryDate }}</td>
|
||||
<td class="contract-table__actions">
|
||||
<button
|
||||
v-if="item.signStatus === '待签署'"
|
||||
class="contract-table__btn contract-table__btn--primary"
|
||||
@click="emit('sign', item.id)"
|
||||
>
|
||||
去签署
|
||||
</button>
|
||||
<button class="contract-table__btn" @click="emit('view', item.id)">
|
||||
详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="contracts.length === 0">
|
||||
<td colspan="10" class="contract-table__empty text-secondary">
|
||||
暂无符合条件的合同
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.contract-table {
|
||||
overflow-x: auto;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
.scrollbar();
|
||||
|
||||
&__grid {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: @font-size-base;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: @spacing-base;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid @border-color;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
background: @bg-color-light;
|
||||
color: @heading-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td {
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: @bg-color-light;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
max-width: 200px;
|
||||
color: @heading-color;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.flex(row, center);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
font-size: @font-size-base;
|
||||
color: @primary-color;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
height: 28px;
|
||||
padding: 0 @spacing-sm;
|
||||
border: none;
|
||||
border-radius: @radius-sm;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: @spacing-xl 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
src/views/contract/cpns/SignSteps.vue
Normal file
76
src/views/contract/cpns/SignSteps.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
const steps = [
|
||||
{
|
||||
title: '打开手机支付宝',
|
||||
description: '点击右上角 + 选择扫一扫,扫描本页二维码',
|
||||
},
|
||||
{
|
||||
title: '验证身份信息',
|
||||
description: '跟随提示进行实名验证,确保本人操作',
|
||||
},
|
||||
{
|
||||
title: '确认合同内容并签署',
|
||||
description: '确定合同内容无误根据提示在指定位置进行合同签署',
|
||||
},
|
||||
{
|
||||
title: '完成签署',
|
||||
description: '签署完成请耐心等待系统反应,本页面将自动跳转至合同详情页',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol class="sign-steps">
|
||||
<li
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.title"
|
||||
class="sign-steps__item"
|
||||
>
|
||||
<span class="sign-steps__index">{{ index + 1 }}</span>
|
||||
<div class="sign-steps__body">
|
||||
<h3 class="sign-steps__title">{{ step.title }}</h3>
|
||||
<p class="sign-steps__desc text-secondary">{{ step.description }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.sign-steps {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-lg;
|
||||
|
||||
&__item {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__index {
|
||||
.flex(row, center);
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-light;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__body {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-base;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
98
src/views/contract/detail.vue
Normal file
98
src/views/contract/detail.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { AppButton } from '@/components'
|
||||
import type { FileInfo } from '@/types/api'
|
||||
// 页面容器:数据只经 store,不直接 import api
|
||||
import { useContractStore } from '@/stores/modules/contract'
|
||||
import ContractInfoCard from './cpns/ContractInfoCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const contractStore = useContractStore()
|
||||
const { current } = storeToRefs(contractStore)
|
||||
|
||||
const contractId = computed(() => String(route.params.id))
|
||||
|
||||
function load(): void {
|
||||
void contractStore.fetchDetail(contractId.value)
|
||||
}
|
||||
|
||||
function onBack(): void {
|
||||
void router.push('/contract')
|
||||
}
|
||||
|
||||
function onViewProject(): void {
|
||||
// TODO: 关联项目 id 由接口返回后跳转项目详情
|
||||
ElMessage.info('项目详情跳转待接口接入')
|
||||
}
|
||||
|
||||
function onPreview(file: FileInfo): void {
|
||||
// TODO(api): 合同文件预览接口
|
||||
ElMessage.info(`「${file.name}」预览待接口接入`)
|
||||
}
|
||||
|
||||
function onDownload(file: FileInfo): void {
|
||||
// TODO(api): 合同文件下载接口
|
||||
ElMessage.info(`「${file.name}」下载待接口接入`)
|
||||
}
|
||||
|
||||
watch(contractId, load)
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contract-detail">
|
||||
<nav class="contract-detail__breadcrumb text-secondary">
|
||||
<RouterLink to="/contract" class="link">
|
||||
合同中心
|
||||
</RouterLink>
|
||||
<span> > </span>
|
||||
<span>合同详情</span>
|
||||
</nav>
|
||||
|
||||
<ContractInfoCard
|
||||
v-if="current"
|
||||
:contract="current"
|
||||
@view-project="onViewProject"
|
||||
@preview="onPreview"
|
||||
@download="onDownload"
|
||||
/>
|
||||
|
||||
<p v-else class="contract-detail__empty text-secondary">
|
||||
合同不存在或已下线
|
||||
</p>
|
||||
|
||||
<div class="contract-detail__footer">
|
||||
<AppButton @click="onBack">
|
||||
返回
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.contract-detail {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
padding-top: @spacing-lg;
|
||||
padding-bottom: @spacing-xxl;
|
||||
|
||||
&__breadcrumb {
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
.flex(row, flex-start);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: @spacing-xxl 0;
|
||||
text-align: center;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
src/views/contract/index.ts
Normal file
5
src/views/contract/index.ts
Normal file
@@ -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'
|
||||
95
src/views/contract/index.vue
Normal file
95
src/views/contract/index.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { FilterPanel, PaginationBar } from '@/components'
|
||||
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
|
||||
import { useContractStore } from '@/stores/modules/contract'
|
||||
import ContractTable from './cpns/ContractTable.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const contractStore = useContractStore()
|
||||
const { query, list, total } = storeToRefs(contractStore)
|
||||
|
||||
const keyword = computed({
|
||||
get: () => query.value.keyword,
|
||||
set: (value: string) => contractStore.setQuery({ ...query.value, keyword: value }),
|
||||
})
|
||||
|
||||
function onSearch(): void {
|
||||
void contractStore.search()
|
||||
}
|
||||
|
||||
function onReset(): void {
|
||||
void contractStore.resetQuery()
|
||||
}
|
||||
|
||||
function onPageChange(page: number): void {
|
||||
void contractStore.changePage(page)
|
||||
}
|
||||
|
||||
function onView(id: string): void {
|
||||
void router.push(`/contract/${id}`)
|
||||
}
|
||||
|
||||
function onSign(id: string): void {
|
||||
void router.push(`/contract/${id}/sign`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void contractStore.fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contract">
|
||||
<header class="contract__head">
|
||||
<h1 class="contract__title">
|
||||
合同中心
|
||||
</h1>
|
||||
<p class="contract__slogan text-secondary">
|
||||
项目电子合同在线签署、归档与查询管理
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FilterPanel
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="合同名称/合同编号搜索"
|
||||
@search="onSearch"
|
||||
@reset="onReset"
|
||||
/>
|
||||
|
||||
<ContractTable
|
||||
:contracts="list"
|
||||
@view="onView"
|
||||
@sign="onSign"
|
||||
/>
|
||||
|
||||
<PaginationBar
|
||||
:page="query.page"
|
||||
:page-size="query.pageSize"
|
||||
:total="total"
|
||||
@page-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.contract {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
padding-top: @spacing-xl;
|
||||
padding-bottom: @spacing-xxl;
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-xxl;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__slogan {
|
||||
margin-top: @spacing-xs;
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
78
src/views/contract/sign.vue
Normal file
78
src/views/contract/sign.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { AppButton } from '@/components'
|
||||
// 页面容器:数据只经 store,不直接 import api
|
||||
import { useContractStore } from '@/stores/modules/contract'
|
||||
import ContractSignPanel from './cpns/ContractSignPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const contractStore = useContractStore()
|
||||
const { signExpires } = storeToRefs(contractStore)
|
||||
|
||||
const contractId = computed(() => String(route.params.id))
|
||||
|
||||
function load(): void {
|
||||
void contractStore.fetchSignQr(contractId.value)
|
||||
}
|
||||
|
||||
async function onRefresh(): Promise<void> {
|
||||
await contractStore.fetchSignQr(contractId.value, true)
|
||||
ElMessage.success('二维码已刷新')
|
||||
}
|
||||
|
||||
function onBack(): void {
|
||||
void router.push(`/contract/${contractId.value}`)
|
||||
}
|
||||
|
||||
watch(contractId, load)
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contract-sign">
|
||||
<nav class="contract-sign__breadcrumb text-secondary">
|
||||
<RouterLink to="/contract" class="link">
|
||||
合同中心
|
||||
</RouterLink>
|
||||
<span> > </span>
|
||||
<RouterLink :to="`/contract/${contractId}`" class="link">
|
||||
合同详情
|
||||
</RouterLink>
|
||||
<span> > </span>
|
||||
<span>签署合同</span>
|
||||
</nav>
|
||||
|
||||
<ContractSignPanel
|
||||
:expires-in="signExpires"
|
||||
@refresh="onRefresh"
|
||||
/>
|
||||
|
||||
<div class="contract-sign__footer">
|
||||
<AppButton @click="onBack">
|
||||
返回
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.contract-sign {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
padding-top: @spacing-lg;
|
||||
padding-bottom: @spacing-xxl;
|
||||
|
||||
&__breadcrumb {
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
.flex(row, flex-start);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
105
src/views/demand/cpns/DemandStep1Business.vue
Normal file
105
src/views/demand/cpns/DemandStep1Business.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import type { BusinessType } from '@/types/api'
|
||||
import type { Demand } from '@/types/business/demand'
|
||||
|
||||
interface Props {
|
||||
demand: Demand
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ select: [businessType: BusinessType] }>()
|
||||
|
||||
interface BusinessOption {
|
||||
value: BusinessType
|
||||
name: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
const options: BusinessOption[] = [
|
||||
{ value: 'internet', name: '互联网软件', desc: 'Web/小程序/APP/二次开发/桌面工具等' },
|
||||
{ value: 'iot', name: '物联网全案', desc: '软硬件开发+云端一体化/定制化开发等' },
|
||||
{ value: 'manufacturing', name: '生产制造', desc: 'PCB制版/SMT/整机装配/定制生产等' },
|
||||
{ value: 'hardware', name: '硬件电路', desc: '原理图设计/PCB设计/电气效验等' },
|
||||
{ value: 'other', name: '其他', desc: '若没有对应的服务内容请选择该选项' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-step1">
|
||||
<p class="demand-step1__title">
|
||||
请选择业务类型
|
||||
</p>
|
||||
<div class="demand-step1__grid">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="demand-step1__card"
|
||||
:class="{ 'is-active': demand.businessType === option.value }"
|
||||
@click="emit('select', option.value)"
|
||||
>
|
||||
<span class="demand-step1__name">{{ option.name }}</span>
|
||||
<span class="demand-step1__desc">{{ option.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-step1 {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-base;
|
||||
|
||||
&__title {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
.flex(row, flex-start);
|
||||
flex-wrap: wrap;
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__card {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
width: calc((100% - 32px) / 3);
|
||||
min-width: 220px;
|
||||
padding: @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @primary-color;
|
||||
background: @primary-light;
|
||||
box-shadow: @shadow-base;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__card.is-active &__name {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
229
src/views/demand/cpns/DemandStep2Info.vue
Normal file
229
src/views/demand/cpns/DemandStep2Info.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { FileList, UploadArea } from '@/components'
|
||||
import type { FileInfo } from '@/types/api'
|
||||
import type { BudgetRange, Demand } from '@/types/business/demand'
|
||||
|
||||
interface Props {
|
||||
demand: Demand
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:demand': [partial: Partial<Demand>]
|
||||
upload: [file: File]
|
||||
'remove-file': [file: FileInfo]
|
||||
}>()
|
||||
|
||||
const budgetOptions: Array<{ label: string; value: Exclude<BudgetRange, ''> }> = [
|
||||
{ label: '1万以下', value: 'lt1w' },
|
||||
{ label: '1-5万', value: '1to5w' },
|
||||
{ label: '5-10万', value: '5to10w' },
|
||||
{ label: '10万+', value: 'gt10w' },
|
||||
]
|
||||
|
||||
const descriptionLength = computed(() => props.demand.coreDescription.length)
|
||||
|
||||
function onProjectName(event: Event): void {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:demand', { projectName: target.value })
|
||||
}
|
||||
|
||||
function onCoreDescription(event: Event): void {
|
||||
const target = event.target as HTMLTextAreaElement
|
||||
emit('update:demand', { coreDescription: target.value })
|
||||
}
|
||||
|
||||
function onDuration(event: Event): void {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value === '' ? '' : Number(target.value)
|
||||
emit('update:demand', { expectedDuration: value })
|
||||
}
|
||||
|
||||
function onUpload(file: File): void {
|
||||
emit('upload', file)
|
||||
}
|
||||
|
||||
function onRemoveFile(file: FileInfo): void {
|
||||
emit('remove-file', file)
|
||||
}
|
||||
|
||||
function onFileAction(action: 'preview' | 'download', file: FileInfo): void {
|
||||
ElMessage.info(`「${file.name}」${action === 'preview' ? '预览' : '下载'}功能待接口接入`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-step2">
|
||||
<div class="demand-step2__field">
|
||||
<label class="demand-step2__label">项目名称</label>
|
||||
<input
|
||||
class="demand-step2__input"
|
||||
type="text"
|
||||
:value="demand.projectName"
|
||||
placeholder="请输入项目名称"
|
||||
@input="onProjectName"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="demand-step2__field">
|
||||
<label class="demand-step2__label">相关附件</label>
|
||||
<UploadArea @upload="onUpload" />
|
||||
<FileList
|
||||
:files="demand.attachments"
|
||||
@preview="(file) => onFileAction('preview', file)"
|
||||
@download="(file) => onFileAction('download', file)"
|
||||
@delete="onRemoveFile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="demand-step2__field">
|
||||
<label class="demand-step2__label">核心需求描述</label>
|
||||
<div class="demand-step2__textarea-wrap">
|
||||
<textarea
|
||||
class="demand-step2__textarea"
|
||||
:value="demand.coreDescription"
|
||||
maxlength="500"
|
||||
placeholder="请描述您的核心需求(业务目标、功能范围、交付期望等)"
|
||||
@input="onCoreDescription"
|
||||
/>
|
||||
<span class="demand-step2__counter">{{ descriptionLength }}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demand-step2__field">
|
||||
<label class="demand-step2__label">预计开发周期</label>
|
||||
<input
|
||||
class="demand-step2__input"
|
||||
type="number"
|
||||
:value="demand.expectedDuration"
|
||||
min="1"
|
||||
placeholder="请输入预计开发周期(工作日)"
|
||||
@input="onDuration"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="demand-step2__field">
|
||||
<label class="demand-step2__label">预算范围</label>
|
||||
<div class="demand-step2__budgets">
|
||||
<button
|
||||
v-for="option in budgetOptions"
|
||||
:key="option.value"
|
||||
class="demand-step2__budget"
|
||||
:class="{ 'is-active': demand.budgetRange === option.value }"
|
||||
@click="emit('update:demand', { budgetRange: option.value })"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-step2 {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-lg;
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-sm;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&__textarea-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: @spacing-sm @spacing-base 28px;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
line-height: 1.6;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&__counter {
|
||||
position: absolute;
|
||||
right: @spacing-sm;
|
||||
bottom: @spacing-sm;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__budgets {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__budget {
|
||||
height: 36px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @primary-color;
|
||||
background: @primary-light;
|
||||
color: @primary-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
172
src/views/demand/cpns/DemandStep3Confirm.vue
Normal file
172
src/views/demand/cpns/DemandStep3Confirm.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Demand } from '@/types/business/demand'
|
||||
|
||||
interface Props {
|
||||
demand: Demand
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ 'update:demand': [partial: Partial<Demand>] }>()
|
||||
|
||||
const remarkLength = computed(() => props.demand.remark.length)
|
||||
|
||||
function onUserName(event: Event): void {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:demand', { userName: target.value })
|
||||
}
|
||||
|
||||
function onUserPhone(event: Event): void {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:demand', { userPhone: target.value })
|
||||
}
|
||||
|
||||
function onRemark(event: Event): void {
|
||||
const target = event.target as HTMLTextAreaElement
|
||||
emit('update:demand', { remark: target.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demand-step3">
|
||||
<div class="demand-step3__row">
|
||||
<div class="demand-step3__field">
|
||||
<label class="demand-step3__label">用户名称</label>
|
||||
<input
|
||||
class="demand-step3__input"
|
||||
type="text"
|
||||
:value="demand.userName"
|
||||
placeholder="请输入用户名称"
|
||||
@input="onUserName"
|
||||
>
|
||||
</div>
|
||||
<div class="demand-step3__field">
|
||||
<label class="demand-step3__label">用户手机号</label>
|
||||
<input
|
||||
class="demand-step3__input"
|
||||
type="text"
|
||||
:value="demand.userPhone"
|
||||
placeholder="请输入用户手机号"
|
||||
@input="onUserPhone"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demand-step3__field">
|
||||
<label class="demand-step3__label">补充备注</label>
|
||||
<div class="demand-step3__textarea-wrap">
|
||||
<textarea
|
||||
class="demand-step3__textarea"
|
||||
:value="demand.remark"
|
||||
maxlength="500"
|
||||
placeholder="可补充其他说明(选填)"
|
||||
@input="onRemark"
|
||||
/>
|
||||
<span class="demand-step3__counter">{{ remarkLength }}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="demand-step3__tip">
|
||||
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demand-step3 {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-lg;
|
||||
|
||||
&__row {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-base;
|
||||
width: 100%;
|
||||
|
||||
.demand-step3__field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__field {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-sm;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&__textarea-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: @spacing-sm @spacing-base 28px;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
line-height: 1.6;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&__counter {
|
||||
position: absolute;
|
||||
right: @spacing-sm;
|
||||
bottom: @spacing-sm;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
padding: @spacing-sm @spacing-base;
|
||||
border-radius: @radius-base;
|
||||
background: @primary-light;
|
||||
font-size: @font-size-sm;
|
||||
color: @primary-color;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
4
src/views/demand/index.ts
Normal file
4
src/views/demand/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default } from './index.vue'
|
||||
export { default as DemandStep1Business } from './cpns/DemandStep1Business.vue'
|
||||
export { default as DemandStep2Info } from './cpns/DemandStep2Info.vue'
|
||||
export { default as DemandStep3Confirm } from './cpns/DemandStep3Confirm.vue'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user