feat 搭建架构骨架

This commit is contained in:
Ironsp
2026-09-24 09:09:47 +08:00
parent dd9f6b77a4
commit 7a2efa3fea
180 changed files with 18967 additions and 9 deletions

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
# TODO(api): 待接口文档提供后填写
VITE_API_BASE_URL=

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
# TODO(api): 待接口文档提供后填写
VITE_API_BASE_URL=

7
.gitignore vendored
View File

@@ -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/*

View File

@@ -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
View File

@@ -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: {}

View File

@@ -1,5 +1,3 @@
<script setup lang="ts"></script>
<template>
<h1>hello world</h1>
<router-view />
</template>

96
src/api/auth.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(),
})
}

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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'

View 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 }
}

View 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 }
}

View 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 }
}

View 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 }
}

View 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
View 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
View 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

View 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
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** API 基础地址 */
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View 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
View 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>

View File

@@ -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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
// 样式入口(main.ts 引入;variables/mixins 由 vite additionalData 自动注入)
@import './reset.less';
@import './common.less';

37
src/styles/mixins.less Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
// 全局声明(Window 扩展等,按需添加)
export {}
declare global {
interface Window {
// 全局挂载扩展位
}
}

11
src/types/router.d.ts vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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> &gt; </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
View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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> &gt; </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>

View 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'

View 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>

View 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> &gt; </span>
<RouterLink :to="`/contract/${contractId}`" class="link">
合同详情
</RouterLink>
<span> &gt; </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>

View 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>

View 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>

View 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>

View 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