first
This commit is contained in:
2
.env.development
Normal file
2
.env.development
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 开发期走 vite 代理:/api 前缀由 vite.config.ts 转发到后端 8080 并去掉前缀
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
2
.env.production
Normal file
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# TODO(api): 待接口文档提供后填写
|
||||||
|
VITE_API_BASE_URL=
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
docs
|
||||||
|
.claude
|
||||||
|
read_prototype.js
|
||||||
|
RTK.md
|
||||||
|
.playwright-mcp
|
||||||
|
.omo
|
||||||
|
.opencode
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
.npmrc
Normal file
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
use-node-version=20.19.0
|
||||||
|
package-manager-strict=true
|
||||||
|
engine-strict=true
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
README.md
Normal file
5
README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>cz-web</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2131
package-lock.json
generated
Normal file
2131
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "cz-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0",
|
||||||
|
"pnpm": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"preinstall": "npx only-allow pnpm"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.20.0",
|
||||||
|
"element-plus": "^2.14.5",
|
||||||
|
"pinia": "^4.0.3",
|
||||||
|
"vue": "^3.5.42",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1368
pnpm-lock.yaml
generated
Normal file
1368
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
3
src/App.vue
Normal file
3
src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
96
src/api/auth.ts
Normal file
96
src/api/auth.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type {
|
||||||
|
LoginByAccountParams,
|
||||||
|
LoginBySmsParams,
|
||||||
|
LoginResult,
|
||||||
|
RegisterParams,
|
||||||
|
ResetPasswordParams,
|
||||||
|
User,
|
||||||
|
} from '@/types/business/user'
|
||||||
|
import { isPhone, isValidPassword } from '@/utils/validate'
|
||||||
|
|
||||||
|
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||||
|
|
||||||
|
const SIX_DIGIT_CODE = /^\d{6}$/
|
||||||
|
|
||||||
|
/** 发送短信验证码 */
|
||||||
|
export function sendSmsCode(phone: string): Promise<void> {
|
||||||
|
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号密码登录(mock:demo 账号可登录,其余提示先注册) */
|
||||||
|
export function loginByAccount(params: LoginByAccountParams): Promise<LoginResult> {
|
||||||
|
const { account, password } = params
|
||||||
|
if (!account.trim() || !password) {
|
||||||
|
return Promise.reject(new Error('请输入账号和密码'))
|
||||||
|
}
|
||||||
|
if (account === 'demo') {
|
||||||
|
const user: User = {
|
||||||
|
id: 'u1',
|
||||||
|
nickname: '王永志',
|
||||||
|
avatar: '',
|
||||||
|
phone: '186****618',
|
||||||
|
email: 'yongzh******4@163.com',
|
||||||
|
passwordModifiedAt: '',
|
||||||
|
}
|
||||||
|
return Promise.resolve({ token: `mock-token-${Date.now()}`, user })
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('登录失败,请先注册账号'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手机验证码登录(mock:验证码须为 6 位数字) */
|
||||||
|
export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> {
|
||||||
|
const { phone, code } = params
|
||||||
|
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
|
||||||
|
if (!SIX_DIGIT_CODE.test(code)) return Promise.reject(new Error('验证码格式不正确,应为6位数字'))
|
||||||
|
const user: User = {
|
||||||
|
id: 'u1',
|
||||||
|
nickname: '王永志',
|
||||||
|
avatar: '',
|
||||||
|
phone: '186****618',
|
||||||
|
email: 'yongzh******4@163.com',
|
||||||
|
passwordModifiedAt: '',
|
||||||
|
}
|
||||||
|
return Promise.resolve({ token: `mock-token-${Date.now()}`, user })
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAuthForm(
|
||||||
|
phone: string,
|
||||||
|
code: string,
|
||||||
|
password: string,
|
||||||
|
confirmPassword: string,
|
||||||
|
agree: boolean,
|
||||||
|
): string | null {
|
||||||
|
if (!isPhone(phone)) return '手机号格式不正确'
|
||||||
|
if (!SIX_DIGIT_CODE.test(code)) return '验证码格式不正确,应为6位数字'
|
||||||
|
if (!isValidPassword(password)) return '密码长度应为6-20位'
|
||||||
|
if (password !== confirmPassword) return '两次输入的密码不一致'
|
||||||
|
if (!agree) return '请先阅读并同意《用户协议》和《隐私政策》'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册 */
|
||||||
|
export function register(params: RegisterParams): Promise<void> {
|
||||||
|
const message = validateAuthForm(
|
||||||
|
params.phone,
|
||||||
|
params.code,
|
||||||
|
params.password,
|
||||||
|
params.confirmPassword,
|
||||||
|
params.agree,
|
||||||
|
)
|
||||||
|
if (message) return Promise.reject(new Error(message))
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置密码 */
|
||||||
|
export function resetPassword(params: ResetPasswordParams): Promise<void> {
|
||||||
|
const message = validateAuthForm(
|
||||||
|
params.phone,
|
||||||
|
params.code,
|
||||||
|
params.password,
|
||||||
|
params.confirmPassword,
|
||||||
|
params.agree,
|
||||||
|
)
|
||||||
|
if (message) return Promise.reject(new Error(message))
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
227
src/api/cases.ts
Normal file
227
src/api/cases.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import type { BusinessType, PageResult } from '@/types/api'
|
||||||
|
import type { Case, CaseQuery, ScaleRange } from '@/types/business/cases'
|
||||||
|
|
||||||
|
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||||
|
|
||||||
|
/** 案例 mock(可变,收藏状态在前端内存中翻转演示) */
|
||||||
|
const MOCK_CASES: Case[] = [
|
||||||
|
{
|
||||||
|
id: 'case-001',
|
||||||
|
title: '自动化生产线升级改造项目',
|
||||||
|
category: 'manufacturing',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 50,
|
||||||
|
scale: '300万',
|
||||||
|
industry: '自动化',
|
||||||
|
duration: '150个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
||||||
|
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
||||||
|
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
||||||
|
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case-002',
|
||||||
|
title: '智能工厂物联网整体解决方案',
|
||||||
|
category: 'iot',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 30,
|
||||||
|
scale: '500万',
|
||||||
|
industry: '工厂服务',
|
||||||
|
duration: '200个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
|
||||||
|
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
|
||||||
|
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
|
||||||
|
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case-003',
|
||||||
|
title: '智能穿戴设备PCB设计与量产',
|
||||||
|
category: 'hardware',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 50,
|
||||||
|
scale: '10-50万',
|
||||||
|
industry: '智能设备',
|
||||||
|
duration: '30个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
|
||||||
|
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
|
||||||
|
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
|
||||||
|
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case-004',
|
||||||
|
title: '智能园区综合管理平台开发',
|
||||||
|
category: 'internet',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 20,
|
||||||
|
scale: '200万',
|
||||||
|
industry: '园区服务',
|
||||||
|
duration: '200个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。',
|
||||||
|
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
|
||||||
|
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。',
|
||||||
|
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case-005',
|
||||||
|
title: '电商中台系统定制开发',
|
||||||
|
category: 'internet',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/7/20',
|
||||||
|
teamSize: 15,
|
||||||
|
scale: '150万',
|
||||||
|
industry: '电商零售',
|
||||||
|
duration: '120个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '品牌方多渠道订单分散,库存与会员数据不通,需要统一电商中台。',
|
||||||
|
painPoints: '1、多平台库存不同步;2、会员体系割裂;3、营销活动上线周期长。',
|
||||||
|
requirements: '建设商品/库存/订单/会员中台,开放 API 对接主流电商平台。',
|
||||||
|
deliverables: '1、中台系统与管理后台;2、开放 API 与对接文档;3、数据迁移方案;4、上线陪跑服务。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case-006',
|
||||||
|
title: '教培机构在线学习平台',
|
||||||
|
category: 'internet',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/6/30',
|
||||||
|
teamSize: 12,
|
||||||
|
scale: '80万',
|
||||||
|
industry: '教育培训',
|
||||||
|
duration: '90个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '教培机构需要线上授课、作业批改与学员管理一体化能力。',
|
||||||
|
painPoints: '1、授课依赖第三方工具,数据不沉淀;2、续费转化缺少数据支撑。',
|
||||||
|
requirements: '交付直播授课、题库作业、班级管理、数据分析完整平台(Web + 小程序)。',
|
||||||
|
deliverables: '1、Web 学习平台;2、学员端小程序;3、题库与直播模块;4、运维部署手册。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case-007',
|
||||||
|
title: '医院智能随访系统开发',
|
||||||
|
category: 'internet',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/5/18',
|
||||||
|
teamSize: 18,
|
||||||
|
scale: '250万',
|
||||||
|
industry: '医疗健康',
|
||||||
|
duration: '180个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '三甲医院需要对出院患者进行结构化随访,提升复诊率与科研数据质量。',
|
||||||
|
painPoints: '1、电话随访效率低;2、随访数据非结构化,科研难复用。',
|
||||||
|
requirements: '建设随访计划引擎、患者端触达与随访数据仓,对接 HIS 系统。',
|
||||||
|
deliverables: '1、随访管理平台;2、患者端小程序;3、HIS 对接中间件;4、随访报表与数据字典。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case-008',
|
||||||
|
title: '园区光伏能耗管理系统',
|
||||||
|
category: 'iot',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/4/25',
|
||||||
|
teamSize: 10,
|
||||||
|
scale: '120万',
|
||||||
|
industry: '能源环保',
|
||||||
|
duration: '60个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '园区部署屋顶光伏后需要实时监控发电与用电,实现削峰填谷。',
|
||||||
|
painPoints: '1、发电数据分散在多品牌逆变器;2、能耗缺少分项计量。',
|
||||||
|
requirements: '接入逆变器/电表数据,建设发电与能耗看板,输出节能策略。',
|
||||||
|
deliverables: '1、能耗采集网关适配;2、监控看板系统;3、告警与报表;4、验收测试报告。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const SCALE_BOUNDS: Record<ScaleRange, [number, number]> = {
|
||||||
|
lt50w: [0, 50],
|
||||||
|
'50to100w': [50, 100],
|
||||||
|
'100to300w': [100, 300],
|
||||||
|
'300to500w': [300, 500],
|
||||||
|
gt500w: [500, Number.POSITIVE_INFINITY],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从展示值解析规模数值(万),如 '300万' → 300,'10-50万' → 10 */
|
||||||
|
function parseScaleValue(scale: string): number {
|
||||||
|
const value = Number.parseFloat(scale)
|
||||||
|
return Number.isNaN(value) ? 0 : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchScale(item: Case, selected: ScaleRange[]): boolean {
|
||||||
|
if (selected.length === 0) return true
|
||||||
|
const value = parseScaleValue(item.scale)
|
||||||
|
return selected.some((key) => {
|
||||||
|
const [min, max] = SCALE_BOUNDS[key]
|
||||||
|
return value >= min && value < max
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchDate(item: Case, range: [string, string] | null): boolean {
|
||||||
|
if (!range) return true
|
||||||
|
const time = new Date(item.finishDate).getTime()
|
||||||
|
const start = new Date(range[0]).getTime()
|
||||||
|
const end = new Date(range[1]).getTime()
|
||||||
|
return time >= start && time <= end
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters(query: CaseQuery): Case[] {
|
||||||
|
return MOCK_CASES.filter((item) => {
|
||||||
|
if (query.category !== 'all' && item.category !== query.category) return false
|
||||||
|
if (query.industries.length > 0 && !query.industries.includes(item.industry)) return false
|
||||||
|
if (!matchScale(item, query.scales)) return false
|
||||||
|
if (!matchDate(item, query.dateRange)) return false
|
||||||
|
if (query.keyword && !item.title.includes(query.keyword)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 案例列表(筛选 + 分页) */
|
||||||
|
export function getCaseList(query: CaseQuery): Promise<PageResult<Case>> {
|
||||||
|
const filtered = applyFilters(query)
|
||||||
|
const start = (query.page - 1) * query.pageSize
|
||||||
|
return Promise.resolve({
|
||||||
|
list: filtered.slice(start, start + query.pageSize),
|
||||||
|
total: filtered.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 案例详情(不存在返回 null) */
|
||||||
|
export function getCaseDetail(id: string): Promise<Case | null> {
|
||||||
|
const found = MOCK_CASES.find((item) => item.id === id) ?? null
|
||||||
|
return Promise.resolve(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收藏/取消收藏,返回最新案例数据 */
|
||||||
|
export function toggleFavorite(id: string): Promise<Case> {
|
||||||
|
const found = MOCK_CASES.find((item) => item.id === id)
|
||||||
|
if (!found) return Promise.reject(new Error('案例不存在'))
|
||||||
|
found.isFavorite = !found.isFavorite
|
||||||
|
return Promise.resolve({ ...found })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务类型计数(筛选面板角标) */
|
||||||
|
export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> {
|
||||||
|
const counts: Partial<Record<BusinessType, number>> = {}
|
||||||
|
for (const item of MOCK_CASES) {
|
||||||
|
counts[item.category] = (counts[item.category] ?? 0) + 1
|
||||||
|
}
|
||||||
|
return Promise.resolve(counts)
|
||||||
|
}
|
||||||
110
src/api/contract.ts
Normal file
110
src/api/contract.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { FileInfo, PageResult } from '@/types/api'
|
||||||
|
import type { Contract, ContractQuery } from '@/types/business/contract'
|
||||||
|
|
||||||
|
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||||
|
|
||||||
|
/** 构造 mock 文件信息 */
|
||||||
|
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
|
||||||
|
return { name, size, uploadTime, url: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情 mock 所属合同 id */
|
||||||
|
const DETAIL_ID = 'contract-001'
|
||||||
|
|
||||||
|
/** 合同列表 mock(3 条,覆盖全部签署状态) */
|
||||||
|
const MOCK_CONTRACTS: Contract[] = [
|
||||||
|
{
|
||||||
|
id: 'contract-001',
|
||||||
|
contractNo: 'C8K2M202408001',
|
||||||
|
contractName: '智慧城市建设项目技术服务合同',
|
||||||
|
contractType: '互联网软件通用/app开发',
|
||||||
|
contractAmount: 201000,
|
||||||
|
relatedProjectName: '智慧城市大数据平台建设项目',
|
||||||
|
projectNo: 'P8K2M202408001',
|
||||||
|
signStatus: '履行中',
|
||||||
|
signDate: '2026-8-17 14:00',
|
||||||
|
effectiveDate: '2026-8-17 14:00',
|
||||||
|
expiryDate: '2026-8-17 14:00',
|
||||||
|
files: [mockFile('项目需求规格说明书.pdf', 2516582, '2026-8-17 14:00')],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contract-002',
|
||||||
|
contractNo: 'C8K2M202408002',
|
||||||
|
contractName: '智慧城市建设项目硬件电路设计合同',
|
||||||
|
contractType: '硬件电路通用/电路版设计',
|
||||||
|
contractAmount: 180000,
|
||||||
|
relatedProjectName: '三甲医院指挥医疗信息化系统',
|
||||||
|
projectNo: 'P8K2M202408001',
|
||||||
|
signStatus: '已过期',
|
||||||
|
signDate: '2026-8-17 14:00',
|
||||||
|
effectiveDate: '2026-8-17 14:00',
|
||||||
|
expiryDate: '2026-8-17 14:00',
|
||||||
|
files: [mockFile('硬件电路设计合同.pdf', 1468006, '2026-8-17 14:00')],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contract-003',
|
||||||
|
contractNo: 'C8K2M202408003',
|
||||||
|
contractName: '智慧城市建设项目物联网全案合同',
|
||||||
|
contractType: '物联网全案通用/一套门禁卡',
|
||||||
|
contractAmount: 96000,
|
||||||
|
relatedProjectName: '社区康养智能服务系统',
|
||||||
|
projectNo: 'P8K2M202408001',
|
||||||
|
signStatus: '待签署',
|
||||||
|
effectiveDate: '2026-8-17 14:00',
|
||||||
|
expiryDate: '2026-8-17 14:00',
|
||||||
|
files: [mockFile('物联网全案合同.pdf', 1258291, '2026-8-17 14:00')],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 合同详情 mock */
|
||||||
|
const CONTRACT_DETAIL: Contract = {
|
||||||
|
id: DETAIL_ID,
|
||||||
|
contractNo: 'C8K2M202408001',
|
||||||
|
contractName: '社区康养智能服务系统开发合同',
|
||||||
|
contractType: '互联网软件开发',
|
||||||
|
contractAmount: 201000,
|
||||||
|
relatedProjectName: '社区康养智能服务系统',
|
||||||
|
projectNo: '20260813ZZ251245',
|
||||||
|
signStatus: '履行中',
|
||||||
|
signDate: '2024-10-15',
|
||||||
|
effectiveDate: '2024-10-15',
|
||||||
|
expiryDate: '2024-10-15',
|
||||||
|
files: [mockFile('项目需求规格说明书.pdf', 2516582, '2024-10-15')],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合同列表(keyword 筛选 + 分页) */
|
||||||
|
export function getContractList(query: ContractQuery): Promise<PageResult<Contract>> {
|
||||||
|
const filtered = MOCK_CONTRACTS.filter((item) => {
|
||||||
|
if (
|
||||||
|
query.keyword
|
||||||
|
&& !item.contractName.includes(query.keyword)
|
||||||
|
&& !item.contractNo.includes(query.keyword)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const pageSize = query.pageSize > 0 ? query.pageSize : 8
|
||||||
|
const start = (query.page - 1) * pageSize
|
||||||
|
return Promise.resolve({
|
||||||
|
list: filtered.slice(start, start + pageSize),
|
||||||
|
total: filtered.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合同详情(不存在返回 null) */
|
||||||
|
export function getContractDetail(id: string): Promise<Contract | null> {
|
||||||
|
if (id === DETAIL_ID) return Promise.resolve({ ...CONTRACT_DETAIL, files: [...CONTRACT_DETAIL.files] })
|
||||||
|
const found = MOCK_CONTRACTS.find((item) => item.id === id) ?? null
|
||||||
|
return Promise.resolve(found ? { ...found, files: [...found.files] } : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取签署二维码(返回有效期) */
|
||||||
|
export function getSignQr(_id: string): Promise<{ expiresInSeconds: number }> {
|
||||||
|
return Promise.resolve({ expiresInSeconds: 272 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新签署二维码(返回有效期) */
|
||||||
|
export function refreshSignQr(_id: string): Promise<{ expiresInSeconds: number }> {
|
||||||
|
return Promise.resolve({ expiresInSeconds: 272 })
|
||||||
|
}
|
||||||
25
src/api/demand.ts
Normal file
25
src/api/demand.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Demand } from '@/types/business/demand'
|
||||||
|
import { storage } from '@/utils/storage'
|
||||||
|
|
||||||
|
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||||
|
|
||||||
|
const DRAFT_KEY = 'demand-draft'
|
||||||
|
|
||||||
|
/** 提交需求(mock:返回提报单 id) */
|
||||||
|
export function submitDemand(demand: Demand): Promise<{ id: string }> {
|
||||||
|
if (!demand.businessType || !demand.projectName || !demand.coreDescription) {
|
||||||
|
return Promise.reject(new Error('需求信息不完整,无法提交'))
|
||||||
|
}
|
||||||
|
return Promise.resolve({ id: `demand-${Date.now()}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取本地草稿(无草稿返回 null) */
|
||||||
|
export function getDraftDemand(): Promise<Demand | null> {
|
||||||
|
return Promise.resolve(storage.sessionGet<Demand>(DRAFT_KEY))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存草稿到本地 */
|
||||||
|
export function saveDraft(demand: Demand): Promise<void> {
|
||||||
|
storage.sessionSet(DRAFT_KEY, { ...demand, status: 'draft' })
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
316
src/api/finance.ts
Normal file
316
src/api/finance.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import type { PageResult } from '@/types/api'
|
||||||
|
import type {
|
||||||
|
Bill,
|
||||||
|
BillQuery,
|
||||||
|
FinanceStats,
|
||||||
|
Invoice,
|
||||||
|
InvoiceQuery,
|
||||||
|
InvoiceTitle,
|
||||||
|
InvoiceTitlePayload,
|
||||||
|
PayMethod,
|
||||||
|
Payment,
|
||||||
|
PaymentResult,
|
||||||
|
} from '@/types/business/finance'
|
||||||
|
|
||||||
|
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||||
|
|
||||||
|
/** 账单 mock(可变:支付后状态在前端内存中翻转演示) */
|
||||||
|
const MOCK_BILLS: Bill[] = [
|
||||||
|
{
|
||||||
|
id: 'bill-001',
|
||||||
|
planName: '第一期·预付款 比例33%',
|
||||||
|
billNo: '20260813ZZ251245',
|
||||||
|
tradeNo: 'T20260819153035678K2M001',
|
||||||
|
projectName: '某大型银行数字化转型方案',
|
||||||
|
amount: 20000,
|
||||||
|
status: '已支付',
|
||||||
|
createTime: '2026-8-17 14:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bill-002',
|
||||||
|
planName: '第二期·进度款 比例30%',
|
||||||
|
billNo: '20260813ZZ251246',
|
||||||
|
projectName: '智慧城市大数据平台建设项目',
|
||||||
|
amount: 20100,
|
||||||
|
status: '待支付',
|
||||||
|
createTime: '2026-8-17 14:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bill-003',
|
||||||
|
planName: '第三期·验收款 比例27%',
|
||||||
|
billNo: '20260813ZZ251247',
|
||||||
|
projectName: '三甲医院指挥医疗信息化系统',
|
||||||
|
amount: 20000,
|
||||||
|
status: '未到期',
|
||||||
|
createTime: '2026-8-17 14:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bill-004',
|
||||||
|
planName: '第四期·尾款 比例10%',
|
||||||
|
billNo: '20260813ZZ251248',
|
||||||
|
projectName: '智慧城市大数据平台建设项目',
|
||||||
|
amount: 20100,
|
||||||
|
status: '未到期',
|
||||||
|
createTime: '2026-8-17 14:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bill-005',
|
||||||
|
planName: '第二期·进度款 比例30%',
|
||||||
|
billNo: '20260813ZZ251249',
|
||||||
|
projectName: '三甲医院指挥医疗信息化系统',
|
||||||
|
amount: 20100,
|
||||||
|
status: '待支付',
|
||||||
|
createTime: '2026-8-16 10:30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bill-006',
|
||||||
|
planName: '第一期·预付款 比例33%',
|
||||||
|
billNo: '20260813ZZ251250',
|
||||||
|
tradeNo: 'T20260819153035678K2M002',
|
||||||
|
projectName: '某大型银行数字化转型方案',
|
||||||
|
amount: 20000,
|
||||||
|
status: '已支付',
|
||||||
|
createTime: '2026-8-15 09:12',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 发票 mock(可变:申请开票后状态在前端内存中翻转演示) */
|
||||||
|
const MOCK_INVOICES: Invoice[] = [
|
||||||
|
{
|
||||||
|
id: 'invoice-001',
|
||||||
|
planName: '第一期·预付款 比例33%',
|
||||||
|
billNo: '20260813ZZ251245',
|
||||||
|
tradeNo: 'T20260819153035678K2M001',
|
||||||
|
projectName: '某大型银行数字化转型方案',
|
||||||
|
amount: 20000,
|
||||||
|
status: '已开票',
|
||||||
|
applyTime: '2026-8-17 14:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'invoice-002',
|
||||||
|
planName: '第一期·预付款 比例33%',
|
||||||
|
billNo: '20260813ZZ251250',
|
||||||
|
tradeNo: 'T20260819153035678K2M002',
|
||||||
|
projectName: '某大型银行数字化转型方案',
|
||||||
|
amount: 20000,
|
||||||
|
status: '开票中',
|
||||||
|
applyTime: '2026-8-17 14:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'invoice-003',
|
||||||
|
planName: '第二期·进度款 比例30%',
|
||||||
|
billNo: '20260813ZZ251249',
|
||||||
|
projectName: '三甲医院指挥医疗信息化系统',
|
||||||
|
amount: 20100,
|
||||||
|
status: '未开票',
|
||||||
|
applyTime: '2026-8-17 14:00',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 发票抬头 mock(可变,CRUD 在前端内存中演示) */
|
||||||
|
const MOCK_TITLES: InvoiceTitle[] = [
|
||||||
|
{
|
||||||
|
id: 'title-001',
|
||||||
|
type: '企业单位',
|
||||||
|
title: '山东日照有限有限公司',
|
||||||
|
taxNo: '344683434513543543545535',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'title-002',
|
||||||
|
type: '企业单位',
|
||||||
|
title: '山东省一周练七次有限公司',
|
||||||
|
taxNo: '6856754615216565476846',
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 支付进行单(billId → 当前二维码) */
|
||||||
|
const MOCK_PAYMENTS = new Map<string, Payment>()
|
||||||
|
|
||||||
|
/** 已产生支付结果的账单(billId → 结果),预置已支付账单 */
|
||||||
|
const MOCK_RESULTS = new Map<string, PaymentResult>()
|
||||||
|
MOCK_RESULTS.set('bill-001', {
|
||||||
|
method: 'wechat',
|
||||||
|
amount: 20000,
|
||||||
|
payTime: '2026-01-21 15:00:00',
|
||||||
|
tradeNo: 'T20260819153035678K2M001',
|
||||||
|
bills: [{ ...MOCK_BILLS[0] }],
|
||||||
|
})
|
||||||
|
MOCK_RESULTS.set('bill-006', {
|
||||||
|
method: 'wechat',
|
||||||
|
amount: 20000,
|
||||||
|
payTime: '2026-01-21 15:00:00',
|
||||||
|
tradeNo: 'T20260819153035678K2M002',
|
||||||
|
bills: [{ ...MOCK_BILLS[5] }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const DEFAULT_STATS: FinanceStats = {
|
||||||
|
totalExpense: 1726823900,
|
||||||
|
month: '2026年1',
|
||||||
|
pendingCount: 3,
|
||||||
|
distribution: [
|
||||||
|
{ name: '生产智造', value: 25, color: '#3370ff' },
|
||||||
|
{ name: '物联网全案', value: 25, color: '#34c724' },
|
||||||
|
{ name: '硬件电路', value: 25, color: '#ff8f1f' },
|
||||||
|
{ name: '互联网软件', value: 25, color: '#f54a45' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 财务统计(period 为展示周期参数,mock 恒返回原型数据) */
|
||||||
|
export function getFinanceStats(period?: string): Promise<FinanceStats> {
|
||||||
|
void period
|
||||||
|
return Promise.resolve(structuredClone(DEFAULT_STATS))
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchBillKeyword(item: Bill, keyword: string): boolean {
|
||||||
|
if (!keyword) return true
|
||||||
|
return (
|
||||||
|
item.planName.includes(keyword) ||
|
||||||
|
item.billNo.includes(keyword) ||
|
||||||
|
item.projectName.includes(keyword) ||
|
||||||
|
(item.tradeNo?.includes(keyword) ?? false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账单列表(筛选 + 分页) */
|
||||||
|
export function getBillList(query: BillQuery): Promise<PageResult<Bill>> {
|
||||||
|
const filtered = MOCK_BILLS.filter((item) => {
|
||||||
|
if (query.status !== 'all' && item.status !== query.status) return false
|
||||||
|
return matchBillKeyword(item, query.keyword)
|
||||||
|
})
|
||||||
|
const start = (query.page - 1) * query.pageSize
|
||||||
|
return Promise.resolve({
|
||||||
|
list: filtered.slice(start, start + query.pageSize).map((item) => ({ ...item })),
|
||||||
|
total: filtered.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账单详情(不存在返回 null) */
|
||||||
|
export function getBillDetail(id: string): Promise<Bill | null> {
|
||||||
|
const found = MOCK_BILLS.find((item) => item.id === id) ?? null
|
||||||
|
return Promise.resolve(found ? { ...found } : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建支付单(生成二维码支付信息) */
|
||||||
|
export function createPayment(billId: string, method: PayMethod): Promise<Payment> {
|
||||||
|
const bill = MOCK_BILLS.find((item) => item.id === billId)
|
||||||
|
if (!bill) return Promise.reject(new Error('账单不存在'))
|
||||||
|
const payment: Payment = {
|
||||||
|
billId,
|
||||||
|
method,
|
||||||
|
amount: bill.amount,
|
||||||
|
qrExpiresInSeconds: 300,
|
||||||
|
}
|
||||||
|
MOCK_PAYMENTS.set(billId, payment)
|
||||||
|
return Promise.resolve({ ...payment })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换支付方式(刷新二维码) */
|
||||||
|
export function switchPaymentMethod(billId: string, method: PayMethod): Promise<Payment> {
|
||||||
|
return createPayment(billId, method)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询支付结果(无支付记录且未支付返回 null;演示数据 payTime/tradeNo 固定) */
|
||||||
|
export function getPayResult(billId: string): Promise<PaymentResult | null> {
|
||||||
|
const existing = MOCK_RESULTS.get(billId)
|
||||||
|
if (existing) return Promise.resolve(structuredClone(existing))
|
||||||
|
|
||||||
|
const payment = MOCK_PAYMENTS.get(billId)
|
||||||
|
const bill = MOCK_BILLS.find((item) => item.id === billId)
|
||||||
|
if (!payment || !bill) return Promise.resolve(null)
|
||||||
|
|
||||||
|
// 演示:存在支付单即视为完成支付(createPayment 由「完成支付」触发)
|
||||||
|
const tradeNo = bill.tradeNo ?? 'T20260819153035678K2M001'
|
||||||
|
if (bill.status !== '已支付') {
|
||||||
|
bill.status = '已支付'
|
||||||
|
bill.tradeNo = tradeNo
|
||||||
|
}
|
||||||
|
const result: PaymentResult = {
|
||||||
|
method: payment.method,
|
||||||
|
amount: payment.amount,
|
||||||
|
payTime: '2026-01-21 15:00:00',
|
||||||
|
tradeNo,
|
||||||
|
bills: [{ ...bill }],
|
||||||
|
}
|
||||||
|
MOCK_RESULTS.set(billId, result)
|
||||||
|
return Promise.resolve(structuredClone(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchInvoiceKeyword(item: Invoice, keyword: string): boolean {
|
||||||
|
if (!keyword) return true
|
||||||
|
return (
|
||||||
|
item.planName.includes(keyword) ||
|
||||||
|
item.billNo.includes(keyword) ||
|
||||||
|
item.projectName.includes(keyword) ||
|
||||||
|
(item.tradeNo?.includes(keyword) ?? false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发票列表(筛选 + 分页) */
|
||||||
|
export function getInvoiceList(query: InvoiceQuery): Promise<PageResult<Invoice>> {
|
||||||
|
const filtered = MOCK_INVOICES.filter((item) => {
|
||||||
|
if (query.status !== 'all' && item.status !== query.status) return false
|
||||||
|
return matchInvoiceKeyword(item, query.keyword)
|
||||||
|
})
|
||||||
|
const start = (query.page - 1) * query.pageSize
|
||||||
|
return Promise.resolve({
|
||||||
|
list: filtered.slice(start, start + query.pageSize).map((item) => ({ ...item })),
|
||||||
|
total: filtered.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 申请开票(演示:状态置为开票中),返回最新发票 */
|
||||||
|
export function applyInvoice(id: string): Promise<Invoice> {
|
||||||
|
const found = MOCK_INVOICES.find((item) => item.id === id)
|
||||||
|
if (!found) return Promise.reject(new Error('发票记录不存在'))
|
||||||
|
found.status = '开票中'
|
||||||
|
return Promise.resolve({ ...found })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发票抬头列表 */
|
||||||
|
export function getInvoiceTitles(): Promise<InvoiceTitle[]> {
|
||||||
|
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增发票抬头 */
|
||||||
|
export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
|
||||||
|
const created: InvoiceTitle = {
|
||||||
|
id: `title-${Date.now()}`,
|
||||||
|
...payload,
|
||||||
|
isDefault: false,
|
||||||
|
}
|
||||||
|
MOCK_TITLES.push(created)
|
||||||
|
return Promise.resolve({ ...created })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑发票抬头 */
|
||||||
|
export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
|
||||||
|
const found = MOCK_TITLES.find((item) => item.id === id)
|
||||||
|
if (!found) return Promise.reject(new Error('抬头不存在'))
|
||||||
|
found.type = payload.type
|
||||||
|
found.title = payload.title
|
||||||
|
found.taxNo = payload.taxNo
|
||||||
|
return Promise.resolve({ ...found })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除发票抬头 */
|
||||||
|
export function deleteInvoiceTitle(id: string): Promise<void> {
|
||||||
|
const index = MOCK_TITLES.findIndex((item) => item.id === id)
|
||||||
|
if (index < 0) return Promise.reject(new Error('抬头不存在'))
|
||||||
|
MOCK_TITLES.splice(index, 1)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设为默认抬头(唯一默认) */
|
||||||
|
export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> {
|
||||||
|
for (const item of MOCK_TITLES) {
|
||||||
|
item.isDefault = item.id === id
|
||||||
|
}
|
||||||
|
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出报表(演示:稍后由页面提示成功) */
|
||||||
|
export function exportReport(): Promise<void> {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
180
src/api/home.ts
Normal file
180
src/api/home.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import type { BusinessType } from '@/types/api'
|
||||||
|
import type { Case } from '@/types/business/cases'
|
||||||
|
import type { BusinessLine, HomeStats, ServiceType } from '@/types/business/home'
|
||||||
|
|
||||||
|
// TODO(api): 待接口文档到位后,将以下 mock 返回替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||||
|
|
||||||
|
const SERVICE_TYPES: ServiceType[] = [
|
||||||
|
{ label: 'Web应用', desc: '响应式 Web 与后台管理系统定制开发' },
|
||||||
|
{ label: '小程序', desc: '微信/支付宝小程序开发与上架' },
|
||||||
|
{ label: 'APP', desc: 'iOS/Android 移动应用定制开发' },
|
||||||
|
{ label: '二次开发', desc: '基于现有系统的功能扩展与重构' },
|
||||||
|
{ label: '桌面工具', desc: '跨平台桌面端工具与自动化软件' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SERVICE_CONTENT = `一、产品咨询与规划服务
|
||||||
|
1、业务诊断与梳理:深入调研客户现有业务流程,识别数字化痛点与改进机会。
|
||||||
|
2、竞品分析与市场调研:分析行业标杆产品,提炼差异化竞争优势。
|
||||||
|
3、产品定位与MVP规划:明确核心用户群体,规划最小可行性产品(MVP)功能集,制定迭代路线图。
|
||||||
|
4、技术可行性评估:评估所选技术栈的成熟度、扩展性及与现有系统的兼容性。
|
||||||
|
二、UI/UX 设计服务
|
||||||
|
1、用户研究与体验地图:构建用户画像,梳理用户旅程,优化关键触点体验。
|
||||||
|
2、交互原型设计:制作高保真可交互原型,直观展示产品逻辑与操作流程,支持早期验证。
|
||||||
|
3、视觉UI设计:遵循最新设计趋势与品牌规范,输出图标、界面、动效等视觉元素。
|
||||||
|
4、设计规范与组件库:建立统一的设计系统(Design System),确保多端体验一致性,提升后续迭代效率。
|
||||||
|
三、技术开发服务
|
||||||
|
5、前端开发:基于 React/Vue/Flutter 等框架,实现响应式Web、H5、小程序及跨平台移动端应用。
|
||||||
|
6、后端开发:采用 Spring Cloud、Node.js、Go 等微服务架构,构建高并发、高可用的业务接口与逻辑。
|
||||||
|
7、数据库设计:根据业务特性设计关系型(MySQL)与非关系型(Redis、MongoDB)数据库集群方案。
|
||||||
|
8、DevOps 与云服务部署:搭建 CI/CD 自动化流水线,基于 Docker/K8s 进行容器化部署,保障系统弹性伸缩。
|
||||||
|
四、质量保障(QA)服务
|
||||||
|
1、功能测试:覆盖单元测试、集成测试、系统测试及UAT(用户验收测试)支持。
|
||||||
|
2、性能测试:进行压力测试、负载测试,定位系统瓶颈,优化响应速度。
|
||||||
|
3、安全测试:实施渗透测试、漏洞扫描,保障数据安全与隐私合规。
|
||||||
|
4、兼容性测试:覆盖主流浏览器、操作系统及移动设备型号。
|
||||||
|
五、运维与持续运营服务
|
||||||
|
1、7x24小时监控:实时监控系统健康度、流量及异常日志。
|
||||||
|
2、数据运营分析:接入埋点系统,输出用户行为分析报告,指导产品优化。
|
||||||
|
3、迭代升级:基于用户反馈与业务增长需求,提供持续的功能迭代与版本更新。`
|
||||||
|
|
||||||
|
const BUSINESS_LINES: BusinessLine[] = [
|
||||||
|
{
|
||||||
|
id: 'internet',
|
||||||
|
name: '互联网软件',
|
||||||
|
desc: 'Web/小程序/APP/二次开发/桌面工具等',
|
||||||
|
slogan: '专业的软件定制开发服务,从需求到上线全流程保障',
|
||||||
|
icon: '',
|
||||||
|
serviceContent: SERVICE_CONTENT,
|
||||||
|
serviceTypes: SERVICE_TYPES,
|
||||||
|
video: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hardware',
|
||||||
|
name: '硬件电路',
|
||||||
|
desc: '原理图设计/PCB设计/电气效验等',
|
||||||
|
slogan: '专业的硬件电路设计服务,从方案到量产全流程保障',
|
||||||
|
icon: '',
|
||||||
|
serviceContent: SERVICE_CONTENT,
|
||||||
|
serviceTypes: SERVICE_TYPES,
|
||||||
|
video: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'iot',
|
||||||
|
name: '物联网全案',
|
||||||
|
desc: '软硬件开发+云端一体化/定制化开发等',
|
||||||
|
slogan: '专业的物联网软硬一体化服务,从感知到云端全流程保障',
|
||||||
|
icon: '',
|
||||||
|
serviceContent: SERVICE_CONTENT,
|
||||||
|
serviceTypes: SERVICE_TYPES,
|
||||||
|
video: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'manufacturing',
|
||||||
|
name: '生产制造',
|
||||||
|
desc: 'PCB制版/SMT/整机装配/定制生产等',
|
||||||
|
slogan: '专业的研发智造生产服务,从制版到交付全流程保障',
|
||||||
|
icon: '',
|
||||||
|
serviceContent: SERVICE_CONTENT,
|
||||||
|
serviceTypes: SERVICE_TYPES,
|
||||||
|
video: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const FEATURED_CASES: Case[] = [
|
||||||
|
{
|
||||||
|
id: 'case-001',
|
||||||
|
title: '自动化生产线升级改造项目',
|
||||||
|
category: 'manufacturing',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 50,
|
||||||
|
scale: '300万',
|
||||||
|
industry: '自动化',
|
||||||
|
duration: '150个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
||||||
|
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
||||||
|
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
||||||
|
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case-002',
|
||||||
|
title: '智能工厂物联网整体解决方案',
|
||||||
|
category: 'iot',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 30,
|
||||||
|
scale: '500万',
|
||||||
|
industry: '工厂服务',
|
||||||
|
duration: '200个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
|
||||||
|
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
|
||||||
|
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
|
||||||
|
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case-003',
|
||||||
|
title: '智能穿戴设备PCB设计与量产',
|
||||||
|
category: 'hardware',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 50,
|
||||||
|
scale: '10-50万',
|
||||||
|
industry: '智能设备',
|
||||||
|
duration: '30个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
|
||||||
|
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
|
||||||
|
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
|
||||||
|
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'case-004',
|
||||||
|
title: '智能园区综合管理平台开发',
|
||||||
|
category: 'internet',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 20,
|
||||||
|
scale: '200万',
|
||||||
|
industry: '园区服务',
|
||||||
|
duration: '200个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '随着新型城镇化建设的加速和"数字中国"战略的深入推进,传统园区管理模式正面临严峻挑战,普遍存在"数据孤岛"现象。',
|
||||||
|
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
|
||||||
|
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒。',
|
||||||
|
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 首页数据看板 */
|
||||||
|
export function getHomeStats(): Promise<HomeStats> {
|
||||||
|
return Promise.resolve({ pending: 3, inProgress: 3, total: 6 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务线列表(首页 4 卡片) */
|
||||||
|
export function getBusinessLines(): Promise<BusinessLine[]> {
|
||||||
|
return Promise.resolve(BUSINESS_LINES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务详情(业务详情页) */
|
||||||
|
export function getBusinessDetail(type: BusinessType): Promise<BusinessLine> {
|
||||||
|
const found = BUSINESS_LINES.find((line) => line.id === type)
|
||||||
|
return Promise.resolve(found ?? BUSINESS_LINES[0]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页案例精选(category=all 时返回全部) */
|
||||||
|
export function getFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<Case[]> {
|
||||||
|
const list = category === 'all'
|
||||||
|
? FEATURED_CASES
|
||||||
|
: FEATURED_CASES.filter((item) => item.category === category)
|
||||||
|
return Promise.resolve(list)
|
||||||
|
}
|
||||||
153
src/api/message.ts
Normal file
153
src/api/message.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import type { PageResult } from '@/types/api'
|
||||||
|
import type { Message, MessageQuery } from '@/types/business/message'
|
||||||
|
|
||||||
|
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||||
|
|
||||||
|
/** 消息 mock(可变,已读状态在前端内存中翻转演示) */
|
||||||
|
const MOCK_MESSAGES: Message[] = [
|
||||||
|
{
|
||||||
|
id: 'msg-001',
|
||||||
|
type: '系统通知',
|
||||||
|
title: '【新功能】物联网全案"整机联调"看板正式上线',
|
||||||
|
summary: '整机联调看板已上线,支持联调进度、问题闭环与交付物一览。',
|
||||||
|
content: '尊敬的用户:\n\n为了提升物联网全案项目的交付透明度,平台已正式上线"整机联调"看板功能。\n\n一、功能亮点\n1、联调进度实时同步:设备接入、协议联调、场景验证各阶段进度一目了然;\n2、问题闭环跟踪:联调过程中产生的缺陷与阻塞项统一登记、分派、闭环;\n3、交付物一览:联调报告、测试记录、验收文档集中展示,随时查阅下载。\n\n二、使用方式\n进入「项目中心 → 项目详情 → 整机联调」即可查看对应看板。已有进行中的物联网全案项目将自动迁移历史数据,无需重新配置。\n\n三、温馨提示\n如在使用过程中遇到问题,可通过「在线客服」联系我们,或联系专属项目经理协助处理。\n\n智联智造平台产品团队\n2026年8月',
|
||||||
|
publishTime: '2026-08-06 10:00',
|
||||||
|
isRead: false,
|
||||||
|
attachments: [
|
||||||
|
{ name: '物联网全案服务介绍.pdf', size: 2.5 * 1024 * 1024, uploadTime: '2026-08-06 10:00', url: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-002',
|
||||||
|
type: '系统通知',
|
||||||
|
title: '平台 v2.6 版本更新公告',
|
||||||
|
summary: '新增需求提报模板、案例库对比功能,并优化了消息中心体验。',
|
||||||
|
content: '尊敬的用户:\n\n平台 v2.6 版本已于近日发布,本次更新内容如下:\n\n一、新增功能\n1、需求提报模板:常用需求类型支持保存为模板,一键复用;\n2、案例库对比:最多选择 4 个案例进行横向对比,辅助决策;\n3、消息中心改版:分类标签、关键词搜索、全部已读一站式处理。\n\n二、体验优化\n1、列表页加载性能提升约 40%;\n2、移动端适配细节修复;\n3、附件上传支持 1GB 大文件。\n\n感谢您一直以来的支持,祝使用愉快!',
|
||||||
|
publishTime: '2026-07-15 14:30',
|
||||||
|
isRead: false,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-003',
|
||||||
|
type: '系统通知',
|
||||||
|
title: '关于平台服务时间调整的通知',
|
||||||
|
summary: '自 2026-09-01 起,在线客服服务时间调整为 9:00-21:00。',
|
||||||
|
content: '尊敬的用户:\n\n为进一步提升服务质量,自 2026 年 9 月 1 日起,在线客服服务时间调整如下:\n\n服务时间:每日 9:00 - 21:00(法定节假日照常服务)\n非服务时段提交的留言,我们将在下一个服务时段内尽快回复。\n\n紧急事务可通过专属项目经理电话联系。\n\n感谢您的理解与支持!',
|
||||||
|
publishTime: '2026-08-28 09:00',
|
||||||
|
isRead: true,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-004',
|
||||||
|
type: '状态通知',
|
||||||
|
title: '合同生效通知',
|
||||||
|
summary: '您的合同已正式生效,合同编号 CZ-2026-0815 已开始生效。',
|
||||||
|
content: '您好:\n\n您的合同已正式生效。\n\n合同编号:CZ-2026-0815\n合同名称:智能园区综合管理平台开发合同\n生效日期:2026-08-15\n\n合同已开始生效,可随时在「合同中心」查看详情及履约进度。\n\n如有疑问,请联系您的专属项目经理。',
|
||||||
|
publishTime: '2026-08-15 16:42',
|
||||||
|
isRead: false,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-005',
|
||||||
|
type: '状态通知',
|
||||||
|
title: '项目进度更新:需求确认阶段已完成',
|
||||||
|
summary: '「智能工厂物联网整体解决方案」需求确认已完成,即将进入方案设计。',
|
||||||
|
content: '您好:\n\n您的项目进度已更新:\n\n项目名称:智能工厂物联网整体解决方案\n当前阶段:需求确认 ✅ 已完成\n下一阶段:方案设计\n\n项目组将于 3 个工作日内提交初步方案,届时会再次通知您。您也可以前往「项目中心」查看详细进度与里程碑。\n\n感谢您的配合!',
|
||||||
|
publishTime: '2026-08-20 11:05',
|
||||||
|
isRead: false,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-006',
|
||||||
|
type: '状态通知',
|
||||||
|
title: '账单待办提醒',
|
||||||
|
summary: '您有一笔待付款账单,账单编号 BILL-202608-023,请及时处理。',
|
||||||
|
content: '您好:\n\n您有一笔待付款账单,请及时处理:\n\n账单编号:BILL-202608-023\n关联项目:电商中台系统定制开发\n账单金额:150,000.00 元\n到期日期:2026-09-10\n\n请前往「财务中心 → 账单管理」完成支付。如对账单有疑问,请通过在线客服联系我们。\n\n温馨提示:逾期支付可能影响项目排期,请留意处理时间。',
|
||||||
|
publishTime: '2026-08-25 10:00',
|
||||||
|
isRead: true,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-007',
|
||||||
|
type: '在线客服',
|
||||||
|
title: '您好,请问有什么可以帮您的?',
|
||||||
|
summary: '专属客服小助手已收到您的咨询,将尽快为您答复。',
|
||||||
|
content: '您好,欢迎咨询智联智造平台!\n\n我是您的专属客服小助手,请问有什么可以帮您的?\n\n您可以咨询:\n1、物联网全案开发的流程与周期\n2、项目进度查询\n3、合同与账单相关问题\n4、平台操作指引\n\n我们将在服务时间内尽快回复您。',
|
||||||
|
publishTime: '2026-08-21 10:25',
|
||||||
|
isRead: false,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-008',
|
||||||
|
type: '在线客服',
|
||||||
|
title: '您的咨询已有回复',
|
||||||
|
summary: '关于"整机联调周期"的咨询已回复,点击查看详细内容。',
|
||||||
|
content: '您好:\n\n关于您咨询的"整机联调周期"问题,客服回复如下:\n\n一般物联网全案项目的整机联调周期为 2-4 周,具体取决于设备数量、协议复杂度与场景验证范围。常规智能家居类项目约 2 周,工业场景多设备联调约 3-4 周。\n\n详细排期以项目计划为准,您可在「项目中心」查看最新里程碑。\n\n如还有其他问题,欢迎继续咨询!',
|
||||||
|
publishTime: '2026-08-19 15:40',
|
||||||
|
isRead: true,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-009',
|
||||||
|
type: '在线客服',
|
||||||
|
title: '会话满意度回访',
|
||||||
|
summary: '您上次的会话已结束,欢迎对本次服务进行评价。',
|
||||||
|
content: '您好:\n\n您于 2026-08-18 的会话已结束,感谢您的咨询!\n\n欢迎对本次服务进行评价,您的反馈将帮助我们持续改进。如问题未得到解决,可直接回复本条消息,我们将重新为您跟进。\n\n祝您使用愉快!',
|
||||||
|
publishTime: '2026-08-18 17:00',
|
||||||
|
isRead: false,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function applyFilters(query: MessageQuery): Message[] {
|
||||||
|
return MOCK_MESSAGES.filter((item) => {
|
||||||
|
if (query.type !== 'all' && item.type !== query.type) return false
|
||||||
|
if (query.keyword && !item.title.includes(query.keyword) && !item.summary.includes(query.keyword)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息列表(筛选 + 分页) */
|
||||||
|
export function getMessageList(query: MessageQuery): Promise<PageResult<Message>> {
|
||||||
|
const filtered = applyFilters(query)
|
||||||
|
const start = (query.page - 1) * query.pageSize
|
||||||
|
return Promise.resolve({
|
||||||
|
list: filtered.slice(start, start + query.pageSize),
|
||||||
|
total: filtered.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息详情(不存在返回 null) */
|
||||||
|
export function getMessageDetail(id: string): Promise<Message | null> {
|
||||||
|
const found = MOCK_MESSAGES.find((item) => item.id === id) ?? null
|
||||||
|
return Promise.resolve(found ? { ...found } : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全部标记已读 */
|
||||||
|
export function markAllRead(): Promise<void> {
|
||||||
|
for (const item of MOCK_MESSAGES) {
|
||||||
|
item.isRead = true
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条标记已读 */
|
||||||
|
export function markRead(id: string): Promise<void> {
|
||||||
|
const found = MOCK_MESSAGES.find((item) => item.id === id)
|
||||||
|
if (found) found.isRead = true
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 各分类消息总数(tab 角标) */
|
||||||
|
export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状态通知: number; 系统通知: number }> {
|
||||||
|
const counts = { all: MOCK_MESSAGES.length, 在线客服: 0, 状态通知: 0, 系统通知: 0 }
|
||||||
|
for (const item of MOCK_MESSAGES) {
|
||||||
|
counts[item.type] += 1
|
||||||
|
}
|
||||||
|
return Promise.resolve(counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未读消息总数(头部角标) */
|
||||||
|
export function getUnreadCount(): Promise<number> {
|
||||||
|
return Promise.resolve(MOCK_MESSAGES.filter((item) => !item.isRead).length)
|
||||||
|
}
|
||||||
418
src/api/profile.ts
Normal file
418
src/api/profile.ts
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import type { BusinessType, PageResult } from '@/types/api'
|
||||||
|
import type { Case } from '@/types/business/cases'
|
||||||
|
import type {
|
||||||
|
Address,
|
||||||
|
ChangePasswordParams,
|
||||||
|
Feedback,
|
||||||
|
FavoritesQuery,
|
||||||
|
HelpFaqGroup,
|
||||||
|
ListQuery,
|
||||||
|
Profile,
|
||||||
|
Qualification,
|
||||||
|
QualificationInput,
|
||||||
|
} from '@/types/business/profile'
|
||||||
|
|
||||||
|
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||||
|
|
||||||
|
/** 账号资料 mock(可变,昵称/绑定等操作在前端内存中演示) */
|
||||||
|
const MOCK_PROFILE: Profile = {
|
||||||
|
nickname: '王永志',
|
||||||
|
phone: '186****618',
|
||||||
|
email: 'yongzh******4@163.com',
|
||||||
|
passwordModifiedAt: '15天前',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取账号资料 */
|
||||||
|
export function getProfile(): Promise<Profile> {
|
||||||
|
return Promise.resolve({ ...MOCK_PROFILE })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改昵称 */
|
||||||
|
export function updateNickname(name: string): Promise<Profile> {
|
||||||
|
MOCK_PROFILE.nickname = name
|
||||||
|
return Promise.resolve({ ...MOCK_PROFILE })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改密码(验证码 + 新密码) */
|
||||||
|
export function changePassword(params: ChangePasswordParams): Promise<void> {
|
||||||
|
MOCK_PROFILE.passwordModifiedAt = '刚刚'
|
||||||
|
void params
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定/换绑手机号 */
|
||||||
|
export function bindPhone(phone: string, code: string): Promise<Profile> {
|
||||||
|
void code
|
||||||
|
MOCK_PROFILE.phone = phone
|
||||||
|
return Promise.resolve({ ...MOCK_PROFILE })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定/换绑邮箱 */
|
||||||
|
export function bindEmail(email: string, code: string): Promise<Profile> {
|
||||||
|
void code
|
||||||
|
MOCK_PROFILE.email = email
|
||||||
|
return Promise.resolve({ ...MOCK_PROFILE })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消绑定邮箱 */
|
||||||
|
export function unbindEmail(): Promise<Profile> {
|
||||||
|
MOCK_PROFILE.email = ''
|
||||||
|
return Promise.resolve({ ...MOCK_PROFILE })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注销前安全身份验证 */
|
||||||
|
export function verifyIdentity(phone: string, code: string): Promise<void> {
|
||||||
|
void phone
|
||||||
|
void code
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注销账号 */
|
||||||
|
export function deleteAccount(): Promise<void> {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收货地址 mock(isDefault 混合) */
|
||||||
|
const MOCK_ADDRESSES: Address[] = [
|
||||||
|
{
|
||||||
|
id: 'addr-1',
|
||||||
|
receiverName: '王永志',
|
||||||
|
phone: '18556669516',
|
||||||
|
region: '山东省日照市东港区',
|
||||||
|
detailAddress: '凌云购物广场三楼',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'addr-2',
|
||||||
|
receiverName: '王永志',
|
||||||
|
phone: '18556669516',
|
||||||
|
region: '山东省日照市东港区',
|
||||||
|
detailAddress: '海曲东路 12 号研发中心 A 座 8 层',
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'addr-3',
|
||||||
|
receiverName: '李晓婷',
|
||||||
|
phone: '13863331234',
|
||||||
|
region: '山东省青岛市市南区',
|
||||||
|
detailAddress: '香港中路 76 号 A 座 18 层',
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'addr-4',
|
||||||
|
receiverName: '王永志',
|
||||||
|
phone: '18556669516',
|
||||||
|
region: '山东省济南市历下区',
|
||||||
|
detailAddress: '经十路 9999 号舜耕大厦 12 层',
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function paginate<T>(list: T[], query: ListQuery): PageResult<T> {
|
||||||
|
const start = (query.page - 1) * query.pageSize
|
||||||
|
return {
|
||||||
|
list: list.slice(start, start + query.pageSize),
|
||||||
|
total: list.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收货地址列表(分页) */
|
||||||
|
export function getAddresses(query: ListQuery): Promise<PageResult<Address>> {
|
||||||
|
return Promise.resolve(paginate(MOCK_ADDRESSES, query))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增收货地址 */
|
||||||
|
export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
|
||||||
|
const created: Address = {
|
||||||
|
...input,
|
||||||
|
id: `addr-${Date.now()}`,
|
||||||
|
isDefault: MOCK_ADDRESSES.length === 0,
|
||||||
|
}
|
||||||
|
MOCK_ADDRESSES.push(created)
|
||||||
|
return Promise.resolve({ ...created })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑收货地址 */
|
||||||
|
export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
|
||||||
|
const found = MOCK_ADDRESSES.find((item) => item.id === id)
|
||||||
|
if (!found) return Promise.reject(new Error('地址不存在'))
|
||||||
|
Object.assign(found, input)
|
||||||
|
return Promise.resolve({ ...found })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除收货地址 */
|
||||||
|
export function deleteAddress(id: string): Promise<void> {
|
||||||
|
const index = MOCK_ADDRESSES.findIndex((item) => item.id === id)
|
||||||
|
if (index >= 0) MOCK_ADDRESSES.splice(index, 1)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设为默认地址 */
|
||||||
|
export function setDefaultAddress(id: string): Promise<Address[]> {
|
||||||
|
for (const item of MOCK_ADDRESSES) {
|
||||||
|
item.isDefault = item.id === id
|
||||||
|
}
|
||||||
|
return Promise.resolve(MOCK_ADDRESSES.map((item) => ({ ...item })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我的收藏 mock(4 条已收藏案例,字段与案例库原型一致) */
|
||||||
|
const MOCK_FAVORITES: Case[] = [
|
||||||
|
{
|
||||||
|
id: 'fav-1',
|
||||||
|
title: '自动化生产线升级改造项目',
|
||||||
|
category: 'manufacturing',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 50,
|
||||||
|
scale: '300万',
|
||||||
|
industry: '自动化',
|
||||||
|
duration: '150个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
||||||
|
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
||||||
|
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
||||||
|
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fav-2',
|
||||||
|
title: '智能工厂物联网整体解决方案',
|
||||||
|
category: 'iot',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 30,
|
||||||
|
scale: '500万',
|
||||||
|
industry: '工厂服务',
|
||||||
|
duration: '200个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
|
||||||
|
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
|
||||||
|
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
|
||||||
|
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fav-3',
|
||||||
|
title: '智能穿戴设备PCB设计与量产',
|
||||||
|
category: 'hardware',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 50,
|
||||||
|
scale: '10-50万',
|
||||||
|
industry: '智能设备',
|
||||||
|
duration: '30个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
|
||||||
|
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
|
||||||
|
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
|
||||||
|
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fav-4',
|
||||||
|
title: '智能园区综合管理平台开发',
|
||||||
|
category: 'internet',
|
||||||
|
cover: '',
|
||||||
|
finishDate: '2026/8/10',
|
||||||
|
teamSize: 20,
|
||||||
|
scale: '200万',
|
||||||
|
industry: '园区服务',
|
||||||
|
duration: '200个工作日',
|
||||||
|
clientType: '企业',
|
||||||
|
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。',
|
||||||
|
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
|
||||||
|
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。',
|
||||||
|
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
|
||||||
|
video: '',
|
||||||
|
isFavorite: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 我的收藏(关键字/分类筛选 + 分页) */
|
||||||
|
export function getFavorites(query: FavoritesQuery): Promise<PageResult<Case>> {
|
||||||
|
const filtered = MOCK_FAVORITES.filter((item) => {
|
||||||
|
if (query.category !== 'all' && item.category !== query.category) return false
|
||||||
|
if (query.keyword && !item.title.includes(query.keyword)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const start = (query.page - 1) * query.pageSize
|
||||||
|
return Promise.resolve({
|
||||||
|
list: filtered.slice(start, start + query.pageSize).map((item) => ({ ...item })),
|
||||||
|
total: filtered.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收藏分类计数(分类快捷按钮角标) */
|
||||||
|
export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> {
|
||||||
|
const counts: Partial<Record<BusinessType, number>> = {
|
||||||
|
internet: 1,
|
||||||
|
hardware: 1,
|
||||||
|
iot: 1,
|
||||||
|
manufacturing: 1,
|
||||||
|
}
|
||||||
|
return Promise.resolve(counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资质 mock(状态混合:已认证/审核中/未通过) */
|
||||||
|
const MOCK_QUALIFICATIONS: Qualification[] = [
|
||||||
|
{
|
||||||
|
id: 'qual-1',
|
||||||
|
type: '体系认证',
|
||||||
|
certName: 'ISO9001 质量管理体系认证证书',
|
||||||
|
certNo: 'ISO202609080075',
|
||||||
|
issuer: '中国质量认证中心',
|
||||||
|
validFrom: '2026-9-10',
|
||||||
|
validTo: '2027-9-10',
|
||||||
|
status: '已认证',
|
||||||
|
attachments: [
|
||||||
|
{ name: '体系认证证书', size: 1_048_576, uploadTime: '2026-09-10', url: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qual-2',
|
||||||
|
type: '营业执照',
|
||||||
|
certName: '营业执照',
|
||||||
|
certNo: '91371102MA3XX8876Q',
|
||||||
|
issuer: '日照市东港区市场监督管理局',
|
||||||
|
validFrom: '2020-3-15',
|
||||||
|
validTo: '2030-3-14',
|
||||||
|
status: '审核中',
|
||||||
|
attachments: [
|
||||||
|
{ name: '营业执照', size: 820_000, uploadTime: '2026-09-12', url: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qual-3',
|
||||||
|
type: '检测报告',
|
||||||
|
certName: '智能终端产品第三方检测报告',
|
||||||
|
certNo: 'WT20260512001',
|
||||||
|
issuer: '山东省产品质量检验研究院',
|
||||||
|
validFrom: '2026-5-12',
|
||||||
|
validTo: '2027-5-12',
|
||||||
|
status: '未通过',
|
||||||
|
attachments: [
|
||||||
|
{ name: '检测报告', size: 2_097_152, uploadTime: '2026-05-15', url: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qual-4',
|
||||||
|
type: '生产许可证',
|
||||||
|
certName: '全国工业产品生产许可证',
|
||||||
|
certNo: 'XK06-123-45678',
|
||||||
|
issuer: '山东省市场监督管理局',
|
||||||
|
validFrom: '2024-1-8',
|
||||||
|
validTo: '2029-1-7',
|
||||||
|
status: '已认证',
|
||||||
|
attachments: [
|
||||||
|
{ name: '生产许可证', size: 1_258_291, uploadTime: '2024-01-10', url: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 资质列表(分页) */
|
||||||
|
export function getQualifications(query: ListQuery): Promise<PageResult<Qualification>> {
|
||||||
|
return Promise.resolve(paginate(MOCK_QUALIFICATIONS, query))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增资质(提交后进入审核中) */
|
||||||
|
export function createQualification(input: QualificationInput): Promise<Qualification> {
|
||||||
|
const created: Qualification = {
|
||||||
|
...input,
|
||||||
|
id: `qual-${Date.now()}`,
|
||||||
|
status: '审核中',
|
||||||
|
}
|
||||||
|
MOCK_QUALIFICATIONS.unshift(created)
|
||||||
|
return Promise.resolve({ ...created })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑资质(未通过重新提交后回到审核中) */
|
||||||
|
export function updateQualification(id: string, input: QualificationInput): Promise<Qualification> {
|
||||||
|
const found = MOCK_QUALIFICATIONS.find((item) => item.id === id)
|
||||||
|
if (!found) return Promise.reject(new Error('资质不存在'))
|
||||||
|
found.type = input.type
|
||||||
|
found.certName = input.certName
|
||||||
|
found.certNo = input.certNo
|
||||||
|
found.issuer = input.issuer
|
||||||
|
found.validFrom = input.validFrom
|
||||||
|
found.validTo = input.validTo
|
||||||
|
found.attachments = input.attachments
|
||||||
|
if (found.status === '未通过') found.status = '审核中'
|
||||||
|
return Promise.resolve({ ...found })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除资质 */
|
||||||
|
export function deleteQualification(id: string): Promise<void> {
|
||||||
|
const index = MOCK_QUALIFICATIONS.findIndex((item) => item.id === id)
|
||||||
|
if (index >= 0) MOCK_QUALIFICATIONS.splice(index, 1)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交意见反馈 */
|
||||||
|
export function submitFeedback(feedback: Feedback): Promise<void> {
|
||||||
|
void feedback
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 帮助中心 FAQ */
|
||||||
|
export function getHelpFaqs(): Promise<HelpFaqGroup[]> {
|
||||||
|
return Promise.resolve([
|
||||||
|
{
|
||||||
|
title: '1. 账户与登录',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
q: '如何注册账号?',
|
||||||
|
a: '点击页面右上角"注册",输入手机号并获取验证码,设置 6-20 位登录密码即可完成注册。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: '忘记密码怎么办?',
|
||||||
|
a: '在登录页点击"忘记密码",通过绑定手机号验证码验证身份后即可重新设置密码。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: '一个账号可以多地登录吗?',
|
||||||
|
a: '同一账号同时仅允许在 1 台设备上保持在线,超出将自动踢出最早登录的设备。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: '如何修改绑定手机号?',
|
||||||
|
a: '进入 个人中心 → 账号安全 → 绑定手机,验证原手机号后即可绑定新号码。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '2. 功能使用指南',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
q: '如何提交需求?',
|
||||||
|
a: '进入"发布需求"页面,填写需求描述、预算、周期等信息并提交,专业顾问将在一个工作日内与您联系。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: '如何查看我的消息?',
|
||||||
|
a: '登录后点击右上角消息图标,或进入 个人中心 → 消息通知,即可查看系统通知与顾问消息。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: '如何申请售后?',
|
||||||
|
a: '在"我的订单"中找到对应项目,点击"申请售后"填写问题描述并上传附件,平台将尽快为您处理。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '3. 系统与权限',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
q: '无权限怎么办?',
|
||||||
|
a: '请确认当前账号角色是否具备该模块权限;如仍无法访问,请联系专属客服或平台管理员为您开通。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: '收不到通知怎么办?',
|
||||||
|
a: '请检查浏览器通知权限与消息设置是否开启;若仍未收到,请联系客服核实账号通知配置。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: '支持哪些浏览器?',
|
||||||
|
a: '推荐使用 Chrome、Edge、Firefox、Safari 等主流浏览器的最新版本访问本平台。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
270
src/api/project.ts
Normal file
270
src/api/project.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import type { FileInfo, PageResult } from '@/types/api'
|
||||||
|
import type {
|
||||||
|
AfterSales,
|
||||||
|
AfterSalesPayload,
|
||||||
|
Project,
|
||||||
|
ProjectQuery,
|
||||||
|
} from '@/types/business/project'
|
||||||
|
import { formatDate } from '@/utils/format'
|
||||||
|
|
||||||
|
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||||
|
|
||||||
|
/** 构造 mock 文件信息 */
|
||||||
|
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
|
||||||
|
return { name, size, uploadTime, url: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情 mock 所属项目 id */
|
||||||
|
const DETAIL_ID = 'project-004'
|
||||||
|
|
||||||
|
/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合) */
|
||||||
|
const MOCK_PROJECTS: Project[] = [
|
||||||
|
{
|
||||||
|
id: 'project-001',
|
||||||
|
projectNo: 'P8K2M202408001',
|
||||||
|
projectName: '某大型银行数字化转型方案',
|
||||||
|
businessType: 'internet',
|
||||||
|
progress: 64,
|
||||||
|
status: '待立项',
|
||||||
|
submitTime: '2026-8-17 14:00',
|
||||||
|
milestones: [],
|
||||||
|
team: [],
|
||||||
|
files: [],
|
||||||
|
paymentPlans: [],
|
||||||
|
invoiceRecords: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project-002',
|
||||||
|
projectNo: 'P8K2M202408002',
|
||||||
|
projectName: '智慧城市大数据平台建设项目',
|
||||||
|
businessType: 'iot',
|
||||||
|
progress: 64,
|
||||||
|
status: '待验收',
|
||||||
|
submitTime: '2026-8-17 14:00',
|
||||||
|
milestones: [],
|
||||||
|
team: [],
|
||||||
|
files: [],
|
||||||
|
paymentPlans: [],
|
||||||
|
invoiceRecords: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project-003',
|
||||||
|
projectNo: 'P8K2M202408003',
|
||||||
|
projectName: '三甲医院指挥医疗信息化系统',
|
||||||
|
businessType: 'internet',
|
||||||
|
progress: 64,
|
||||||
|
status: '已完成',
|
||||||
|
submitTime: '2026-8-17 14:00',
|
||||||
|
milestones: [],
|
||||||
|
team: [],
|
||||||
|
files: [],
|
||||||
|
paymentPlans: [],
|
||||||
|
invoiceRecords: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DETAIL_ID,
|
||||||
|
projectNo: '20260813RJ251545',
|
||||||
|
projectName: '社区康养智能服务系统',
|
||||||
|
businessType: 'internet',
|
||||||
|
progress: 64,
|
||||||
|
status: '进行中',
|
||||||
|
submitTime: '2026-8-17 14:00',
|
||||||
|
milestones: [],
|
||||||
|
team: [],
|
||||||
|
files: [],
|
||||||
|
paymentPlans: [],
|
||||||
|
invoiceRecords: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project-005',
|
||||||
|
projectNo: 'P8K2M202408005',
|
||||||
|
projectName: '智能仓储物流分拣系统',
|
||||||
|
businessType: 'iot',
|
||||||
|
progress: 64,
|
||||||
|
status: '售后中',
|
||||||
|
submitTime: '2026-8-17 14:00',
|
||||||
|
milestones: [],
|
||||||
|
team: [],
|
||||||
|
files: [],
|
||||||
|
paymentPlans: [],
|
||||||
|
invoiceRecords: [],
|
||||||
|
afterSales: {
|
||||||
|
status: '处理中',
|
||||||
|
type: 'BUG问题',
|
||||||
|
description: '分拣识别偶发误判,需要排查',
|
||||||
|
remark: '',
|
||||||
|
attachments: [],
|
||||||
|
createTime: '2026-8-19 17:00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'project-006',
|
||||||
|
projectNo: 'P8K2M202408006',
|
||||||
|
projectName: '新能源电站运维监控平台',
|
||||||
|
businessType: 'internet',
|
||||||
|
progress: 64,
|
||||||
|
status: '已取消',
|
||||||
|
submitTime: '2026-8-17 14:00',
|
||||||
|
milestones: [],
|
||||||
|
team: [],
|
||||||
|
files: [],
|
||||||
|
paymentPlans: [],
|
||||||
|
invoiceRecords: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 项目详情 mock(社区康养智能服务系统) */
|
||||||
|
const PROJECT_DETAIL: Project = {
|
||||||
|
...MOCK_PROJECTS[3],
|
||||||
|
progress: 65,
|
||||||
|
startTime: '2026-8-13 11:04:12',
|
||||||
|
endTime: '2026-10-30 18:00:00',
|
||||||
|
milestones: [
|
||||||
|
{ name: '需求确认', status: '已完成', time: '2026-8-13 11:04:12' },
|
||||||
|
{ name: '原型设计', status: '已完成', time: '2026-8-13 11:04:12' },
|
||||||
|
{ name: '开发实现', status: '进行中' },
|
||||||
|
{ name: '测试验收', status: '待开始' },
|
||||||
|
{ name: '项目交付', status: '待开始' },
|
||||||
|
],
|
||||||
|
team: [
|
||||||
|
{ role: '项目经理', name: '张三', avatarChar: '张' },
|
||||||
|
{ role: '项目经理', name: '张三', avatarChar: '张' },
|
||||||
|
],
|
||||||
|
files: [
|
||||||
|
mockFile('项目需求规格说明书.pdf', 2516582, '2026-08-19 17:00'),
|
||||||
|
mockFile('项目需求说明书.word', 1887437, '2026-08-19 17:00'),
|
||||||
|
],
|
||||||
|
paymentPlans: [
|
||||||
|
{
|
||||||
|
id: 'bill-001',
|
||||||
|
planName: '第一期·预付款(比例33%)',
|
||||||
|
billNo: '20260813ZZ251245',
|
||||||
|
amount: 20100,
|
||||||
|
status: '已支付',
|
||||||
|
tradeNo: 'T20260819153035678K2M001',
|
||||||
|
projectName: '社区康养智能服务系统',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bill-002',
|
||||||
|
planName: '第二期·付款(比例33%)',
|
||||||
|
billNo: '20260813ZZ251245',
|
||||||
|
amount: 20100,
|
||||||
|
status: '待支付',
|
||||||
|
projectName: '社区康养智能服务系统',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bill-003',
|
||||||
|
planName: '第三期·尾款(比例33%)',
|
||||||
|
billNo: '20260813ZZ251245',
|
||||||
|
amount: 20100,
|
||||||
|
status: '未到期',
|
||||||
|
projectName: '社区康养智能服务系统',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invoiceRecords: [
|
||||||
|
mockFile('增值税专用发票.pdf', 325120, '2026-08-19 17:00'),
|
||||||
|
mockFile('电子回单.jpg', 186368, '2026-08-19 17:00'),
|
||||||
|
],
|
||||||
|
contractFile: mockFile('社区康养智能服务系统开发合同.pdf', 1039360, '2026-08-19 17:00'),
|
||||||
|
afterSales: {
|
||||||
|
status: '处理中',
|
||||||
|
type: 'BUG问题',
|
||||||
|
description: '具体请看清单',
|
||||||
|
remark: '',
|
||||||
|
attachments: [],
|
||||||
|
createTime: '2026-8-19 17:00',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交时间快捷区间匹配 */
|
||||||
|
function matchTimeShortcut(submitTime: string, shortcut: ProjectQuery['timeShortcut']): boolean {
|
||||||
|
if (shortcut === 'all') return true
|
||||||
|
// mock 时间为 '2026-8-17 14:00',替换为 / 保证各环境可解析
|
||||||
|
const time = new Date(submitTime.replaceAll('-', '/')).getTime()
|
||||||
|
const start = new Date()
|
||||||
|
if (shortcut === '3m') start.setMonth(start.getMonth() - 3)
|
||||||
|
if (shortcut === '6m') start.setMonth(start.getMonth() - 6)
|
||||||
|
if (shortcut === '1y') start.setFullYear(start.getFullYear() - 1)
|
||||||
|
return time >= start.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目列表(筛选 + 分页,pageSize 固定 8) */
|
||||||
|
export function getProjectList(query: ProjectQuery): Promise<PageResult<Project>> {
|
||||||
|
const filtered = MOCK_PROJECTS.filter((item) => {
|
||||||
|
if (
|
||||||
|
query.keyword
|
||||||
|
&& !item.projectName.includes(query.keyword)
|
||||||
|
&& !item.projectNo.includes(query.keyword)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (query.businessType !== 'all' && item.businessType !== query.businessType) return false
|
||||||
|
if (query.status !== 'all' && item.status !== query.status) return false
|
||||||
|
if (!matchTimeShortcut(item.submitTime, query.timeShortcut)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const pageSize = query.pageSize > 0 ? query.pageSize : 8
|
||||||
|
const start = (query.page - 1) * pageSize
|
||||||
|
return Promise.resolve({
|
||||||
|
list: filtered.slice(start, start + pageSize),
|
||||||
|
total: filtered.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目详情(不存在返回 null;仅详情项目返回完整数据) */
|
||||||
|
export function getProjectDetail(id: string): Promise<Project | null> {
|
||||||
|
if (id === DETAIL_ID) return Promise.resolve({ ...PROJECT_DETAIL })
|
||||||
|
const found = MOCK_PROJECTS.find((item) => item.id === id) ?? null
|
||||||
|
return Promise.resolve(found ? { ...found } : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传项目文件(追加进详情文件列表) */
|
||||||
|
export function uploadProjectFile(file: File): Promise<FileInfo> {
|
||||||
|
const info: FileInfo = {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
uploadTime: formatDate(new Date()),
|
||||||
|
url: '',
|
||||||
|
}
|
||||||
|
PROJECT_DETAIL.files.push(info)
|
||||||
|
return Promise.resolve({ ...info })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除项目文件(按索引从详情文件列表移除) */
|
||||||
|
export function deleteProjectFile(index: number): Promise<void> {
|
||||||
|
if (index >= 0 && index < PROJECT_DETAIL.files.length) {
|
||||||
|
PROJECT_DETAIL.files.splice(index, 1)
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交售后工单 */
|
||||||
|
export function submitAfterSales(
|
||||||
|
projectId: string,
|
||||||
|
payload: AfterSalesPayload,
|
||||||
|
): Promise<AfterSales> {
|
||||||
|
const result: AfterSales = {
|
||||||
|
status: '处理中',
|
||||||
|
type: payload.type,
|
||||||
|
description: payload.description,
|
||||||
|
remark: payload.remark,
|
||||||
|
attachments: [...payload.attachments],
|
||||||
|
createTime: formatDate(new Date()),
|
||||||
|
}
|
||||||
|
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
|
||||||
|
return Promise.resolve({ ...result, attachments: [...result.attachments] })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消售后工单 */
|
||||||
|
export function cancelAfterSales(projectId: string): Promise<AfterSales> {
|
||||||
|
const result: AfterSales = {
|
||||||
|
status: '无',
|
||||||
|
type: '',
|
||||||
|
description: '',
|
||||||
|
remark: '',
|
||||||
|
attachments: [],
|
||||||
|
createTime: '',
|
||||||
|
}
|
||||||
|
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
|
||||||
|
return Promise.resolve({ ...result })
|
||||||
|
}
|
||||||
72
src/api/service.ts
Normal file
72
src/api/service.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ChatMessage } from '@/types/business/service'
|
||||||
|
|
||||||
|
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
|
function nextId(prefix: string): string {
|
||||||
|
seq += 1
|
||||||
|
return `${prefix}-${Date.now()}-${seq}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowTime(): string {
|
||||||
|
const date = new Date()
|
||||||
|
const pad = (n: number): string => (n < 10 ? `0${n}` : String(n))
|
||||||
|
return `今天 ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 历史会话(欢迎语 + 用户提问 + 文件资料) */
|
||||||
|
export function getChatHistory(): Promise<ChatMessage[]> {
|
||||||
|
return Promise.resolve([
|
||||||
|
{
|
||||||
|
id: 'chat-001',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '您好,欢迎咨询智联智造平台!我是您的专属客服小助手,请问有什么可以帮您的?',
|
||||||
|
time: '今天 10:23',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chat-002',
|
||||||
|
role: 'user',
|
||||||
|
content: '我想咨询一下物联网全案开发的流程和周期',
|
||||||
|
time: '今天 10:23',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chat-003',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
file: { name: '物联网全案服务介绍.pdf', size: 2.5 * 1024 * 1024 },
|
||||||
|
time: '今天 10:24',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送文本消息(echo 用户消息,携带当前时间) */
|
||||||
|
export function sendChatMessage(content: string): Promise<ChatMessage> {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: nextId('chat'),
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
time: nowTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传聊天文件(生成文件卡片消息) */
|
||||||
|
export function uploadChatFile(file: File): Promise<ChatMessage> {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: nextId('chat'),
|
||||||
|
role: 'user',
|
||||||
|
content: '',
|
||||||
|
file: { name: file.name, size: file.size },
|
||||||
|
time: nowTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客服自动回复 */
|
||||||
|
export function mockAutoReply(): Promise<ChatMessage> {
|
||||||
|
return Promise.resolve({
|
||||||
|
id: nextId('chat'),
|
||||||
|
role: 'assistant',
|
||||||
|
content: '收到您的消息,专属客服稍后为您答复~',
|
||||||
|
time: nowTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
62
src/api/web/home.ts
Normal file
62
src/api/web/home.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// 用户web端首页接口(真实后端绑定)
|
||||||
|
// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
|
||||||
|
// 前缀: /web/home-page 鉴权: 公开接口,无需 token
|
||||||
|
//
|
||||||
|
// 使用约定(与项目 SKILL.md 一致):
|
||||||
|
// 页面/组件禁止直接 import axios,一律经 store 调用本模块的 request<T>。
|
||||||
|
// 本文件只放「函数签名 + 请求路径」,不写业务逻辑。
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import type {
|
||||||
|
ProjectStatusCount,
|
||||||
|
ProjectSubmissionVo,
|
||||||
|
BannerVo,
|
||||||
|
AgreementFormVo,
|
||||||
|
} from '@/types/web/home'
|
||||||
|
|
||||||
|
/** 项目状态统计 */
|
||||||
|
export function getProjectStatusCount(): Promise<ProjectStatusCount> {
|
||||||
|
return request<ProjectStatusCount>({
|
||||||
|
url: '/web/home-page/project-status-count',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务服务类型列表 */
|
||||||
|
export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
|
||||||
|
return request<ProjectSubmissionVo[]>({
|
||||||
|
url: '/web/home-page/service-type-list',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务类型详情 */
|
||||||
|
export function getServiceTypeDetail(id: number | string): Promise<ProjectSubmissionVo> {
|
||||||
|
return request<ProjectSubmissionVo>({
|
||||||
|
url: `/web/home-page/service-type-detail/${id}`,
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮播图列表 */
|
||||||
|
export function getCarouselList(): Promise<BannerVo[]> {
|
||||||
|
return request<BannerVo[]>({
|
||||||
|
url: '/web/home-page/carousel-list',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台介绍(富文本 HTML) */
|
||||||
|
export function getPlatformIntro(): Promise<AgreementFormVo> {
|
||||||
|
return request<AgreementFormVo>({
|
||||||
|
url: '/web/home-page/platform-intro',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 联系信息(富文本 HTML) */
|
||||||
|
export function getContactInfo(): Promise<AgreementFormVo> {
|
||||||
|
return request<AgreementFormVo>({
|
||||||
|
url: '/web/home-page/contact-info',
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
103
src/components/AppButton.vue
Normal file
103
src/components/AppButton.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
type?: 'primary' | 'default' | 'text'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
loading?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'default',
|
||||||
|
size: 'md',
|
||||||
|
loading: false,
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ click: [e: MouseEvent] }>()
|
||||||
|
|
||||||
|
function onClick(e: MouseEvent): void {
|
||||||
|
if (props.disabled || props.loading) return
|
||||||
|
emit('click', e)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="app-button"
|
||||||
|
:class="[`app-button--${type}`, `app-button--${size}`, { 'is-loading': loading, 'is-disabled': disabled }]"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="app-button__loading">…</span>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.app-button {
|
||||||
|
.flex(row, center);
|
||||||
|
gap: @spacing-xs;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&--sm {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 @spacing-sm;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--md {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--lg {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 @spacing-lg;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: @primary-color;
|
||||||
|
color: @white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: @primary-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--default {
|
||||||
|
border: 1px solid @border-color-dark;
|
||||||
|
background: @white;
|
||||||
|
color: @text-color;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @primary-color;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--text {
|
||||||
|
color: @primary-color;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @primary-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
146
src/components/AppFooter.vue
Normal file
146
src/components/AppFooter.vue
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useSiteStore } from '@/stores/modules/site'
|
||||||
|
|
||||||
|
const siteStore = useSiteStore()
|
||||||
|
|
||||||
|
const quickLinks = [
|
||||||
|
{ label: '首页', path: '/' },
|
||||||
|
{ label: '案例中心', path: '/cases' },
|
||||||
|
{ label: '需求提报', path: '/demand' },
|
||||||
|
{ label: '下载 APP', path: '/' },
|
||||||
|
{ label: '帮助中心', path: '/profile/help' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const coreBusiness = [
|
||||||
|
'互联网软件开发',
|
||||||
|
'硬件电路开发',
|
||||||
|
'物联网软硬一体化全案',
|
||||||
|
'PCB 制版 & SMT 贴片生产',
|
||||||
|
'元器件集采与整机装配',
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="app-footer__inner container">
|
||||||
|
<div class="app-footer__about">
|
||||||
|
<div class="app-footer__brand">
|
||||||
|
<!-- TODO(蓝湖): 替换 logo 切图 -->
|
||||||
|
<img src="@/assets/vue.svg" class="app-footer__logo" alt="虫子智造 logo">
|
||||||
|
<span class="app-footer__name">虫子智造</span>
|
||||||
|
</div>
|
||||||
|
<p class="app-footer__desc">
|
||||||
|
研发智造一体化自营平台,打通研发‑仿真‑设计‑PCB‑SMT‑整机装配‑售后迭代全链路,为科技企业、硬件厂商、创客团队提供软硬一体化定制与量产落地服务。平台自营不开放第三方服务商入驻,对项目质量全权兜底。
|
||||||
|
</p>
|
||||||
|
<p class="app-footer__legal">
|
||||||
|
©2026 虫子智造 研发智造一体化平台 版权所有|自营研发生产平台,所有项目源码、图纸受平台知识产权保护,禁止盗用、复制、二次分发
|
||||||
|
</p>
|
||||||
|
<p class="app-footer__legal">
|
||||||
|
{{ siteStore.siteInfo.icp }} | {{ siteStore.siteInfo.police }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-footer__col">
|
||||||
|
<h4 class="app-footer__title">
|
||||||
|
快速导航
|
||||||
|
</h4>
|
||||||
|
<RouterLink
|
||||||
|
v-for="link in quickLinks"
|
||||||
|
:key="link.label"
|
||||||
|
:to="link.path"
|
||||||
|
class="app-footer__link"
|
||||||
|
>
|
||||||
|
{{ link.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-footer__col">
|
||||||
|
<h4 class="app-footer__title">
|
||||||
|
核心业务
|
||||||
|
</h4>
|
||||||
|
<span
|
||||||
|
v-for="item in coreBusiness"
|
||||||
|
:key="item"
|
||||||
|
class="app-footer__link"
|
||||||
|
>{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-footer__col">
|
||||||
|
<h4 class="app-footer__title">
|
||||||
|
联系我们
|
||||||
|
</h4>
|
||||||
|
<span class="app-footer__contact">商务合作:{{ siteStore.siteInfo.businessEmail }}</span>
|
||||||
|
<span class="app-footer__contact">技术支持:{{ siteStore.siteInfo.supportEmail }}</span>
|
||||||
|
<span class="app-footer__contact">客服热线:{{ siteStore.siteInfo.hotline }}</span>
|
||||||
|
<span class="app-footer__contact">工作时间:{{ siteStore.siteInfo.workTime }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.app-footer {
|
||||||
|
background: @heading-color;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
|
||||||
|
&__inner {
|
||||||
|
.flex(row, space-between, flex-start);
|
||||||
|
gap: @spacing-xl;
|
||||||
|
padding-top: @spacing-xxl;
|
||||||
|
padding-bottom: @spacing-xxl;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__about {
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__brand {
|
||||||
|
.flex(row, flex-start);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
margin-bottom: @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__logo {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc,
|
||||||
|
&__legal {
|
||||||
|
margin-top: @spacing-sm;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__col {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin-bottom: @spacing-sm;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__link {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__contact {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
138
src/components/AppHeader.vue
Normal file
138
src/components/AppHeader.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useMessageStore } from '@/stores/modules/message'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ label: '首页', path: '/' },
|
||||||
|
{ label: '案例库', path: '/cases' },
|
||||||
|
{ label: '需求提报', path: '/demand' },
|
||||||
|
{ label: '项目中心', path: '/project' },
|
||||||
|
{ label: '合同中心', path: '/contract' },
|
||||||
|
{ label: '财务中心', path: '/finance' },
|
||||||
|
{ label: '个人中心', path: '/profile' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const messageStore = useMessageStore()
|
||||||
|
const { unreadCount } = storeToRefs(messageStore)
|
||||||
|
|
||||||
|
onMounted(() => void messageStore.fetchUnreadCount())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="app-header__inner container">
|
||||||
|
<RouterLink to="/" class="app-header__logo">
|
||||||
|
<!-- TODO(蓝湖): 替换 logo 切图 -->
|
||||||
|
<img src="@/assets/vue.svg" class="app-header__logo-icon" alt="虫子智造 logo">
|
||||||
|
<span class="app-header__logo-text">虫子智造</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<nav class="app-header__nav">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="app-header__nav-item"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="app-header__actions">
|
||||||
|
<RouterLink to="/message" class="app-header__bell" aria-label="消息中心">
|
||||||
|
<span class="app-header__bell-icon">🔔</span>
|
||||||
|
<span v-if="unreadCount > 0" class="app-header__badge">{{ unreadCount }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/login" class="app-header__login link">
|
||||||
|
登录
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.app-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: @header-height;
|
||||||
|
background: @white;
|
||||||
|
box-shadow: @shadow-base;
|
||||||
|
|
||||||
|
&__inner {
|
||||||
|
.flex(row, space-between);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__logo {
|
||||||
|
.flex(row, flex-start);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
font-size: @font-size-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nav {
|
||||||
|
.flex(row, center);
|
||||||
|
gap: @spacing-lg;
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
font-size: @font-size-base;
|
||||||
|
color: @text-color;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.router-link-active {
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
.flex(row, flex-end);
|
||||||
|
gap: @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bell {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -8px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @danger-color;
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__login {
|
||||||
|
font-size: @font-size-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
125
src/components/CaseCard.vue
Normal file
125
src/components/CaseCard.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { formatBusinessType } from '@/utils/format'
|
||||||
|
import type { Case } from '@/types/business/cases'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: Case
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ view: [id: string] }>()
|
||||||
|
|
||||||
|
function onView(id: string): void {
|
||||||
|
emit('view', id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="case-card" @click="onView(item.id)">
|
||||||
|
<div class="case-card__cover">
|
||||||
|
<!-- TODO(蓝湖): 替换案例封面切图,接入 v-lazy -->
|
||||||
|
<img v-if="item.cover" v-lazy="item.cover" class="case-card__img" :alt="item.title">
|
||||||
|
<div v-else class="case-card__img case-card__img--placeholder" />
|
||||||
|
<span class="case-card__tag">{{ formatBusinessType(item.category) }}</span>
|
||||||
|
<span class="case-card__more">查看详情 ></span>
|
||||||
|
</div>
|
||||||
|
<div class="case-card__body">
|
||||||
|
<h3 class="case-card__title">
|
||||||
|
{{ item.title }}
|
||||||
|
</h3>
|
||||||
|
<ul class="case-card__meta">
|
||||||
|
<li>完成时间:{{ item.finishDate }}</li>
|
||||||
|
<li>服务团队:{{ item.teamSize }}人</li>
|
||||||
|
<li>规模:{{ item.scale }}</li>
|
||||||
|
<li>所属行业:{{ item.industry }}</li>
|
||||||
|
<li>项目周期:{{ item.duration }}</li>
|
||||||
|
<li>客户类型:{{ item.clientType }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.case-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: @radius-lg;
|
||||||
|
background: @white;
|
||||||
|
box-shadow: @shadow-base;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.2s, transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: @shadow-lg;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
|
||||||
|
.case-card__more {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cover {
|
||||||
|
position: relative;
|
||||||
|
height: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
&--placeholder {
|
||||||
|
background: linear-gradient(135deg, @primary-light, @bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tag {
|
||||||
|
position: absolute;
|
||||||
|
top: @spacing-sm;
|
||||||
|
left: @spacing-sm;
|
||||||
|
padding: 2px @spacing-sm;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__more {
|
||||||
|
position: absolute;
|
||||||
|
right: @spacing-sm;
|
||||||
|
bottom: @spacing-sm;
|
||||||
|
padding: 2px @spacing-sm;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @primary-color;
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin-bottom: @spacing-sm;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
.multi-ellipsis(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px @spacing-sm;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
|
||||||
|
li {
|
||||||
|
.ellipsis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
132
src/components/ConfirmDialog.vue
Normal file
132
src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
danger?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
title: '提示',
|
||||||
|
message: '',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消',
|
||||||
|
danger: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', { default: false })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ confirm: []; cancel: [] }>()
|
||||||
|
|
||||||
|
function onConfirm(): void {
|
||||||
|
emit('confirm')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel(): void {
|
||||||
|
emit('cancel')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="confirm-dialog" @click.self="onCancel">
|
||||||
|
<div class="confirm-dialog__panel">
|
||||||
|
<h3 class="confirm-dialog__title">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<div class="confirm-dialog__body">
|
||||||
|
<slot>{{ message }}</slot>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-dialog__footer">
|
||||||
|
<button class="confirm-dialog__btn" @click="onCancel">
|
||||||
|
{{ cancelText }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="confirm-dialog__btn confirm-dialog__btn--confirm"
|
||||||
|
:class="{ 'is-danger': danger }"
|
||||||
|
@click="onConfirm"
|
||||||
|
>
|
||||||
|
{{ confirmText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.confirm-dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
.flex(row, center);
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
|
||||||
|
&__panel {
|
||||||
|
width: 400px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
padding: @spacing-lg;
|
||||||
|
border-radius: @radius-lg;
|
||||||
|
background: @white;
|
||||||
|
box-shadow: @shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin-bottom: @spacing-base;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
margin-bottom: @spacing-lg;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
color: @text-color;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
.flex(row, flex-end);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 @spacing-base;
|
||||||
|
border: 1px solid @border-color-dark;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @white;
|
||||||
|
color: @text-color;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @primary-color;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirm {
|
||||||
|
border-color: @primary-color;
|
||||||
|
background: @primary-color;
|
||||||
|
color: @white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: @primary-hover;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-danger {
|
||||||
|
border-color: @danger-color;
|
||||||
|
background: @danger-color;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: fade(@danger-color, 85%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
src/components/FileList.vue
Normal file
93
src/components/FileList.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FileInfo } from '@/types/api'
|
||||||
|
import { formatFileSize } from '@/utils/format'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files: FileInfo[]
|
||||||
|
showActions?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), { showActions: true })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
preview: [file: FileInfo]
|
||||||
|
download: [file: FileInfo]
|
||||||
|
delete: [file: FileInfo]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul v-if="files.length > 0" class="file-list">
|
||||||
|
<li v-for="(file, index) in files" :key="`${file.name}-${index}`" class="file-list__item">
|
||||||
|
<div class="file-list__info">
|
||||||
|
<span class="file-list__name" :title="file.name">{{ file.name }}</span>
|
||||||
|
<span class="file-list__meta text-secondary">
|
||||||
|
{{ formatFileSize(file.size) }} · 上传于{{ file.uploadTime }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="showActions" class="file-list__actions">
|
||||||
|
<button class="file-list__btn" @click="emit('preview', file)">
|
||||||
|
预览
|
||||||
|
</button>
|
||||||
|
<button class="file-list__btn" @click="emit('download', file)">
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
<button class="file-list__btn file-list__btn--danger" @click="emit('delete', file)">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.file-list {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
.flex(row, space-between);
|
||||||
|
gap: @spacing-base;
|
||||||
|
padding: @spacing-sm @spacing-base;
|
||||||
|
border: 1px solid @border-color;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @bg-color-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: @font-size-base;
|
||||||
|
color: @heading-color;
|
||||||
|
.ellipsis();
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
.flex(row, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @primary-color;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
color: @danger-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
162
src/components/FilterPanel.vue
Normal file
162
src/components/FilterPanel.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
export interface FilterChip {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chips?: FilterChip[]
|
||||||
|
keywordPlaceholder?: string
|
||||||
|
searchLabel?: string
|
||||||
|
showResultCount?: boolean
|
||||||
|
resultCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
chips: () => [],
|
||||||
|
keywordPlaceholder: '关键字搜索',
|
||||||
|
searchLabel: '搜索',
|
||||||
|
showResultCount: false,
|
||||||
|
resultCount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyword = defineModel<string>('keyword', { default: '' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
search: []
|
||||||
|
reset: []
|
||||||
|
removeChip: [key: string]
|
||||||
|
clearChips: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="filter-panel">
|
||||||
|
<div class="filter-panel__bar">
|
||||||
|
<input
|
||||||
|
v-model="keyword"
|
||||||
|
class="filter-panel__input"
|
||||||
|
:placeholder="keywordPlaceholder"
|
||||||
|
@keyup.enter="emit('search')"
|
||||||
|
>
|
||||||
|
<button class="filter-panel__btn filter-panel__btn--primary" @click="emit('search')">
|
||||||
|
{{ searchLabel }}<template v-if="showResultCount">({{ resultCount }}个结果)</template>
|
||||||
|
</button>
|
||||||
|
<button class="filter-panel__btn" @click="emit('reset')">
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="chips.length > 0" class="filter-panel__chips">
|
||||||
|
<span class="filter-panel__chips-label text-secondary">已选条件({{ chips.length }}个)</span>
|
||||||
|
<button
|
||||||
|
v-for="chip in chips"
|
||||||
|
:key="chip.key"
|
||||||
|
class="filter-panel__chip"
|
||||||
|
:title="`移除 ${chip.label}`"
|
||||||
|
@click="emit('removeChip', chip.key)"
|
||||||
|
>
|
||||||
|
{{ chip.label }} ×
|
||||||
|
</button>
|
||||||
|
<button class="filter-panel__chip filter-panel__chip--clear" @click="emit('clearChips')">
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-panel__body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.filter-panel {
|
||||||
|
padding: @spacing-base @spacing-lg;
|
||||||
|
border-radius: @radius-lg;
|
||||||
|
background: @white;
|
||||||
|
box-shadow: @shadow-base;
|
||||||
|
|
||||||
|
&__bar {
|
||||||
|
.flex(row, flex-start);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
width: 240px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 @spacing-base;
|
||||||
|
border: 1px solid @border-color;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @bg-color-light;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: @primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 @spacing-base;
|
||||||
|
border: 1px solid @border-color-dark;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @white;
|
||||||
|
color: @text-color;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @primary-color;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
border-color: @primary-color;
|
||||||
|
background: @primary-color;
|
||||||
|
color: @white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: @primary-hover;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chips {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: @spacing-sm;
|
||||||
|
margin-top: @spacing-base;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chip {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 @spacing-sm;
|
||||||
|
border: 1px solid @primary-light;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @primary-light;
|
||||||
|
color: @primary-color;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--clear {
|
||||||
|
border-color: @border-color;
|
||||||
|
background: @white;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
margin-top: @spacing-base;
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: @spacing-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
108
src/components/PaginationBar.vue
Normal file
108
src/components/PaginationBar.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ pageChange: [page: number] }>()
|
||||||
|
|
||||||
|
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||||
|
|
||||||
|
/** 页码序列:≤7 全部展示,否则首尾 + 当前页窗口(… 省略) */
|
||||||
|
const pages = computed<Array<number | '...'>>(() => {
|
||||||
|
const n = pageCount.value
|
||||||
|
const { page } = props
|
||||||
|
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
|
||||||
|
const window: Array<number | '...'> = []
|
||||||
|
for (let p = page - 1; p <= page + 1; p++) {
|
||||||
|
if (p >= 1 && p <= n) window.push(p)
|
||||||
|
}
|
||||||
|
if (page > 3) window.unshift('...')
|
||||||
|
if (page < n - 2) window.push('...')
|
||||||
|
return [1, ...window.filter((item) => item !== 1 && item !== n), n].filter(
|
||||||
|
(item, index, arr) => arr.indexOf(item) === index,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function go(page: number): void {
|
||||||
|
if (page < 1 || page > pageCount.value || page === props.page) return
|
||||||
|
emit('pageChange', page)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="pagination" aria-label="分页">
|
||||||
|
<button
|
||||||
|
class="pagination__btn"
|
||||||
|
:disabled="page <= 1"
|
||||||
|
@click="go(page - 1)"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in pages"
|
||||||
|
:key="`${item}-${index}`"
|
||||||
|
class="pagination__btn"
|
||||||
|
:class="{ 'is-active': item === page, 'is-ellipsis': item === '...' }"
|
||||||
|
:disabled="item === '...'"
|
||||||
|
@click="go(item as number)"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="pagination__btn"
|
||||||
|
:disabled="page >= pageCount"
|
||||||
|
@click="go(page + 1)"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.pagination {
|
||||||
|
.flex(row, center);
|
||||||
|
gap: @spacing-xs;
|
||||||
|
padding: @spacing-base 0;
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
.flex(row, center);
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 @spacing-xs;
|
||||||
|
border: 1px solid @border-color;
|
||||||
|
border-radius: @radius-sm;
|
||||||
|
background: @white;
|
||||||
|
color: @text-color;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled):not(.is-ellipsis) {
|
||||||
|
border-color: @primary-color;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
border-color: @primary-color;
|
||||||
|
background: @primary-color;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-ellipsis {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled:not(.is-ellipsis) {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
src/components/PasswordStrength.vue
Normal file
88
src/components/PasswordStrength.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { getPasswordLevel } from '@/utils/validate'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const level = computed(() => getPasswordLevel(props.password))
|
||||||
|
|
||||||
|
const levelText = computed(() => {
|
||||||
|
if (!props.password) return ''
|
||||||
|
return level.value === 'strong' ? '强' : level.value === 'medium' ? '中' : '弱'
|
||||||
|
})
|
||||||
|
|
||||||
|
const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value === 'medium' ? 2 : 1))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="password" class="pwd-strength">
|
||||||
|
<div class="pwd-strength__bars">
|
||||||
|
<span
|
||||||
|
v-for="i in 3"
|
||||||
|
:key="i"
|
||||||
|
class="pwd-strength__bar"
|
||||||
|
:class="{ 'is-on': i <= levelIndex, [`is-${level}`]: i <= levelIndex }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="pwd-strength__label" :class="`is-${level}`">密码强度:{{ levelText }}</span>
|
||||||
|
<p v-if="level === 'weak'" class="pwd-strength__tip">
|
||||||
|
密码等级过低,请重新输入(建议字母+数字+符号组合)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.pwd-strength {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: @spacing-xs;
|
||||||
|
|
||||||
|
&__bars {
|
||||||
|
.flex(row, center);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bar {
|
||||||
|
width: 56px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @border-color;
|
||||||
|
|
||||||
|
&.is-on.is-weak {
|
||||||
|
background: @danger-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-on.is-medium {
|
||||||
|
background: @warning-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-on.is-strong {
|
||||||
|
background: @success-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
|
||||||
|
&.is-weak {
|
||||||
|
color: @danger-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-medium {
|
||||||
|
color: @warning-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-strong {
|
||||||
|
color: @success-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tip {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @danger-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
114
src/components/QrCodePanel.vue
Normal file
114
src/components/QrCodePanel.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useCountDown } from '@/composables/useCountDown'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 二维码有效期(秒),默认 4:32 */
|
||||||
|
expiresIn?: number
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
expiresIn: 272,
|
||||||
|
size: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ refresh: [] }>()
|
||||||
|
|
||||||
|
const { seconds, start } = useCountDown(props.expiresIn)
|
||||||
|
const started = ref(false)
|
||||||
|
|
||||||
|
const expired = computed(() => started.value && seconds.value <= 0)
|
||||||
|
|
||||||
|
const countdownText = computed(() => {
|
||||||
|
const m = Math.floor(seconds.value / 60)
|
||||||
|
const s = seconds.value % 60
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
start(props.expiresIn)
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
start(props.expiresIn)
|
||||||
|
started.value = true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="qr-panel">
|
||||||
|
<div
|
||||||
|
class="qr-panel__box"
|
||||||
|
:class="{ 'is-expired': expired }"
|
||||||
|
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
<!-- TODO(api): 二维码内容由支付/签署接口返回后渲染 -->
|
||||||
|
<template v-if="!expired">
|
||||||
|
<span class="qr-panel__icon">▦</span>
|
||||||
|
<span class="qr-panel__tip">扫码支付/签署</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="qr-panel__icon">⟳</span>
|
||||||
|
<span class="qr-panel__tip">二维码已过期<br>点击刷新</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p class="qr-panel__status text-secondary">
|
||||||
|
<template v-if="!expired">
|
||||||
|
二维码将在 <strong class="qr-panel__count">{{ countdownText }}</strong> 后失效(过期可点击二维码刷新)
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
二维码已过期,请点击刷新
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.qr-panel {
|
||||||
|
.flex(column, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
|
||||||
|
&__box {
|
||||||
|
.flex(column, center);
|
||||||
|
gap: @spacing-xs;
|
||||||
|
border: 1px solid @border-color;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-expired {
|
||||||
|
background: @bg-color;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 56px;
|
||||||
|
color: @heading-color;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tip {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
color: @danger-color;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
src/components/SmsCodeInput.vue
Normal file
79
src/components/SmsCodeInput.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useCountDown } from '@/composables/useCountDown'
|
||||||
|
|
||||||
|
const code = defineModel<string>('code', { default: '' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ getCode: [] }>()
|
||||||
|
|
||||||
|
const { seconds, isCounting, start } = useCountDown(60)
|
||||||
|
|
||||||
|
function onGetCode(): void {
|
||||||
|
if (isCounting.value) return
|
||||||
|
start(60)
|
||||||
|
emit('getCode')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sms-code">
|
||||||
|
<input
|
||||||
|
v-model="code"
|
||||||
|
class="sms-code__input"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="sms-code__btn"
|
||||||
|
:disabled="isCounting"
|
||||||
|
@click="onGetCode"
|
||||||
|
>
|
||||||
|
{{ isCounting ? `${seconds}秒后重新获取` : '获取验证码' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.sms-code {
|
||||||
|
.flex(row, stretch);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 @spacing-base;
|
||||||
|
border: 1px solid @border-color;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @bg-color-light;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: @primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 132px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid @primary-color;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @white;
|
||||||
|
color: @primary-color;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: @primary-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
border-color: @border-color-dark;
|
||||||
|
color: @text-color-disabled;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
src/components/StatusTag.vue
Normal file
50
src/components/StatusTag.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
label: string
|
||||||
|
variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), { variant: 'info' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="status-tag" :class="`status-tag--${variant}`">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.status-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px @spacing-sm;
|
||||||
|
border-radius: @radius-sm;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: @primary-light;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
background: fade(@success-color, 12%);
|
||||||
|
color: @success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background: fade(@warning-color, 12%);
|
||||||
|
color: @warning-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
background: fade(@danger-color, 12%);
|
||||||
|
color: @danger-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
background: @bg-color;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
86
src/components/StepForm.vue
Normal file
86
src/components/StepForm.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
titles: string[]
|
||||||
|
current: number
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="step-form">
|
||||||
|
<div class="step-form__header">
|
||||||
|
<template v-for="(title, index) in titles" :key="title">
|
||||||
|
<div
|
||||||
|
class="step-form__step"
|
||||||
|
:class="{ 'is-active': index === current, 'is-done': index < current }"
|
||||||
|
>
|
||||||
|
<span class="step-form__dot">{{ index + 1 }}</span>
|
||||||
|
<span class="step-form__label">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="index < titles.length - 1" class="step-form__line" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="step-form__body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.step-form {
|
||||||
|
padding: @spacing-lg @spacing-xl;
|
||||||
|
border-radius: @radius-lg;
|
||||||
|
background: @white;
|
||||||
|
box-shadow: @shadow-base;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
.flex(row, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
margin-bottom: @spacing-lg;
|
||||||
|
padding-bottom: @spacing-base;
|
||||||
|
border-bottom: 1px solid @border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__step {
|
||||||
|
.flex(row, center);
|
||||||
|
gap: @spacing-xs;
|
||||||
|
|
||||||
|
&.is-active .step-form__dot {
|
||||||
|
background: @primary-color;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-done .step-form__dot {
|
||||||
|
border-color: @primary-color;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active .step-form__label {
|
||||||
|
color: @primary-color;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
.flex(row, center);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid @border-color-dark;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: @font-size-base;
|
||||||
|
color: @text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__line {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: @border-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
src/components/UploadArea.vue
Normal file
100
src/components/UploadArea.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hint?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
hint: '支持图片/文档/压缩包,大小不超过1G',
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ upload: [file: File] }>()
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const dragging = ref(false)
|
||||||
|
|
||||||
|
const MAX_SIZE = 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
function pick(): void {
|
||||||
|
inputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFiles(list: FileList | null): void {
|
||||||
|
if (!list) return
|
||||||
|
for (const file of Array.from(list)) {
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
ElMessage.warning(`「${file.name}」超过1G,未添加`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
emit('upload', file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent): void {
|
||||||
|
dragging.value = false
|
||||||
|
handleFiles(event.dataTransfer?.files ?? null)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="upload-area"
|
||||||
|
:class="{ 'is-dragging': dragging, 'is-disabled': disabled }"
|
||||||
|
@click="pick"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
|
<span class="upload-area__icon">⬆</span>
|
||||||
|
<span class="upload-area__hint text-secondary">{{ hint }}</span>
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="upload-area__input"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="handleFiles(($event.target as HTMLInputElement).files)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.upload-area {
|
||||||
|
.flex(column, center);
|
||||||
|
gap: @spacing-xs;
|
||||||
|
padding: @spacing-lg;
|
||||||
|
border: 1px dashed @border-color-dark;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @bg-color-light;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-dragging {
|
||||||
|
border-color: @primary-color;
|
||||||
|
background: @primary-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: @font-size-xl;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
src/components/index.ts
Normal file
15
src/components/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export { default as AppHeader } from './AppHeader.vue'
|
||||||
|
export { default as AppFooter } from './AppFooter.vue'
|
||||||
|
export { default as AppButton } from './AppButton.vue'
|
||||||
|
export { default as CaseCard } from './CaseCard.vue'
|
||||||
|
export { default as FilterPanel } from './FilterPanel.vue'
|
||||||
|
export type { FilterChip } from './FilterPanel.vue'
|
||||||
|
export { default as PaginationBar } from './PaginationBar.vue'
|
||||||
|
export { default as StatusTag } from './StatusTag.vue'
|
||||||
|
export { default as FileList } from './FileList.vue'
|
||||||
|
export { default as UploadArea } from './UploadArea.vue'
|
||||||
|
export { default as StepForm } from './StepForm.vue'
|
||||||
|
export { default as SmsCodeInput } from './SmsCodeInput.vue'
|
||||||
|
export { default as PasswordStrength } from './PasswordStrength.vue'
|
||||||
|
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
||||||
|
export { default as QrCodePanel } from './QrCodePanel.vue'
|
||||||
32
src/composables/useCountDown.ts
Normal file
32
src/composables/useCountDown.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { computed, onScopeDispose, ref } from 'vue'
|
||||||
|
|
||||||
|
/** 倒计时(验证码 60s、二维码失效 4:32 等通用) */
|
||||||
|
export function useCountDown(defaultSeconds = 60) {
|
||||||
|
const seconds = ref(0)
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const isCounting = computed(() => seconds.value > 0)
|
||||||
|
|
||||||
|
function start(total: number = defaultSeconds): void {
|
||||||
|
reset()
|
||||||
|
seconds.value = total
|
||||||
|
timer = setInterval(() => {
|
||||||
|
seconds.value -= 1
|
||||||
|
if (seconds.value <= 0) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
seconds.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(reset)
|
||||||
|
|
||||||
|
return { seconds, isCounting, start, reset }
|
||||||
|
}
|
||||||
29
src/composables/useIntersection.ts
Normal file
29
src/composables/useIntersection.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { onMounted, onUnmounted, ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
/** 元素可见性(配合图片懒加载/曝光统计) */
|
||||||
|
export function useIntersection(
|
||||||
|
target: Ref<Element | null | undefined>,
|
||||||
|
options?: IntersectionObserverInit,
|
||||||
|
): {
|
||||||
|
isVisible: Ref<boolean>
|
||||||
|
} {
|
||||||
|
const isVisible = ref(false)
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = target.value
|
||||||
|
if (!el) return
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
if (entry) isVisible.value = entry.isIntersecting
|
||||||
|
}, options)
|
||||||
|
observer.observe(el)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
observer = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return { isVisible }
|
||||||
|
}
|
||||||
21
src/composables/useLoading.ts
Normal file
21
src/composables/useLoading.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
/** 请求 loading 状态管理 */
|
||||||
|
export function useLoading(initial = false): {
|
||||||
|
loading: Ref<boolean>
|
||||||
|
withLoading: <T>(fn: () => Promise<T>) => Promise<T>
|
||||||
|
} {
|
||||||
|
const loading = ref(initial)
|
||||||
|
|
||||||
|
async function withLoading<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loading, withLoading }
|
||||||
|
}
|
||||||
26
src/composables/useScroll.ts
Normal file
26
src/composables/useScroll.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, ref, type ComputedRef, type Ref } from 'vue'
|
||||||
|
|
||||||
|
/** 滚动监听/吸顶/回到顶部 */
|
||||||
|
export function useScroll(threshold = 200): {
|
||||||
|
scrollY: Ref<number>
|
||||||
|
isAtTop: ComputedRef<boolean>
|
||||||
|
showBackTop: ComputedRef<boolean>
|
||||||
|
scrollToTop: (smooth?: boolean) => void
|
||||||
|
} {
|
||||||
|
const scrollY = ref(0)
|
||||||
|
const isAtTop = computed(() => scrollY.value <= 0)
|
||||||
|
const showBackTop = computed(() => scrollY.value > threshold)
|
||||||
|
|
||||||
|
function onScroll(): void {
|
||||||
|
scrollY.value = window.scrollY
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop(smooth = true): void {
|
||||||
|
window.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }))
|
||||||
|
onUnmounted(() => window.removeEventListener('scroll', onScroll))
|
||||||
|
|
||||||
|
return { scrollY, isAtTop, showBackTop, scrollToTop }
|
||||||
|
}
|
||||||
35
src/directives/debounce.ts
Normal file
35
src/directives/debounce.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Directive } from 'vue'
|
||||||
|
|
||||||
|
interface DebounceBinding {
|
||||||
|
handler: (...args: unknown[]) => void
|
||||||
|
delay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type DebounceValue = DebounceBinding['handler'] | DebounceBinding
|
||||||
|
|
||||||
|
const listenerMap = new WeakMap<Element, EventListener>()
|
||||||
|
|
||||||
|
function normalize(value: DebounceValue): DebounceBinding {
|
||||||
|
return typeof value === 'function' ? { handler: value } : value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v-debounce 防抖点击:v-debounce="fn" 或 v-debounce="{ handler: fn, delay: 500 }" */
|
||||||
|
const debounce: Directive<Element, DebounceValue> = {
|
||||||
|
mounted(el, binding) {
|
||||||
|
const { handler, delay = 300 } = normalize(binding.value)
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const listener: EventListener = () => {
|
||||||
|
if (timer !== null) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => handler(), delay)
|
||||||
|
}
|
||||||
|
listenerMap.set(el, listener)
|
||||||
|
el.addEventListener('click', listener)
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
const listener = listenerMap.get(el)
|
||||||
|
if (listener) el.removeEventListener('click', listener)
|
||||||
|
listenerMap.delete(el)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default debounce
|
||||||
12
src/directives/index.ts
Normal file
12
src/directives/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { App } from 'vue'
|
||||||
|
import lazy from './lazy'
|
||||||
|
import debounce from './debounce'
|
||||||
|
import throttle from './throttle'
|
||||||
|
|
||||||
|
/** main.ts 调用:统一注册自定义指令 */
|
||||||
|
export function setupDirectives(app: App): void {
|
||||||
|
app
|
||||||
|
.directive('lazy', lazy)
|
||||||
|
.directive('debounce', debounce)
|
||||||
|
.directive('throttle', throttle)
|
||||||
|
}
|
||||||
27
src/directives/lazy.ts
Normal file
27
src/directives/lazy.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Directive } from 'vue'
|
||||||
|
|
||||||
|
const observerMap = new WeakMap<Element, IntersectionObserver>()
|
||||||
|
|
||||||
|
/** v-lazy 图片懒加载:v-lazy="图片地址" */
|
||||||
|
const lazy: Directive<Element, string> = {
|
||||||
|
mounted(el, binding) {
|
||||||
|
if (!(el instanceof HTMLImageElement)) return
|
||||||
|
const target = el
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
if (entry?.isIntersecting) {
|
||||||
|
if (binding.value) target.src = binding.value
|
||||||
|
observer.disconnect()
|
||||||
|
observerMap.delete(el)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observerMap.set(el, observer)
|
||||||
|
observer.observe(el)
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
observerMap.get(el)?.disconnect()
|
||||||
|
observerMap.delete(el)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default lazy
|
||||||
38
src/directives/throttle.ts
Normal file
38
src/directives/throttle.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Directive } from 'vue'
|
||||||
|
|
||||||
|
interface ThrottleBinding {
|
||||||
|
handler: (...args: unknown[]) => void
|
||||||
|
interval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThrottleValue = ThrottleBinding['handler'] | ThrottleBinding
|
||||||
|
|
||||||
|
const listenerMap = new WeakMap<Element, EventListener>()
|
||||||
|
|
||||||
|
function normalize(value: ThrottleValue): ThrottleBinding {
|
||||||
|
return typeof value === 'function' ? { handler: value } : value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v-throttle 节流点击:v-throttle="fn" 或 v-throttle="{ handler: fn, interval: 500 }" */
|
||||||
|
const throttle: Directive<Element, ThrottleValue> = {
|
||||||
|
mounted(el, binding) {
|
||||||
|
const { handler, interval = 500 } = normalize(binding.value)
|
||||||
|
let lastTime = 0
|
||||||
|
const listener: EventListener = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastTime >= interval) {
|
||||||
|
lastTime = now
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listenerMap.set(el, listener)
|
||||||
|
el.addEventListener('click', listener)
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
const listener = listenerMap.get(el)
|
||||||
|
if (listener) el.removeEventListener('click', listener)
|
||||||
|
listenerMap.delete(el)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default throttle
|
||||||
10
src/env.d.ts
vendored
Normal file
10
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
/** API 基础地址 */
|
||||||
|
readonly VITE_API_BASE_URL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
58
src/layout/components/FloatingActions.vue
Normal file
58
src/layout/components/FloatingActions.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 全局浮动入口:快捷提报 / 在线客服(PRD 1.2)
|
||||||
|
const entries = [
|
||||||
|
{ label: '快捷提报', icon: '📝', path: '/demand' },
|
||||||
|
{ label: '在线客服', icon: '💬', path: '/service' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="floating-actions">
|
||||||
|
<RouterLink
|
||||||
|
v-for="entry in entries"
|
||||||
|
:key="entry.path"
|
||||||
|
:to="entry.path"
|
||||||
|
class="floating-actions__item"
|
||||||
|
:title="entry.label"
|
||||||
|
>
|
||||||
|
<span class="floating-actions__icon">{{ entry.icon }}</span>
|
||||||
|
<span class="floating-actions__label">{{ entry.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.floating-actions {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 96px;
|
||||||
|
z-index: 90;
|
||||||
|
.flex(column, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
.flex(column, center);
|
||||||
|
gap: 2px;
|
||||||
|
width: 56px;
|
||||||
|
padding: @spacing-sm 0;
|
||||||
|
border-radius: @radius-lg;
|
||||||
|
background: @primary-color;
|
||||||
|
color: @white;
|
||||||
|
box-shadow: @shadow-lg;
|
||||||
|
transition: background 0.2s, transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: @primary-hover;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
23
src/layout/index.vue
Normal file
23
src/layout/index.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { AppFooter, AppHeader } from '@/components'
|
||||||
|
import FloatingActions from './components/FloatingActions.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<AppHeader />
|
||||||
|
<main class="layout__main">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
<AppFooter />
|
||||||
|
<FloatingActions />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.layout {
|
||||||
|
.layout__main {
|
||||||
|
min-height: calc(100vh - @header-height - @footer-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
src/main.ts
Normal file
15
src/main.ts
Normal file
@@ -0,0 +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'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
setupDirectives(app)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
16
src/router/guard.ts
Normal file
16
src/router/guard.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Router } from 'vue-router'
|
||||||
|
import { storage } from '@/utils/storage'
|
||||||
|
|
||||||
|
/** 路由守卫:登录态校验 + document.title */
|
||||||
|
export function setupGuard(router: Router): void {
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta.requiresAuth && !storage.get<string>('token')) {
|
||||||
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
document.title = to.meta.title ? `${to.meta.title} - 虫子智造` : '虫子智造'
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/router/index.ts
Normal file
13
src/router/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { routes } from './routes'
|
||||||
|
import { setupGuard } from './guard'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
scrollBehavior: () => ({ top: 0 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
setupGuard(router)
|
||||||
|
|
||||||
|
export default router
|
||||||
65
src/router/routes.ts
Normal file
65
src/router/routes.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公共布局(AppHeader + router-view + AppFooter + FloatingActions)下的路由
|
||||||
|
* 完整规划见 docs/page-architecture.md 第 2 节
|
||||||
|
* M3–M10 视图由子代理并行实现中,交付后本表即为终态
|
||||||
|
*/
|
||||||
|
const layoutRoutes: RouteRecordRaw = {
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layout/index.vue'),
|
||||||
|
children: [
|
||||||
|
// ── 公开 ──
|
||||||
|
{ path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页' } },
|
||||||
|
{ path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } },
|
||||||
|
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
|
||||||
|
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } },
|
||||||
|
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报' } },
|
||||||
|
|
||||||
|
// ── 登录区 ──
|
||||||
|
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } },
|
||||||
|
{ path: 'project/:id', name: 'ProjectDetail', component: () => import('@/views/project/detail.vue'), meta: { title: '项目详情', requiresAuth: true } },
|
||||||
|
{ path: 'project/:id/after-sales', name: 'AfterSales', component: () => import('@/views/project/after-sales.vue'), meta: { title: '申请售后', requiresAuth: true } },
|
||||||
|
{ path: 'contract', name: 'Contract', component: () => import('@/views/contract'), meta: { title: '合同中心', requiresAuth: true } },
|
||||||
|
{ path: 'contract/:id', name: 'ContractDetail', component: () => import('@/views/contract/detail.vue'), meta: { title: '合同详情', requiresAuth: true } },
|
||||||
|
{ path: 'contract/:id/sign', name: 'ContractSign', component: () => import('@/views/contract/sign.vue'), meta: { title: '签署合同', requiresAuth: true } },
|
||||||
|
{ path: 'finance', name: 'Finance', component: () => import('@/views/finance'), meta: { title: '财务中心', requiresAuth: true } },
|
||||||
|
{ path: 'finance/bills', name: 'Bills', component: () => import('@/views/finance/bills.vue'), meta: { title: '账单明细', requiresAuth: true } },
|
||||||
|
{ path: 'finance/bills/:id', name: 'BillDetail', component: () => import('@/views/finance/bill-detail.vue'), meta: { title: '账单详情', requiresAuth: true } },
|
||||||
|
{ path: 'finance/bills/:id/pay', name: 'BillPay', component: () => import('@/views/finance/pay.vue'), meta: { title: '扫码支付', requiresAuth: true } },
|
||||||
|
{ path: 'finance/bills/:id/pay/result', name: 'PayResult', component: () => import('@/views/finance/pay-result.vue'), meta: { title: '支付结果', requiresAuth: true } },
|
||||||
|
{ path: 'finance/invoices', name: 'Invoices', component: () => import('@/views/finance/invoices.vue'), meta: { title: '发票申请', requiresAuth: true } },
|
||||||
|
{ path: 'finance/invoice-titles', name: 'InvoiceTitles', component: () => import('@/views/finance/invoice-titles.vue'), meta: { title: '发票抬头', requiresAuth: true } },
|
||||||
|
{ path: 'message', name: 'Message', component: () => import('@/views/message'), meta: { title: '消息中心', requiresAuth: true } },
|
||||||
|
{ path: 'message/:id', name: 'MessageDetail', component: () => import('@/views/message/detail.vue'), meta: { title: '通知详情', requiresAuth: true } },
|
||||||
|
{ path: 'service', name: 'Service', component: () => import('@/views/service'), meta: { title: '在线客服', requiresAuth: true } },
|
||||||
|
|
||||||
|
// ── 个人中心(左菜单嵌套)──
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
component: () => import('@/views/profile'),
|
||||||
|
meta: { title: '个人中心', requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{ path: '', redirect: '/profile/security' },
|
||||||
|
{ path: 'security', name: 'ProfileSecurity', component: () => import('@/views/profile/security.vue'), meta: { title: '账号安全', requiresAuth: true } },
|
||||||
|
{ path: 'address', name: 'ProfileAddress', component: () => import('@/views/profile/address.vue'), meta: { title: '地址管理', requiresAuth: true } },
|
||||||
|
{ path: 'favorites', name: 'ProfileFavorites', component: () => import('@/views/profile/favorites.vue'), meta: { title: '收藏列表', requiresAuth: true } },
|
||||||
|
{ path: 'qualifications', name: 'ProfileQualifications', component: () => import('@/views/profile/qualifications.vue'), meta: { title: '资质列表', requiresAuth: true } },
|
||||||
|
{ path: 'feedback', name: 'ProfileFeedback', component: () => import('@/views/profile/feedback.vue'), meta: { title: '意见反馈', requiresAuth: true } },
|
||||||
|
{ path: 'help', name: 'ProfileHelp', component: () => import('@/views/profile/help.vue'), meta: { title: '帮助中心', requiresAuth: true } },
|
||||||
|
{ path: 'about', name: 'ProfileAbout', component: () => import('@/views/profile/about.vue'), meta: { title: '关于我们', requiresAuth: true } },
|
||||||
|
{ path: 'hotline', name: 'ProfileHotline', component: () => import('@/views/profile/hotline.vue'), meta: { title: '专属客服热线', requiresAuth: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无全局布局的路由(登录注册独立品牌页 + 404) */
|
||||||
|
const plainRoutes: RouteRecordRaw[] = [
|
||||||
|
{ path: '/login', name: 'Login', component: () => import('@/views/login'), meta: { title: '登录' } },
|
||||||
|
{ path: '/register', name: 'Register', component: () => import('@/views/login/register.vue'), meta: { title: '注册' } },
|
||||||
|
{ path: '/reset-password', name: 'ResetPassword', component: () => import('@/views/login/reset.vue'), meta: { title: '找回密码' } },
|
||||||
|
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue'), meta: { title: '页面不存在' } },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const routes: RouteRecordRaw[] = [layoutRoutes, ...plainRoutes]
|
||||||
5
src/stores/index.ts
Normal file
5
src/stores/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
export default pinia
|
||||||
84
src/stores/modules/cases.ts
Normal file
84
src/stores/modules/cases.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { BusinessType, PageResult } from '@/types/api'
|
||||||
|
import type { Case, CaseQuery } from '@/types/business/cases'
|
||||||
|
import { getCaseCounts, getCaseDetail, getCaseList, toggleFavorite as toggleFavoriteApi } from '@/api/cases'
|
||||||
|
|
||||||
|
function createDefaultQuery(): CaseQuery {
|
||||||
|
return {
|
||||||
|
category: 'all',
|
||||||
|
industries: [],
|
||||||
|
scales: [],
|
||||||
|
dateRange: null,
|
||||||
|
keyword: '',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 案例库数据(列表 / 筛选 / 分页 / 收藏) */
|
||||||
|
export const useCasesStore = defineStore('cases', () => {
|
||||||
|
const query = ref<CaseQuery>(createDefaultQuery())
|
||||||
|
const list = ref<Case[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const counts = ref<Partial<Record<BusinessType, number>>>({})
|
||||||
|
const currentCase = ref<Case | null>(null)
|
||||||
|
|
||||||
|
async function fetchCases(): Promise<void> {
|
||||||
|
const result: PageResult<Case> = await getCaseList(query.value)
|
||||||
|
list.value = result.list
|
||||||
|
total.value = result.total
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCounts(): Promise<void> {
|
||||||
|
counts.value = await getCaseCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuery(next: CaseQuery): void {
|
||||||
|
query.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确定筛选(重置页码后取数) */
|
||||||
|
async function search(): Promise<void> {
|
||||||
|
query.value = { ...query.value, page: 1 }
|
||||||
|
await fetchCases()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置全部条件 */
|
||||||
|
async function resetQuery(): Promise<void> {
|
||||||
|
query.value = createDefaultQuery()
|
||||||
|
await fetchCases()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePage(page: number): Promise<void> {
|
||||||
|
query.value = { ...query.value, page }
|
||||||
|
await fetchCases()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCaseDetail(id: string): Promise<void> {
|
||||||
|
currentCase.value = await getCaseDetail(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFavorite(id: string): Promise<void> {
|
||||||
|
const updated = await toggleFavoriteApi(id)
|
||||||
|
const index = list.value.findIndex((item) => item.id === id)
|
||||||
|
if (index >= 0) list.value[index] = updated
|
||||||
|
if (currentCase.value?.id === id) currentCase.value = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
counts,
|
||||||
|
currentCase,
|
||||||
|
fetchCases,
|
||||||
|
fetchCounts,
|
||||||
|
setQuery,
|
||||||
|
search,
|
||||||
|
resetQuery,
|
||||||
|
changePage,
|
||||||
|
fetchCaseDetail,
|
||||||
|
toggleFavorite,
|
||||||
|
}
|
||||||
|
})
|
||||||
79
src/stores/modules/contract.ts
Normal file
79
src/stores/modules/contract.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { PageResult } from '@/types/api'
|
||||||
|
import type { Contract, ContractQuery } from '@/types/business/contract'
|
||||||
|
import {
|
||||||
|
getContractDetail,
|
||||||
|
getContractList,
|
||||||
|
getSignQr,
|
||||||
|
refreshSignQr,
|
||||||
|
} from '@/api/contract'
|
||||||
|
|
||||||
|
function createDefaultQuery(): ContractQuery {
|
||||||
|
return {
|
||||||
|
keyword: '',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合同中心数据(列表 / 筛选 / 分页 / 详情 / 签署二维码) */
|
||||||
|
export const useContractStore = defineStore('contract', () => {
|
||||||
|
const query = ref<ContractQuery>(createDefaultQuery())
|
||||||
|
const list = ref<Contract[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const current = ref<Contract | null>(null)
|
||||||
|
const signExpires = ref(272)
|
||||||
|
|
||||||
|
async function fetchList(): Promise<void> {
|
||||||
|
const result: PageResult<Contract> = await getContractList(query.value)
|
||||||
|
list.value = result.list
|
||||||
|
total.value = result.total
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetail(id: string): Promise<void> {
|
||||||
|
current.value = await getContractDetail(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuery(next: ContractQuery): void {
|
||||||
|
query.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确定筛选(重置页码后取数) */
|
||||||
|
async function search(): Promise<void> {
|
||||||
|
query.value = { ...query.value, page: 1 }
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置全部条件 */
|
||||||
|
async function resetQuery(): Promise<void> {
|
||||||
|
query.value = createDefaultQuery()
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePage(page: number): Promise<void> {
|
||||||
|
query.value = { ...query.value, page }
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取/刷新签署二维码(refresh=true 时走刷新接口) */
|
||||||
|
async function fetchSignQr(id: string, refresh = false): Promise<void> {
|
||||||
|
const result = refresh ? await refreshSignQr(id) : await getSignQr(id)
|
||||||
|
signExpires.value = result.expiresInSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
current,
|
||||||
|
signExpires,
|
||||||
|
fetchList,
|
||||||
|
fetchDetail,
|
||||||
|
setQuery,
|
||||||
|
search,
|
||||||
|
resetQuery,
|
||||||
|
changePage,
|
||||||
|
fetchSignQr,
|
||||||
|
}
|
||||||
|
})
|
||||||
126
src/stores/modules/demand.ts
Normal file
126
src/stores/modules/demand.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { Demand } from '@/types/business/demand'
|
||||||
|
import type { FileInfo } from '@/types/api'
|
||||||
|
import { formatDate } from '@/utils/format'
|
||||||
|
import { getDraftDemand, saveDraft as saveDraftApi, submitDemand } from '@/api/demand'
|
||||||
|
|
||||||
|
function createDefaultForm(): Demand {
|
||||||
|
return {
|
||||||
|
businessType: 'internet',
|
||||||
|
projectName: '',
|
||||||
|
attachments: [],
|
||||||
|
coreDescription: '',
|
||||||
|
expectedDuration: '',
|
||||||
|
budgetRange: '',
|
||||||
|
userName: '王永志',
|
||||||
|
userPhone: '1866****9618',
|
||||||
|
remark: '',
|
||||||
|
status: 'draft',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 需求提报数据(M3:表单 / 步骤 / 提交与草稿) */
|
||||||
|
export const useDemandStore = defineStore('demand', () => {
|
||||||
|
const form = ref<Demand>(createDefaultForm())
|
||||||
|
const step = ref(1)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function setStep(next: number): void {
|
||||||
|
step.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchForm(partial: Partial<Demand>): void {
|
||||||
|
form.value = { ...form.value, ...partial }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File → FileInfo(mock:url 为空,uploadTime 取当前时间) */
|
||||||
|
function addAttachment(file: File): void {
|
||||||
|
const info: FileInfo = {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
uploadTime: formatDate(new Date()),
|
||||||
|
url: '',
|
||||||
|
}
|
||||||
|
form.value.attachments = [...form.value.attachments, info]
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment(target: FileInfo): void {
|
||||||
|
form.value.attachments = form.value.attachments.filter(
|
||||||
|
(item) => !(item.name === target.name && item.uploadTime === target.uploadTime),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交需求:必填校验通过后调 api,成功返回 true(页面可据此跳转) */
|
||||||
|
async function submit(): Promise<boolean> {
|
||||||
|
if (!form.value.businessType) {
|
||||||
|
ElMessage.warning('请选择业务类型')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.value.projectName.trim()) {
|
||||||
|
ElMessage.warning('请填写项目名称')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.value.coreDescription.trim()) {
|
||||||
|
ElMessage.warning('请填写核心需求描述')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await submitDemand(form.value)
|
||||||
|
form.value.status = 'submitted'
|
||||||
|
ElMessage.success('提交后我们的专业顾问将在一个工作日内与您联系')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '提交失败,请稍后重试')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存草稿到 sessionStorage */
|
||||||
|
async function saveDraft(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await saveDraftApi(form.value)
|
||||||
|
form.value.status = 'draft'
|
||||||
|
ElMessage.success('草稿已保存')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复本地草稿(无草稿则保持默认表单) */
|
||||||
|
async function loadDraft(): Promise<void> {
|
||||||
|
const draft = await getDraftDemand()
|
||||||
|
if (draft) form.value = { ...createDefaultForm(), ...draft }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置表单与步骤 */
|
||||||
|
function reset(): void {
|
||||||
|
form.value = createDefaultForm()
|
||||||
|
step.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
step,
|
||||||
|
loading,
|
||||||
|
setStep,
|
||||||
|
patchForm,
|
||||||
|
addAttachment,
|
||||||
|
removeAttachment,
|
||||||
|
submit,
|
||||||
|
saveDraft,
|
||||||
|
loadDraft,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
})
|
||||||
207
src/stores/modules/finance.ts
Normal file
207
src/stores/modules/finance.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { PageResult } from '@/types/api'
|
||||||
|
import type {
|
||||||
|
Bill,
|
||||||
|
BillQuery,
|
||||||
|
FinanceStats,
|
||||||
|
Invoice,
|
||||||
|
InvoiceQuery,
|
||||||
|
InvoiceTitle,
|
||||||
|
InvoiceTitlePayload,
|
||||||
|
PayMethod,
|
||||||
|
Payment,
|
||||||
|
PaymentResult,
|
||||||
|
} from '@/types/business/finance'
|
||||||
|
import {
|
||||||
|
applyInvoice as applyInvoiceApi,
|
||||||
|
createInvoiceTitle,
|
||||||
|
createPayment as createPaymentApi,
|
||||||
|
deleteInvoiceTitle,
|
||||||
|
exportReport as exportReportApi,
|
||||||
|
getBillDetail,
|
||||||
|
getBillList,
|
||||||
|
getFinanceStats,
|
||||||
|
getInvoiceList,
|
||||||
|
getInvoiceTitles,
|
||||||
|
getPayResult,
|
||||||
|
setDefaultTitle as setDefaultTitleApi,
|
||||||
|
switchPaymentMethod as switchPaymentMethodApi,
|
||||||
|
updateInvoiceTitle,
|
||||||
|
} from '@/api/finance'
|
||||||
|
|
||||||
|
function createDefaultBillQuery(): BillQuery {
|
||||||
|
return { keyword: '', status: 'all', page: 1, pageSize: 6 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultInvoiceQuery(): InvoiceQuery {
|
||||||
|
return { keyword: '', status: 'all', page: 1, pageSize: 6 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 财务中心数据(统计/账单/支付/发票/抬头) */
|
||||||
|
export const useFinanceStore = defineStore('finance', () => {
|
||||||
|
// —— 统计
|
||||||
|
const stats = ref<FinanceStats | null>(null)
|
||||||
|
|
||||||
|
// —— 账单列表
|
||||||
|
const list = ref<Bill[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const query = ref<BillQuery>(createDefaultBillQuery())
|
||||||
|
const currentBill = ref<Bill | null>(null)
|
||||||
|
|
||||||
|
// —— 支付
|
||||||
|
const payment = ref<Payment | null>(null)
|
||||||
|
const result = ref<PaymentResult | null>(null)
|
||||||
|
|
||||||
|
// —— 发票
|
||||||
|
const invoices = ref<Invoice[]>([])
|
||||||
|
const invoiceTotal = ref(0)
|
||||||
|
const invoiceQuery = ref<InvoiceQuery>(createDefaultInvoiceQuery())
|
||||||
|
|
||||||
|
// —— 发票抬头
|
||||||
|
const titles = ref<InvoiceTitle[]>([])
|
||||||
|
|
||||||
|
// —— 统计
|
||||||
|
async function fetchStats(period?: string): Promise<void> {
|
||||||
|
stats.value = await getFinanceStats(period)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 账单
|
||||||
|
async function fetchList(): Promise<void> {
|
||||||
|
const pageData: PageResult<Bill> = await getBillList(query.value)
|
||||||
|
list.value = pageData.list
|
||||||
|
total.value = pageData.total
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuery(next: BillQuery): void {
|
||||||
|
query.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确定筛选(重置页码后取数) */
|
||||||
|
async function search(): Promise<void> {
|
||||||
|
query.value = { ...query.value, page: 1 }
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置全部条件 */
|
||||||
|
async function resetQuery(): Promise<void> {
|
||||||
|
query.value = createDefaultBillQuery()
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePage(page: number): Promise<void> {
|
||||||
|
query.value = { ...query.value, page }
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetail(id: string): Promise<void> {
|
||||||
|
currentBill.value = await getBillDetail(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 支付
|
||||||
|
async function createPayment(billId: string, method: PayMethod): Promise<void> {
|
||||||
|
payment.value = await createPaymentApi(billId, method)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换支付方式(刷新二维码) */
|
||||||
|
async function switchPaymentMethod(billId: string, method: PayMethod): Promise<void> {
|
||||||
|
payment.value = await switchPaymentMethodApi(billId, method)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchResult(billId: string): Promise<void> {
|
||||||
|
result.value = await getPayResult(billId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 发票
|
||||||
|
async function fetchInvoices(): Promise<void> {
|
||||||
|
const pageData: PageResult<Invoice> = await getInvoiceList(invoiceQuery.value)
|
||||||
|
invoices.value = pageData.list
|
||||||
|
invoiceTotal.value = pageData.total
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInvoiceQuery(next: InvoiceQuery): void {
|
||||||
|
invoiceQuery.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发票确定筛选(重置页码后取数) */
|
||||||
|
async function searchInvoices(): Promise<void> {
|
||||||
|
invoiceQuery.value = { ...invoiceQuery.value, page: 1 }
|
||||||
|
await fetchInvoices()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeInvoicePage(page: number): Promise<void> {
|
||||||
|
invoiceQuery.value = { ...invoiceQuery.value, page }
|
||||||
|
await fetchInvoices()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 申请开票(状态 → 开票中) */
|
||||||
|
async function applyInvoice(id: string): Promise<void> {
|
||||||
|
const updated = await applyInvoiceApi(id)
|
||||||
|
const index = invoices.value.findIndex((item) => item.id === id)
|
||||||
|
if (index >= 0) invoices.value[index] = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 发票抬头
|
||||||
|
async function fetchTitles(): Promise<void> {
|
||||||
|
titles.value = await getInvoiceTitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTitle(payload: InvoiceTitlePayload): Promise<void> {
|
||||||
|
await createInvoiceTitle(payload)
|
||||||
|
await fetchTitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTitle(id: string, payload: InvoiceTitlePayload): Promise<void> {
|
||||||
|
await updateInvoiceTitle(id, payload)
|
||||||
|
await fetchTitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTitle(id: string): Promise<void> {
|
||||||
|
await deleteInvoiceTitle(id)
|
||||||
|
await fetchTitles()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDefaultTitle(id: string): Promise<void> {
|
||||||
|
titles.value = await setDefaultTitleApi(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 报表导出
|
||||||
|
async function exportReport(): Promise<void> {
|
||||||
|
await exportReportApi()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
query,
|
||||||
|
currentBill,
|
||||||
|
payment,
|
||||||
|
result,
|
||||||
|
invoices,
|
||||||
|
invoiceTotal,
|
||||||
|
invoiceQuery,
|
||||||
|
titles,
|
||||||
|
fetchStats,
|
||||||
|
fetchList,
|
||||||
|
setQuery,
|
||||||
|
search,
|
||||||
|
resetQuery,
|
||||||
|
changePage,
|
||||||
|
fetchDetail,
|
||||||
|
createPayment,
|
||||||
|
switchPaymentMethod,
|
||||||
|
fetchResult,
|
||||||
|
fetchInvoices,
|
||||||
|
setInvoiceQuery,
|
||||||
|
searchInvoices,
|
||||||
|
changeInvoicePage,
|
||||||
|
applyInvoice,
|
||||||
|
fetchTitles,
|
||||||
|
createTitle,
|
||||||
|
updateTitle,
|
||||||
|
deleteTitle,
|
||||||
|
setDefaultTitle,
|
||||||
|
export: exportReport,
|
||||||
|
}
|
||||||
|
})
|
||||||
46
src/stores/modules/home.ts
Normal file
46
src/stores/modules/home.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { BusinessType } from '@/types/api'
|
||||||
|
import type { Case } from '@/types/business/cases'
|
||||||
|
import type { BusinessLine, HomeStats } from '@/types/business/home'
|
||||||
|
import { getBusinessDetail, getBusinessLines, getFeaturedCases, getHomeStats } from '@/api/home'
|
||||||
|
|
||||||
|
/** 首页数据(数据层:取数 + 状态,页面只调本 store) */
|
||||||
|
export const useHomeStore = defineStore('home', () => {
|
||||||
|
const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 })
|
||||||
|
const businessLines = ref<BusinessLine[]>([])
|
||||||
|
const featuredCases = ref<Case[]>([])
|
||||||
|
const currentBusiness = ref<BusinessLine | null>(null)
|
||||||
|
|
||||||
|
/** 首页初始化:看板 + 业务线 + 案例精选 */
|
||||||
|
async function fetchHomeData(): Promise<void> {
|
||||||
|
const [homeStats, lines, cases] = await Promise.all([
|
||||||
|
getHomeStats(),
|
||||||
|
getBusinessLines(),
|
||||||
|
getFeaturedCases(),
|
||||||
|
])
|
||||||
|
stats.value = homeStats
|
||||||
|
businessLines.value = lines
|
||||||
|
featuredCases.value = cases
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按业务类型切换案例精选 */
|
||||||
|
async function fetchFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<void> {
|
||||||
|
featuredCases.value = await getFeaturedCases(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务详情 */
|
||||||
|
async function fetchBusinessDetail(type: BusinessType): Promise<void> {
|
||||||
|
currentBusiness.value = await getBusinessDetail(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
businessLines,
|
||||||
|
featuredCases,
|
||||||
|
currentBusiness,
|
||||||
|
fetchHomeData,
|
||||||
|
fetchFeaturedCases,
|
||||||
|
fetchBusinessDetail,
|
||||||
|
}
|
||||||
|
})
|
||||||
112
src/stores/modules/message.ts
Normal file
112
src/stores/modules/message.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { PageResult } from '@/types/api'
|
||||||
|
import type { Message, MessageQuery } from '@/types/business/message'
|
||||||
|
import {
|
||||||
|
getMessageDetail,
|
||||||
|
getMessageList,
|
||||||
|
getTabCounts,
|
||||||
|
getUnreadCount,
|
||||||
|
markAllRead as markAllReadApi,
|
||||||
|
markRead as markReadApi,
|
||||||
|
} from '@/api/message'
|
||||||
|
|
||||||
|
/** tab 计数结构 */
|
||||||
|
export interface MessageTabCounts {
|
||||||
|
all: number
|
||||||
|
在线客服: number
|
||||||
|
状态通知: number
|
||||||
|
系统通知: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultQuery(): MessageQuery {
|
||||||
|
return {
|
||||||
|
keyword: '',
|
||||||
|
type: 'all',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息中心数据(列表 / 筛选 / 分页 / 已读) */
|
||||||
|
export const useMessageStore = defineStore('message', () => {
|
||||||
|
const query = ref<MessageQuery>(createDefaultQuery())
|
||||||
|
const list = ref<Message[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const current = ref<Message | null>(null)
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const tabCounts = ref<MessageTabCounts>({ all: 0, 在线客服: 0, 状态通知: 0, 系统通知: 0 })
|
||||||
|
|
||||||
|
async function fetchList(): Promise<void> {
|
||||||
|
const result: PageResult<Message> = await getMessageList(query.value)
|
||||||
|
list.value = result.list
|
||||||
|
total.value = result.total
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetail(id: string): Promise<void> {
|
||||||
|
const detail = await getMessageDetail(id)
|
||||||
|
current.value = detail
|
||||||
|
if (detail && !detail.isRead) {
|
||||||
|
await markReadApi(id)
|
||||||
|
detail.isRead = true
|
||||||
|
if (unreadCount.value > 0) unreadCount.value -= 1
|
||||||
|
await fetchTabCounts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuery(next: MessageQuery): void {
|
||||||
|
query.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确定搜索(重置页码后取数) */
|
||||||
|
async function search(): Promise<void> {
|
||||||
|
query.value = { ...query.value, page: 1 }
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置全部条件 */
|
||||||
|
async function resetQuery(): Promise<void> {
|
||||||
|
query.value = createDefaultQuery()
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePage(page: number): Promise<void> {
|
||||||
|
query.value = { ...query.value, page }
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全部标记已读 */
|
||||||
|
async function markAllRead(): Promise<void> {
|
||||||
|
await markAllReadApi()
|
||||||
|
ElMessage.success('已全部标记为已读')
|
||||||
|
await Promise.all([fetchList(), fetchUnreadCount(), fetchTabCounts()])
|
||||||
|
if (current.value) current.value.isRead = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUnreadCount(): Promise<void> {
|
||||||
|
unreadCount.value = await getUnreadCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTabCounts(): Promise<void> {
|
||||||
|
tabCounts.value = await getTabCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
current,
|
||||||
|
unreadCount,
|
||||||
|
tabCounts,
|
||||||
|
fetchList,
|
||||||
|
fetchDetail,
|
||||||
|
setQuery,
|
||||||
|
search,
|
||||||
|
resetQuery,
|
||||||
|
changePage,
|
||||||
|
markAllRead,
|
||||||
|
fetchUnreadCount,
|
||||||
|
fetchTabCounts,
|
||||||
|
}
|
||||||
|
})
|
||||||
258
src/stores/modules/profile.ts
Normal file
258
src/stores/modules/profile.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { BusinessType, PageResult } from '@/types/api'
|
||||||
|
import type { Case } from '@/types/business/cases'
|
||||||
|
import type {
|
||||||
|
Address,
|
||||||
|
ChangePasswordParams,
|
||||||
|
Feedback,
|
||||||
|
FavoritesQuery,
|
||||||
|
HelpFaqGroup,
|
||||||
|
ListQuery,
|
||||||
|
Profile,
|
||||||
|
Qualification,
|
||||||
|
QualificationInput,
|
||||||
|
} from '@/types/business/profile'
|
||||||
|
import {
|
||||||
|
bindEmail,
|
||||||
|
bindPhone,
|
||||||
|
changePassword,
|
||||||
|
createAddress,
|
||||||
|
createQualification,
|
||||||
|
deleteAccount as deleteAccountApi,
|
||||||
|
deleteAddress,
|
||||||
|
deleteQualification,
|
||||||
|
getAddresses,
|
||||||
|
getCaseCounts,
|
||||||
|
getFavorites,
|
||||||
|
getHelpFaqs,
|
||||||
|
getProfile,
|
||||||
|
getQualifications,
|
||||||
|
setDefaultAddress,
|
||||||
|
submitFeedback as submitFeedbackApi,
|
||||||
|
unbindEmail,
|
||||||
|
updateAddress,
|
||||||
|
updateNickname,
|
||||||
|
updateQualification,
|
||||||
|
verifyIdentity,
|
||||||
|
} from '@/api/profile'
|
||||||
|
import { storage } from '@/utils/storage'
|
||||||
|
|
||||||
|
function createDefaultAddressQuery(): ListQuery {
|
||||||
|
return { page: 1, pageSize: 6 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultQualificationQuery(): ListQuery {
|
||||||
|
return { page: 1, pageSize: 6 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultFavoritesQuery(): FavoritesQuery {
|
||||||
|
return { page: 1, pageSize: 8, keyword: '', category: 'all' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 个人中心数据(资料/安全/地址/收藏/资质/反馈/帮助) */
|
||||||
|
export const useProfileStore = defineStore('profile', () => {
|
||||||
|
// ── 账号资料 ──
|
||||||
|
const profile = ref<Profile | null>(null)
|
||||||
|
|
||||||
|
async function fetchProfile(): Promise<void> {
|
||||||
|
profile.value = await getProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveNickname(name: string): Promise<void> {
|
||||||
|
profile.value = await updateNickname(name)
|
||||||
|
ElMessage.success('昵称修改成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 账号安全 ──
|
||||||
|
async function changePasswordAction(params: ChangePasswordParams): Promise<void> {
|
||||||
|
await changePassword(params)
|
||||||
|
profile.value = await getProfile()
|
||||||
|
ElMessage.success('密码修改成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindPhoneAction(phone: string, code: string): Promise<void> {
|
||||||
|
profile.value = await bindPhone(phone, code)
|
||||||
|
ElMessage.success('手机号绑定成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindEmailAction(email: string, code: string): Promise<void> {
|
||||||
|
profile.value = await bindEmail(email, code)
|
||||||
|
ElMessage.success('邮箱绑定成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbindEmailAction(): Promise<void> {
|
||||||
|
profile.value = await unbindEmail()
|
||||||
|
ElMessage.success('已取消邮箱绑定')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyIdentityAction(phone: string, code: string): Promise<void> {
|
||||||
|
await verifyIdentity(phone, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccountAction(): Promise<void> {
|
||||||
|
await deleteAccountApi()
|
||||||
|
storage.remove('token')
|
||||||
|
ElMessage.success('注销成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 收货地址 ──
|
||||||
|
const addresses = ref<Address[]>([])
|
||||||
|
const addressTotal = ref(0)
|
||||||
|
const addressQuery = ref<ListQuery>(createDefaultAddressQuery())
|
||||||
|
|
||||||
|
async function fetchAddresses(): Promise<void> {
|
||||||
|
const result: PageResult<Address> = await getAddresses(addressQuery.value)
|
||||||
|
addresses.value = result.list
|
||||||
|
addressTotal.value = result.total
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAddressAction(input: Omit<Address, 'id' | 'isDefault'>): Promise<void> {
|
||||||
|
await createAddress(input)
|
||||||
|
ElMessage.success('新增地址成功')
|
||||||
|
addressQuery.value = { ...addressQuery.value, page: 1 }
|
||||||
|
await fetchAddresses()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAddressAction(id: string, input: Omit<Address, 'id' | 'isDefault'>): Promise<void> {
|
||||||
|
await updateAddress(id, input)
|
||||||
|
ElMessage.success('地址保存成功')
|
||||||
|
await fetchAddresses()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAddressAction(id: string): Promise<void> {
|
||||||
|
await deleteAddress(id)
|
||||||
|
ElMessage.success('地址删除成功')
|
||||||
|
await fetchAddresses()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDefaultAddressAction(id: string): Promise<void> {
|
||||||
|
await setDefaultAddress(id)
|
||||||
|
ElMessage.success('已设为默认地址')
|
||||||
|
await fetchAddresses()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeAddressPage(page: number): Promise<void> {
|
||||||
|
addressQuery.value = { ...addressQuery.value, page }
|
||||||
|
await fetchAddresses()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 我的收藏 ──
|
||||||
|
const favorites = ref<Case[]>([])
|
||||||
|
const favoritesTotal = ref(0)
|
||||||
|
const favoritesCounts = ref<Partial<Record<BusinessType, number>>>({})
|
||||||
|
const favoritesQuery = ref<FavoritesQuery>(createDefaultFavoritesQuery())
|
||||||
|
|
||||||
|
async function fetchFavorites(): Promise<void> {
|
||||||
|
const result: PageResult<Case> = await getFavorites(favoritesQuery.value)
|
||||||
|
favorites.value = result.list
|
||||||
|
favoritesTotal.value = result.total
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFavoritesCounts(): Promise<void> {
|
||||||
|
favoritesCounts.value = await getCaseCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchFavorites(patch: Partial<FavoritesQuery>): Promise<void> {
|
||||||
|
favoritesQuery.value = { ...favoritesQuery.value, ...patch, page: 1 }
|
||||||
|
await fetchFavorites()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetFavorites(): Promise<void> {
|
||||||
|
favoritesQuery.value = createDefaultFavoritesQuery()
|
||||||
|
await fetchFavorites()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeFavoritesPage(page: number): Promise<void> {
|
||||||
|
favoritesQuery.value = { ...favoritesQuery.value, page }
|
||||||
|
await fetchFavorites()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 资质管理 ──
|
||||||
|
const qualifications = ref<Qualification[]>([])
|
||||||
|
const qualificationTotal = ref(0)
|
||||||
|
const qualificationQuery = ref<ListQuery>(createDefaultQualificationQuery())
|
||||||
|
|
||||||
|
async function fetchQualifications(): Promise<void> {
|
||||||
|
const result: PageResult<Qualification> = await getQualifications(qualificationQuery.value)
|
||||||
|
qualifications.value = result.list
|
||||||
|
qualificationTotal.value = result.total
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createQualificationAction(input: QualificationInput): Promise<void> {
|
||||||
|
await createQualification(input)
|
||||||
|
ElMessage.success('资质提交成功')
|
||||||
|
qualificationQuery.value = { ...qualificationQuery.value, page: 1 }
|
||||||
|
await fetchQualifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateQualificationAction(id: string, input: QualificationInput): Promise<void> {
|
||||||
|
await updateQualification(id, input)
|
||||||
|
ElMessage.success('资质保存成功')
|
||||||
|
await fetchQualifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteQualificationAction(id: string): Promise<void> {
|
||||||
|
await deleteQualification(id)
|
||||||
|
ElMessage.success('资质删除成功')
|
||||||
|
await fetchQualifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeQualificationPage(page: number): Promise<void> {
|
||||||
|
qualificationQuery.value = { ...qualificationQuery.value, page }
|
||||||
|
await fetchQualifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 意见反馈 / 帮助中心 ──
|
||||||
|
async function submitFeedbackAction(feedback: Feedback): Promise<void> {
|
||||||
|
await submitFeedbackApi(feedback)
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpFaqs = ref<HelpFaqGroup[]>([])
|
||||||
|
|
||||||
|
async function fetchHelpFaqs(): Promise<void> {
|
||||||
|
helpFaqs.value = await getHelpFaqs()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
fetchProfile,
|
||||||
|
saveNickname,
|
||||||
|
changePassword: changePasswordAction,
|
||||||
|
bindPhone: bindPhoneAction,
|
||||||
|
bindEmail: bindEmailAction,
|
||||||
|
unbindEmail: unbindEmailAction,
|
||||||
|
verifyIdentity: verifyIdentityAction,
|
||||||
|
deleteAccount: deleteAccountAction,
|
||||||
|
addresses,
|
||||||
|
addressTotal,
|
||||||
|
addressQuery,
|
||||||
|
fetchAddresses,
|
||||||
|
createAddress: createAddressAction,
|
||||||
|
updateAddress: updateAddressAction,
|
||||||
|
deleteAddress: deleteAddressAction,
|
||||||
|
setDefaultAddress: setDefaultAddressAction,
|
||||||
|
changeAddressPage,
|
||||||
|
favorites,
|
||||||
|
favoritesTotal,
|
||||||
|
favoritesCounts,
|
||||||
|
favoritesQuery,
|
||||||
|
fetchFavorites,
|
||||||
|
fetchFavoritesCounts,
|
||||||
|
searchFavorites,
|
||||||
|
resetFavorites,
|
||||||
|
changeFavoritesPage,
|
||||||
|
qualifications,
|
||||||
|
qualificationTotal,
|
||||||
|
qualificationQuery,
|
||||||
|
fetchQualifications,
|
||||||
|
createQualification: createQualificationAction,
|
||||||
|
updateQualification: updateQualificationAction,
|
||||||
|
deleteQualification: deleteQualificationAction,
|
||||||
|
changeQualificationPage,
|
||||||
|
submitFeedback: submitFeedbackAction,
|
||||||
|
helpFaqs,
|
||||||
|
fetchHelpFaqs,
|
||||||
|
}
|
||||||
|
})
|
||||||
106
src/stores/modules/project.ts
Normal file
106
src/stores/modules/project.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { PageResult } from '@/types/api'
|
||||||
|
import type { AfterSalesPayload, Project, ProjectQuery } from '@/types/business/project'
|
||||||
|
import {
|
||||||
|
cancelAfterSales as cancelAfterSalesApi,
|
||||||
|
deleteProjectFile,
|
||||||
|
getProjectDetail,
|
||||||
|
getProjectList,
|
||||||
|
submitAfterSales as submitAfterSalesApi,
|
||||||
|
uploadProjectFile,
|
||||||
|
} from '@/api/project'
|
||||||
|
|
||||||
|
function createDefaultQuery(): ProjectQuery {
|
||||||
|
return {
|
||||||
|
keyword: '',
|
||||||
|
businessType: 'all',
|
||||||
|
status: 'all',
|
||||||
|
timeShortcut: 'all',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目中心数据(列表 / 筛选 / 分页 / 详情 / 文件 / 售后) */
|
||||||
|
export const useProjectStore = defineStore('project', () => {
|
||||||
|
const query = ref<ProjectQuery>(createDefaultQuery())
|
||||||
|
const list = ref<Project[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const current = ref<Project | null>(null)
|
||||||
|
|
||||||
|
async function fetchList(): Promise<void> {
|
||||||
|
const result: PageResult<Project> = await getProjectList(query.value)
|
||||||
|
list.value = result.list
|
||||||
|
total.value = result.total
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetail(id: string): Promise<void> {
|
||||||
|
current.value = await getProjectDetail(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuery(next: ProjectQuery): void {
|
||||||
|
query.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确定筛选(重置页码后取数) */
|
||||||
|
async function search(): Promise<void> {
|
||||||
|
query.value = { ...query.value, page: 1 }
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置全部条件 */
|
||||||
|
async function resetQuery(): Promise<void> {
|
||||||
|
query.value = createDefaultQuery()
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePage(page: number): Promise<void> {
|
||||||
|
query.value = { ...query.value, page }
|
||||||
|
await fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传项目文件(追加到当前详情) */
|
||||||
|
async function uploadFile(file: File): Promise<void> {
|
||||||
|
const info = await uploadProjectFile(file)
|
||||||
|
current.value?.files.push(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除当前详情中的项目文件 */
|
||||||
|
async function deleteFile(index: number): Promise<void> {
|
||||||
|
await deleteProjectFile(index)
|
||||||
|
current.value?.files.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交售后工单 */
|
||||||
|
async function submitAfterSales(payload: AfterSalesPayload): Promise<void> {
|
||||||
|
if (!current.value) return
|
||||||
|
current.value.afterSales = await submitAfterSalesApi(current.value.id, payload)
|
||||||
|
ElMessage.success('已提交售后工单')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消售后工单 */
|
||||||
|
async function cancelAfterSales(): Promise<void> {
|
||||||
|
if (!current.value) return
|
||||||
|
current.value.afterSales = await cancelAfterSalesApi(current.value.id)
|
||||||
|
ElMessage.success('已取消售后工单')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
current,
|
||||||
|
fetchList,
|
||||||
|
fetchDetail,
|
||||||
|
setQuery,
|
||||||
|
search,
|
||||||
|
resetQuery,
|
||||||
|
changePage,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
submitAfterSales,
|
||||||
|
cancelAfterSales,
|
||||||
|
}
|
||||||
|
})
|
||||||
60
src/stores/modules/service.ts
Normal file
60
src/stores/modules/service.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { ChatMessage } from '@/types/business/service'
|
||||||
|
import {
|
||||||
|
getChatHistory,
|
||||||
|
mockAutoReply,
|
||||||
|
sendChatMessage,
|
||||||
|
uploadChatFile,
|
||||||
|
} from '@/api/service'
|
||||||
|
|
||||||
|
/** 在线客服会话数据(消息列表 / 发送状态) */
|
||||||
|
export const useServiceStore = defineStore('service', () => {
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const sending = ref(false)
|
||||||
|
|
||||||
|
async function fetchHistory(): Promise<void> {
|
||||||
|
messages.value = await getChatHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送文本:先推入用户消息,600ms 后推入客服自动回复 */
|
||||||
|
async function send(content: string): Promise<void> {
|
||||||
|
const trimmed = content.trim()
|
||||||
|
if (!trimmed || sending.value) return
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
const userMsg = await sendChatMessage(trimmed)
|
||||||
|
messages.value.push(userMsg)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
void mockAutoReply().then((reply) => {
|
||||||
|
messages.value.push(reply)
|
||||||
|
sending.value = false
|
||||||
|
})
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送文件卡片消息 */
|
||||||
|
async function sendFile(file: File): Promise<void> {
|
||||||
|
if (sending.value) return
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
const fileMsg = await uploadChatFile(file)
|
||||||
|
messages.value.push(fileMsg)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
sending,
|
||||||
|
fetchHistory,
|
||||||
|
send,
|
||||||
|
sendFile,
|
||||||
|
}
|
||||||
|
})
|
||||||
24
src/stores/modules/site.ts
Normal file
24
src/stores/modules/site.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/** 站点公共数据(站点配置、全局 loading),非页面级 */
|
||||||
|
export const useSiteStore = defineStore('site', () => {
|
||||||
|
const globalLoading = ref(false)
|
||||||
|
|
||||||
|
// TODO(api): 站点配置接入后由 fetchSiteConfig 覆盖默认值
|
||||||
|
const siteInfo = ref({
|
||||||
|
appName: '虫子智造',
|
||||||
|
hotline: '400-XXX-XXXX',
|
||||||
|
businessEmail: 'business@chongzizhizao.com',
|
||||||
|
supportEmail: 'support@chongzizhizao.com',
|
||||||
|
workTime: '周一-周五 9:00-18:00',
|
||||||
|
icp: 'ICP 备 XXXXXXXX 号',
|
||||||
|
police: '公网安备 XXXXXXXXXXXX 号',
|
||||||
|
})
|
||||||
|
|
||||||
|
function setGlobalLoading(value: boolean): void {
|
||||||
|
globalLoading.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return { globalLoading, siteInfo, setGlobalLoading }
|
||||||
|
})
|
||||||
137
src/stores/modules/user.ts
Normal file
137
src/stores/modules/user.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type {
|
||||||
|
LoginByAccountParams,
|
||||||
|
LoginBySmsParams,
|
||||||
|
LoginResult,
|
||||||
|
RegisterParams,
|
||||||
|
ResetPasswordParams,
|
||||||
|
User,
|
||||||
|
} from '@/types/business/user'
|
||||||
|
import {
|
||||||
|
loginByAccount as loginByAccountApi,
|
||||||
|
loginBySms as loginBySmsApi,
|
||||||
|
register as registerApi,
|
||||||
|
resetPassword as resetPasswordApi,
|
||||||
|
sendSmsCode as sendSmsCodeApi,
|
||||||
|
} from '@/api/auth'
|
||||||
|
import { storage } from '@/utils/storage'
|
||||||
|
|
||||||
|
/** 用户登录态(M4:token 持久化 key 'token',路由守卫读取同一 key) */
|
||||||
|
export const useUserStore = defineStore('user', () => {
|
||||||
|
const token = ref<string | null>(storage.get<string>('token'))
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => Boolean(token.value))
|
||||||
|
|
||||||
|
/** 发送短信验证码 */
|
||||||
|
async function sendSmsCode(phone: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await sendSmsCodeApi(phone)
|
||||||
|
ElMessage.success('验证码已发送')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '验证码发送失败')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录成功统一落库(token 持久化) */
|
||||||
|
function applyLogin(result: LoginResult): void {
|
||||||
|
token.value = result.token
|
||||||
|
user.value = result.user
|
||||||
|
storage.set('token', result.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号密码登录 */
|
||||||
|
async function loginByAccount(params: LoginByAccountParams): Promise<boolean> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await loginByAccountApi(params)
|
||||||
|
applyLogin(result)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '登录失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手机验证码登录 */
|
||||||
|
async function loginBySms(params: LoginBySmsParams): Promise<boolean> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await loginBySmsApi(params)
|
||||||
|
applyLogin(result)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '登录失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册 */
|
||||||
|
async function register(params: RegisterParams): Promise<boolean> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await registerApi(params)
|
||||||
|
ElMessage.success('注册成功,请登录')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '注册失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置密码 */
|
||||||
|
async function resetPassword(params: ResetPasswordParams): Promise<boolean> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await resetPasswordApi(params)
|
||||||
|
ElMessage.success('修改成功,请登录')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '修改失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退出登录(清除持久化 token) */
|
||||||
|
function logout(): void {
|
||||||
|
token.value = null
|
||||||
|
user.value = null
|
||||||
|
storage.remove('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
isLoggedIn,
|
||||||
|
sendSmsCode,
|
||||||
|
loginByAccount,
|
||||||
|
loginBySms,
|
||||||
|
register,
|
||||||
|
resetPassword,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
})
|
||||||
25
src/styles/common.less
Normal file
25
src/styles/common.less
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// 通用工具类
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: @container-width;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-ellipsis {
|
||||||
|
.ellipsis();
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
3
src/styles/index.less
Normal file
3
src/styles/index.less
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// 样式入口(main.ts 引入;variables/mixins 由 vite additionalData 自动注入)
|
||||||
|
@import './reset.less';
|
||||||
|
@import './common.less';
|
||||||
37
src/styles/mixins.less
Normal file
37
src/styles/mixins.less
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// 混合宏(flex、文本省略、滚动条)
|
||||||
|
|
||||||
|
.flex(@direction: row; @justify: flex-start; @align: center) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: @direction;
|
||||||
|
justify-content: @justify;
|
||||||
|
align-items: @align;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis() {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-ellipsis(@lines: 2) {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: @lines;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar(@width: 6px) {
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: @width;
|
||||||
|
height: @width;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: @border-color-dark;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/styles/reset.less
Normal file
64
src/styles/reset.less
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// 样式重置
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: @text-color;
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
line-height: 1.5;
|
||||||
|
background-color: @bg-color;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
56
src/styles/variables.less
Normal file
56
src/styles/variables.less
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// ★ less 变量唯一来源(颜色/间距/字号/圆角)
|
||||||
|
// 注:蓝湖设计稿到位前为原型近似值,到位后按标注精确校准
|
||||||
|
|
||||||
|
// 品牌色
|
||||||
|
@primary-color: #3370ff;
|
||||||
|
@primary-hover: #598bff;
|
||||||
|
@primary-light: #eaf1ff;
|
||||||
|
|
||||||
|
// 功能色
|
||||||
|
@success-color: #34c724;
|
||||||
|
@warning-color: #ff8f1f;
|
||||||
|
@danger-color: #f54a45;
|
||||||
|
@info-color: #8a94a6;
|
||||||
|
|
||||||
|
// 文字色
|
||||||
|
@heading-color: #1f2733;
|
||||||
|
@text-color: #525e71;
|
||||||
|
@text-color-secondary: #8a94a6;
|
||||||
|
@text-color-disabled: #b8c0cc;
|
||||||
|
|
||||||
|
// 背景 / 边框
|
||||||
|
@bg-color: #f5f6f8;
|
||||||
|
@bg-color-light: #fafbfc;
|
||||||
|
@border-color: #e4e7ec;
|
||||||
|
@border-color-dark: #cdd3dd;
|
||||||
|
@white: #ffffff;
|
||||||
|
|
||||||
|
// 圆角
|
||||||
|
@radius-sm: 4px;
|
||||||
|
@radius-base: 8px;
|
||||||
|
@radius-lg: 12px;
|
||||||
|
@radius-round: 999px;
|
||||||
|
|
||||||
|
// 间距
|
||||||
|
@spacing-xs: 4px;
|
||||||
|
@spacing-sm: 8px;
|
||||||
|
@spacing-base: 16px;
|
||||||
|
@spacing-lg: 24px;
|
||||||
|
@spacing-xl: 32px;
|
||||||
|
@spacing-xxl: 48px;
|
||||||
|
|
||||||
|
// 字号
|
||||||
|
@font-size-sm: 12px;
|
||||||
|
@font-size-base: 14px;
|
||||||
|
@font-size-lg: 16px;
|
||||||
|
@font-size-xl: 20px;
|
||||||
|
@font-size-xxl: 28px;
|
||||||
|
|
||||||
|
// 布局
|
||||||
|
@container-width: 1200px;
|
||||||
|
@header-height: 64px;
|
||||||
|
@footer-height: 240px;
|
||||||
|
|
||||||
|
// 阴影
|
||||||
|
@shadow-base: 0 2px 8px rgba(31, 39, 51, 0.08);
|
||||||
|
@shadow-lg: 0 4px 16px rgba(31, 39, 51, 0.12);
|
||||||
31
src/types/api.d.ts
vendored
Normal file
31
src/types/api.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// 接口通用结构(PRD 4.8)
|
||||||
|
|
||||||
|
/** 业务类型(跨模块共享) */
|
||||||
|
export type BusinessType = 'internet' | 'hardware' | 'iot' | 'manufacturing' | 'other'
|
||||||
|
|
||||||
|
/** 统一响应结构 */
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页结果 */
|
||||||
|
export interface PageResult<T> {
|
||||||
|
list: T[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用分页查询参数 */
|
||||||
|
export interface PageQuery {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件/文件信息 */
|
||||||
|
export interface FileInfo {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
uploadTime: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
38
src/types/business/cases.d.ts
vendored
Normal file
38
src/types/business/cases.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// 案例实体(PRD 4.1,home/cases/profile.favorites 共享)
|
||||||
|
|
||||||
|
import type { BusinessType } from '@/types/api'
|
||||||
|
|
||||||
|
/** 项目规模区间 */
|
||||||
|
export type ScaleRange = 'lt50w' | '50to100w' | '100to300w' | '300to500w' | 'gt500w'
|
||||||
|
|
||||||
|
/** 案例 */
|
||||||
|
export interface Case {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
category: BusinessType
|
||||||
|
cover: string
|
||||||
|
finishDate: string
|
||||||
|
teamSize: number
|
||||||
|
/** 规模展示值(如 '300万'、'10-50万'),筛选区间见 CaseQuery.scales */
|
||||||
|
scale: string
|
||||||
|
industry: string
|
||||||
|
duration: string
|
||||||
|
clientType: string
|
||||||
|
background: string
|
||||||
|
painPoints: string
|
||||||
|
requirements: string
|
||||||
|
deliverables: string
|
||||||
|
video: string
|
||||||
|
isFavorite: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 案例筛选条件 */
|
||||||
|
export interface CaseQuery {
|
||||||
|
category: BusinessType | 'all'
|
||||||
|
industries: string[]
|
||||||
|
scales: ScaleRange[]
|
||||||
|
dateRange: [string, string] | null
|
||||||
|
keyword: string
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
29
src/types/business/contract.d.ts
vendored
Normal file
29
src/types/business/contract.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// 合同中心实体(PRD M6)
|
||||||
|
|
||||||
|
import type { FileInfo } from '@/types/api'
|
||||||
|
|
||||||
|
/** 签署状态 */
|
||||||
|
export type SignStatus = '履行中' | '已过期' | '待签署'
|
||||||
|
|
||||||
|
/** 合同 */
|
||||||
|
export interface Contract {
|
||||||
|
id: string
|
||||||
|
contractNo: string
|
||||||
|
contractName: string
|
||||||
|
contractType: string
|
||||||
|
contractAmount: number
|
||||||
|
relatedProjectName: string
|
||||||
|
projectNo: string
|
||||||
|
signStatus: SignStatus
|
||||||
|
signDate?: string
|
||||||
|
effectiveDate: string
|
||||||
|
expiryDate: string
|
||||||
|
files: FileInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合同列表查询条件 */
|
||||||
|
export interface ContractQuery {
|
||||||
|
keyword: string
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
20
src/types/business/demand.d.ts
vendored
Normal file
20
src/types/business/demand.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// 需求提报实体(M3,page-architecture)
|
||||||
|
|
||||||
|
import type { BusinessType, FileInfo } from '@/types/api'
|
||||||
|
|
||||||
|
/** 预算范围 */
|
||||||
|
export type BudgetRange = 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | ''
|
||||||
|
|
||||||
|
/** 需求提报表单 */
|
||||||
|
export interface Demand {
|
||||||
|
businessType: BusinessType
|
||||||
|
projectName: string
|
||||||
|
attachments: FileInfo[]
|
||||||
|
coreDescription: string
|
||||||
|
expectedDuration: number | ''
|
||||||
|
budgetRange: 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | ''
|
||||||
|
userName: string
|
||||||
|
userPhone: string
|
||||||
|
remark: string
|
||||||
|
status: 'draft' | 'submitted'
|
||||||
|
}
|
||||||
92
src/types/business/finance.d.ts
vendored
Normal file
92
src/types/business/finance.d.ts
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// 财务中心实体(账单/支付/发票/抬头/统计)
|
||||||
|
|
||||||
|
/** 账单状态 */
|
||||||
|
export type BillStatus = '待支付' | '已支付' | '未到期'
|
||||||
|
|
||||||
|
/** 发票状态 */
|
||||||
|
export type InvoiceStatus = '已开票' | '开票中' | '未开票'
|
||||||
|
|
||||||
|
/** 支付方式 */
|
||||||
|
export type PayMethod = 'wechat' | 'alipay'
|
||||||
|
|
||||||
|
/** 账单 */
|
||||||
|
export interface Bill {
|
||||||
|
id: string
|
||||||
|
/** 分期计划名,如 '第一期·预付款 比例33%' */
|
||||||
|
planName: string
|
||||||
|
billNo: string
|
||||||
|
tradeNo?: string
|
||||||
|
projectName: string
|
||||||
|
amount: number
|
||||||
|
status: BillStatus
|
||||||
|
createTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账单查询条件 */
|
||||||
|
export interface BillQuery {
|
||||||
|
keyword: string
|
||||||
|
status: BillStatus | 'all'
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付单(二维码) */
|
||||||
|
export interface Payment {
|
||||||
|
billId: string
|
||||||
|
method: PayMethod
|
||||||
|
amount: number
|
||||||
|
qrExpiresInSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付结果 */
|
||||||
|
export interface PaymentResult {
|
||||||
|
method: PayMethod
|
||||||
|
amount: number
|
||||||
|
payTime: string
|
||||||
|
tradeNo: string
|
||||||
|
bills: Bill[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发票 */
|
||||||
|
export interface Invoice {
|
||||||
|
id: string
|
||||||
|
planName: string
|
||||||
|
billNo: string
|
||||||
|
tradeNo?: string
|
||||||
|
projectName: string
|
||||||
|
amount: number
|
||||||
|
status: InvoiceStatus
|
||||||
|
applyTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发票查询条件 */
|
||||||
|
export interface InvoiceQuery {
|
||||||
|
keyword: string
|
||||||
|
status: InvoiceStatus | 'all'
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发票抬头 */
|
||||||
|
export interface InvoiceTitle {
|
||||||
|
id: string
|
||||||
|
type: '企业单位' | '非企业单位'
|
||||||
|
title: string
|
||||||
|
taxNo: string
|
||||||
|
isDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增/编辑发票抬头入参 */
|
||||||
|
export interface InvoiceTitlePayload {
|
||||||
|
type: InvoiceTitle['type']
|
||||||
|
title: string
|
||||||
|
taxNo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 财务统计 */
|
||||||
|
export interface FinanceStats {
|
||||||
|
totalExpense: number
|
||||||
|
month: string
|
||||||
|
pendingCount: number
|
||||||
|
distribution: Array<{ name: string; value: number; color: string }>
|
||||||
|
}
|
||||||
34
src/types/business/home.d.ts
vendored
Normal file
34
src/types/business/home.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// 首页实体(PRD 3.1/3.2,page-architecture M1)
|
||||||
|
|
||||||
|
import type { BusinessType } from '@/types/api'
|
||||||
|
|
||||||
|
/** 数据看板 */
|
||||||
|
export interface HomeStats {
|
||||||
|
pending: number
|
||||||
|
inProgress: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快捷入口 */
|
||||||
|
export interface QuickLink {
|
||||||
|
label: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务类型子项 */
|
||||||
|
export interface ServiceType {
|
||||||
|
label: string
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务线(首页业务卡片 + 业务详情页共用) */
|
||||||
|
export interface BusinessLine {
|
||||||
|
id: BusinessType
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
slogan: string
|
||||||
|
icon: string
|
||||||
|
serviceContent: string
|
||||||
|
serviceTypes: ServiceType[]
|
||||||
|
video: string
|
||||||
|
}
|
||||||
27
src/types/business/message.d.ts
vendored
Normal file
27
src/types/business/message.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// 消息实体(PRD M8 消息中心)
|
||||||
|
|
||||||
|
import type { FileInfo } from '@/types/api'
|
||||||
|
|
||||||
|
/** 消息类型 */
|
||||||
|
export type MessageType = '在线客服' | '状态通知' | '系统通知'
|
||||||
|
|
||||||
|
/** 站内消息 */
|
||||||
|
export interface Message {
|
||||||
|
id: string
|
||||||
|
type: MessageType
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
/** 正文纯文本,详情页 pre-wrap 渲染 */
|
||||||
|
content: string
|
||||||
|
publishTime: string
|
||||||
|
isRead: boolean
|
||||||
|
attachments: FileInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息查询条件 */
|
||||||
|
export interface MessageQuery {
|
||||||
|
keyword: string
|
||||||
|
type: MessageType | 'all'
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
87
src/types/business/profile.d.ts
vendored
Normal file
87
src/types/business/profile.d.ts
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// 个人中心实体(M10:security/address/favorites/qualifications/feedback/help/about/hotline 共享)
|
||||||
|
|
||||||
|
import type { BusinessType, FileInfo } from '@/types/api'
|
||||||
|
|
||||||
|
/** 账号资料 */
|
||||||
|
export interface Profile {
|
||||||
|
nickname: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
/** 密码上次修改时间(展示值,如 '15天前') */
|
||||||
|
passwordModifiedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改密码入参 */
|
||||||
|
export interface ChangePasswordParams {
|
||||||
|
code: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收货地址 */
|
||||||
|
export interface Address {
|
||||||
|
id: string
|
||||||
|
receiverName: string
|
||||||
|
phone: string
|
||||||
|
region: string
|
||||||
|
detailAddress: string
|
||||||
|
isDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资质类型 */
|
||||||
|
export type QualificationType =
|
||||||
|
| '营业执照'
|
||||||
|
| '生产许可证'
|
||||||
|
| '体系认证'
|
||||||
|
| '检测报告'
|
||||||
|
| '授权书'
|
||||||
|
| '其他资质'
|
||||||
|
|
||||||
|
/** 资质审核状态 */
|
||||||
|
export type QualificationStatus = '已认证' | '审核中' | '未通过'
|
||||||
|
|
||||||
|
/** 资质 */
|
||||||
|
export interface Qualification {
|
||||||
|
id: string
|
||||||
|
type: QualificationType
|
||||||
|
certName: string
|
||||||
|
certNo: string
|
||||||
|
issuer: string
|
||||||
|
validFrom: string
|
||||||
|
validTo: string
|
||||||
|
status: QualificationStatus
|
||||||
|
attachments: FileInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资质新增/编辑提交载荷(id 存在为编辑) */
|
||||||
|
export type QualificationInput = Omit<Qualification, 'id' | 'status'> & { id?: string }
|
||||||
|
|
||||||
|
/** 意见反馈 */
|
||||||
|
export interface Feedback {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
attachments: FileInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表分页查询 */
|
||||||
|
export interface ListQuery {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我的收藏查询(关键字 + 分类 + 分页) */
|
||||||
|
export interface FavoritesQuery extends ListQuery {
|
||||||
|
keyword: string
|
||||||
|
category: BusinessType | 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 帮助中心 FAQ 条目 */
|
||||||
|
export interface HelpFaqItem {
|
||||||
|
q: string
|
||||||
|
a: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 帮助中心 FAQ 分组 */
|
||||||
|
export interface HelpFaqGroup {
|
||||||
|
title: string
|
||||||
|
items: HelpFaqItem[]
|
||||||
|
}
|
||||||
79
src/types/business/project.d.ts
vendored
Normal file
79
src/types/business/project.d.ts
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// 项目中心实体(PRD M5)
|
||||||
|
|
||||||
|
import type { BusinessType, FileInfo } from '@/types/api'
|
||||||
|
|
||||||
|
/** 项目状态 */
|
||||||
|
export type ProjectStatus = '待立项' | '进行中' | '待验收' | '已完成' | '售后中' | '已取消'
|
||||||
|
|
||||||
|
/** 里程碑节点 */
|
||||||
|
export interface Milestone {
|
||||||
|
name: string
|
||||||
|
status: '待开始' | '进行中' | '已完成'
|
||||||
|
time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 团队成员 */
|
||||||
|
export interface TeamMember {
|
||||||
|
role: string
|
||||||
|
name: string
|
||||||
|
avatarChar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目账单(付款计划) */
|
||||||
|
export interface ProjectBill {
|
||||||
|
id: string
|
||||||
|
planName: string
|
||||||
|
billNo: string
|
||||||
|
amount: number
|
||||||
|
status: '待支付' | '已支付' | '未到期'
|
||||||
|
tradeNo?: string
|
||||||
|
projectName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 售后工单 */
|
||||||
|
export interface AfterSales {
|
||||||
|
status: '无' | '处理中'
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
remark: string
|
||||||
|
attachments: FileInfo[]
|
||||||
|
createTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 售后提交入参 */
|
||||||
|
export interface AfterSalesPayload {
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
remark: string
|
||||||
|
attachments: FileInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目 */
|
||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
projectNo: string
|
||||||
|
projectName: string
|
||||||
|
businessType: BusinessType
|
||||||
|
progress: number
|
||||||
|
status: ProjectStatus
|
||||||
|
submitTime: string
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
milestones: Milestone[]
|
||||||
|
team: TeamMember[]
|
||||||
|
files: FileInfo[]
|
||||||
|
paymentPlans: ProjectBill[]
|
||||||
|
invoiceRecords: FileInfo[]
|
||||||
|
contractFile?: FileInfo
|
||||||
|
afterSales?: AfterSales
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目列表查询条件 */
|
||||||
|
export interface ProjectQuery {
|
||||||
|
keyword: string
|
||||||
|
businessType: BusinessType | 'all'
|
||||||
|
status: ProjectStatus | 'all'
|
||||||
|
timeShortcut: 'all' | '3m' | '6m' | '1y'
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
17
src/types/business/service.d.ts
vendored
Normal file
17
src/types/business/service.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// 在线客服会话实体(PRD M9 在线客服)
|
||||||
|
|
||||||
|
/** 会话角色 */
|
||||||
|
export type ChatRole = 'user' | 'assistant'
|
||||||
|
|
||||||
|
/** 会话消息 */
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
role: ChatRole
|
||||||
|
content: string
|
||||||
|
/** 文件消息卡片(有值时按文件渲染) */
|
||||||
|
file?: {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
time: string
|
||||||
|
}
|
||||||
47
src/types/business/user.d.ts
vendored
Normal file
47
src/types/business/user.d.ts
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// 用户/认证实体(M4,page-architecture)
|
||||||
|
|
||||||
|
/** 用户 */
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
nickname: string
|
||||||
|
avatar: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
passwordModifiedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账号密码登录参数 */
|
||||||
|
export interface LoginByAccountParams {
|
||||||
|
account: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 短信验证码登录参数 */
|
||||||
|
export interface LoginBySmsParams {
|
||||||
|
phone: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录结果 */
|
||||||
|
export interface LoginResult {
|
||||||
|
token: string
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册参数 */
|
||||||
|
export interface RegisterParams {
|
||||||
|
phone: string
|
||||||
|
code: string
|
||||||
|
password: string
|
||||||
|
confirmPassword: string
|
||||||
|
agree: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找回/重置密码参数 */
|
||||||
|
export interface ResetPasswordParams {
|
||||||
|
phone: string
|
||||||
|
code: string
|
||||||
|
password: string
|
||||||
|
confirmPassword: string
|
||||||
|
agree: boolean
|
||||||
|
}
|
||||||
8
src/types/global.d.ts
vendored
Normal file
8
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// 全局声明(Window 扩展等,按需添加)
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
// 全局挂载扩展位
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/types/router.d.ts
vendored
Normal file
11
src/types/router.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// RouteMeta 扩展
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
/** 页面标题(document.title) */
|
||||||
|
title?: string
|
||||||
|
/** 是否需要登录 */
|
||||||
|
requiresAuth?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/types/web/home.d.ts
vendored
Normal file
65
src/types/web/home.d.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// 后端「用户web端首页」接口真实返回类型
|
||||||
|
// 对应 vermis-maker: com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
|
||||||
|
// 统一前缀: /web/home-page (公开接口,无需登录)
|
||||||
|
// 字段与后端 VO 严格对齐(驼峰、可空用 | null)。
|
||||||
|
|
||||||
|
/** GET /web/home-page/project-status-count —— 项目状态统计 */
|
||||||
|
export interface ProjectStatusCount {
|
||||||
|
/** 项目数量 */
|
||||||
|
projectCount: number
|
||||||
|
/** 进行中的项目数目 */
|
||||||
|
ongoingProjectCount: number
|
||||||
|
/** 待处理数目 */
|
||||||
|
pendingProjectCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务类型子项(service-type-list / service-type-detail 内含) */
|
||||||
|
export interface ProjectServiceTypeVo {
|
||||||
|
id: number | null
|
||||||
|
projectId: number | null
|
||||||
|
serviceName: string | null
|
||||||
|
iconUrl: string | null
|
||||||
|
sortOrder: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目背景(富文本) */
|
||||||
|
export interface ProjectBackgroundVo {
|
||||||
|
id: number | null
|
||||||
|
projectId: number | null
|
||||||
|
content: string | null
|
||||||
|
sortOrder: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目提报视图对象:service-type-list / service-type-detail/{id} 的返回 */
|
||||||
|
export interface ProjectSubmissionVo {
|
||||||
|
id: number
|
||||||
|
businessType: string | null
|
||||||
|
title: string | null
|
||||||
|
subtitle: string | null
|
||||||
|
/** 是否启用:1 启用,0 停用 */
|
||||||
|
enabled: number | null
|
||||||
|
coverUrl: string | null
|
||||||
|
logoUrl: string | null
|
||||||
|
serviceTypes: ProjectServiceTypeVo[]
|
||||||
|
backgrounds: ProjectBackgroundVo[]
|
||||||
|
/** yyyy-MM-dd HH:mm:ss */
|
||||||
|
updateTime: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /web/home-page/carousel-list —— 轮播图 */
|
||||||
|
export interface BannerVo {
|
||||||
|
id: number | null
|
||||||
|
imageUrl: string | null
|
||||||
|
sort: number | null
|
||||||
|
/** 是否启用(true 启用,false 停用) */
|
||||||
|
enabled: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /web/home-page/platform-intro、contact-info —— 协议/静态文章富文本 */
|
||||||
|
export interface AgreementFormVo {
|
||||||
|
id: number | null
|
||||||
|
/** 协议类型(目前只有「关于我们」一条) */
|
||||||
|
type: string | null
|
||||||
|
/** 文章内容(富文本 HTML) */
|
||||||
|
content: string | null
|
||||||
|
}
|
||||||
67
src/utils/format.ts
Normal file
67
src/utils/format.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// 格式化工具(无 Vue 依赖)
|
||||||
|
|
||||||
|
import type { BusinessType } from '@/types/api'
|
||||||
|
import type { ScaleRange } from '@/types/business/cases'
|
||||||
|
|
||||||
|
const BUSINESS_TYPE_LABELS: Record<BusinessType, string> = {
|
||||||
|
internet: '互联网软件',
|
||||||
|
hardware: '硬件电路',
|
||||||
|
iot: '物联网全案',
|
||||||
|
manufacturing: '生产制造',
|
||||||
|
other: '其他',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCALE_LABELS: Record<ScaleRange, string> = {
|
||||||
|
lt50w: '50万以下',
|
||||||
|
'50to100w': '50-100万',
|
||||||
|
'100to300w': '100-300万',
|
||||||
|
'300to500w': '300-500万',
|
||||||
|
gt500w: '500万以上',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务类型展示名 */
|
||||||
|
export function formatBusinessType(type: BusinessType): string {
|
||||||
|
return BUSINESS_TYPE_LABELS[type]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目规模展示名 */
|
||||||
|
export function formatScale(scale: ScaleRange): string {
|
||||||
|
return SCALE_LABELS[scale]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 金额千分位(默认两位小数) */
|
||||||
|
export function formatMoney(value: number, decimals = 2): string {
|
||||||
|
const fixed = value.toFixed(decimals)
|
||||||
|
const [int = '0', fraction] = fixed.split('.')
|
||||||
|
const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
return fraction ? `${withSep}.${fraction}` : withSep
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return n < 10 ? `0${n}` : String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日期格式化(YYYY-MM-DD HH:mm:ss 可裁剪) */
|
||||||
|
export function formatDate(input: Date | string | number, pattern = 'YYYY-MM-DD HH:mm:ss'): string {
|
||||||
|
const date = input instanceof Date ? input : new Date(input)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
YYYY: String(date.getFullYear()),
|
||||||
|
MM: pad(date.getMonth() + 1),
|
||||||
|
DD: pad(date.getDate()),
|
||||||
|
HH: pad(date.getHours()),
|
||||||
|
mm: pad(date.getMinutes()),
|
||||||
|
ss: pad(date.getSeconds()),
|
||||||
|
}
|
||||||
|
return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (token) => map[token] ?? token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件大小(B/KB/MB/GB) */
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes}B`
|
||||||
|
const kb = bytes / 1024
|
||||||
|
if (kb < 1024) return `${kb.toFixed(1)}KB`
|
||||||
|
const mb = kb / 1024
|
||||||
|
if (mb < 1024) return `${mb.toFixed(1)}MB`
|
||||||
|
return `${(mb / 1024).toFixed(1)}GB`
|
||||||
|
}
|
||||||
43
src/utils/request.ts
Normal file
43
src/utils/request.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import axios, { type AxiosRequestConfig } from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { ApiResponse } from '@/types/api'
|
||||||
|
import { storage } from '@/utils/storage'
|
||||||
|
|
||||||
|
// 后端 R.ok() 成功码为 200(org.dromara.common.core.constant.HttpStatus.SUCCESS)。
|
||||||
|
// 鉴权头 Authorization: Bearer <token> 已与后端 sa-token.token-prefix=Bearer 对齐,无需改动。
|
||||||
|
const SUCCESS_CODE = 200
|
||||||
|
|
||||||
|
/** 全项目唯一 axios 实例 */
|
||||||
|
const service = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
service.interceptors.request.use((config) => {
|
||||||
|
const token = storage.get<string>('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
service.interceptors.response.use(undefined, (error) => {
|
||||||
|
ElMessage.error(error?.message || '网络异常,请稍后重试')
|
||||||
|
return Promise.reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一请求:自动解包 ApiResponse<T>,失败统一提示并 reject
|
||||||
|
* 页面/组件禁止直接 import axios,一律经 store 调用本函数
|
||||||
|
*/
|
||||||
|
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||||
|
const resp = await service.request<ApiResponse<T>>(config)
|
||||||
|
const payload = resp.data
|
||||||
|
if (payload.code !== SUCCESS_CODE) {
|
||||||
|
ElMessage.error(payload.msg || '请求失败')
|
||||||
|
return Promise.reject(new Error(payload.msg || '请求失败'))
|
||||||
|
}
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export default service
|
||||||
34
src/utils/storage.ts
Normal file
34
src/utils/storage.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// localStorage/sessionStorage 泛型封装(无 Vue 依赖)
|
||||||
|
|
||||||
|
const PREFIX = 'cz:'
|
||||||
|
|
||||||
|
function parse<T>(raw: string | null): T | null {
|
||||||
|
if (raw === null) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storage = {
|
||||||
|
get<T>(key: string): T | null {
|
||||||
|
return parse<T>(localStorage.getItem(PREFIX + key))
|
||||||
|
},
|
||||||
|
set<T>(key: string, value: T): void {
|
||||||
|
localStorage.setItem(PREFIX + key, JSON.stringify(value))
|
||||||
|
},
|
||||||
|
remove(key: string): void {
|
||||||
|
localStorage.removeItem(PREFIX + key)
|
||||||
|
},
|
||||||
|
sessionGet<T>(key: string): T | null {
|
||||||
|
return parse<T>(sessionStorage.getItem(PREFIX + key))
|
||||||
|
},
|
||||||
|
sessionSet<T>(key: string, value: T): void {
|
||||||
|
sessionStorage.setItem(PREFIX + key, JSON.stringify(value))
|
||||||
|
},
|
||||||
|
sessionRemove(key: string): void {
|
||||||
|
sessionStorage.removeItem(PREFIX + key)
|
||||||
|
},
|
||||||
|
}
|
||||||
36
src/utils/validate.ts
Normal file
36
src/utils/validate.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// 校验规则(无 Vue 依赖)
|
||||||
|
|
||||||
|
/** 手机号 */
|
||||||
|
export function isPhone(value: string): boolean {
|
||||||
|
return /^1[3-9]\d{9}$/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 邮箱 */
|
||||||
|
export function isEmail(value: string): boolean {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL */
|
||||||
|
export function isUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\/[^\s]+$/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PasswordLevel = 'weak' | 'medium' | 'strong'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码强度等级(6-20 位)
|
||||||
|
* weak:仅字母或仅数字;medium:字母+数字;strong:字母+数字+特殊符号
|
||||||
|
*/
|
||||||
|
export function getPasswordLevel(password: string): PasswordLevel {
|
||||||
|
const hasLetter = /[a-zA-Z]/.test(password)
|
||||||
|
const hasDigit = /\d/.test(password)
|
||||||
|
const hasSymbol = /[^\w\s]/.test(password)
|
||||||
|
if (hasLetter && hasDigit && hasSymbol) return 'strong'
|
||||||
|
if (hasLetter && hasDigit) return 'medium'
|
||||||
|
return 'weak'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录密码合法性(6-20 位) */
|
||||||
|
export function isValidPassword(password: string): boolean {
|
||||||
|
return password.length >= 6 && password.length <= 20
|
||||||
|
}
|
||||||
35
src/views/404.vue
Normal file
35
src/views/404.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
function goHome(): void {
|
||||||
|
window.location.href = '/'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="not-found">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p class="text-secondary">
|
||||||
|
页面不存在或已下线
|
||||||
|
</p>
|
||||||
|
<button class="not-found__back link" @click="goHome">
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.not-found {
|
||||||
|
.flex(column, center);
|
||||||
|
gap: @spacing-base;
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 64px;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__back {
|
||||||
|
color: @primary-color;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
164
src/views/cases/cpns/CaseDetailHeader.vue
Normal file
164
src/views/cases/cpns/CaseDetailHeader.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { formatBusinessType } from '@/utils/format'
|
||||||
|
import type { Case } from '@/types/business/cases'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: Case
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
favorite: [id: string]
|
||||||
|
share: [id: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="case-header">
|
||||||
|
<div class="case-header__main">
|
||||||
|
<div class="case-header__head">
|
||||||
|
<h1 class="case-header__title">
|
||||||
|
{{ item.title }}
|
||||||
|
</h1>
|
||||||
|
<span class="case-header__tag">{{ formatBusinessType(item.category) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="case-header__actions">
|
||||||
|
<button
|
||||||
|
class="case-header__btn"
|
||||||
|
:class="{ 'is-favorite': item.isFavorite }"
|
||||||
|
@click="emit('favorite', item.id)"
|
||||||
|
>
|
||||||
|
{{ item.isFavorite ? '★ 已收藏' : '☆ 收藏' }}
|
||||||
|
</button>
|
||||||
|
<button class="case-header__btn" @click="emit('share', item.id)">
|
||||||
|
分享
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="case-header__meta">
|
||||||
|
<div class="case-header__meta-item">
|
||||||
|
<span class="text-secondary">完成时间</span>
|
||||||
|
<span>{{ item.finishDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="case-header__meta-item">
|
||||||
|
<span class="text-secondary">服务团队</span>
|
||||||
|
<span>{{ item.teamSize }}人</span>
|
||||||
|
</div>
|
||||||
|
<div class="case-header__meta-item">
|
||||||
|
<span class="text-secondary">规模</span>
|
||||||
|
<span>{{ item.scale }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="case-header__meta-item">
|
||||||
|
<span class="text-secondary">所属行业</span>
|
||||||
|
<span>{{ item.industry }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="case-header__meta-item">
|
||||||
|
<span class="text-secondary">项目周期</span>
|
||||||
|
<span>{{ item.duration }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="case-header__meta-item">
|
||||||
|
<span class="text-secondary">客户类型</span>
|
||||||
|
<span>{{ item.clientType }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="case-header__video">
|
||||||
|
<!-- TODO(蓝湖): 接入真实视频源 -->
|
||||||
|
<span>视频将在预览时播放</span>
|
||||||
|
<span class="case-header__video-time">0:00 / 0:00</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.case-header {
|
||||||
|
.flex(row, flex-start);
|
||||||
|
gap: @spacing-lg;
|
||||||
|
padding: @spacing-xl;
|
||||||
|
border-radius: @radius-lg;
|
||||||
|
background: @white;
|
||||||
|
box-shadow: @shadow-base;
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
margin-bottom: @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: @font-size-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tag {
|
||||||
|
padding: 2px @spacing-sm;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @primary-light;
|
||||||
|
color: @primary-color;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
.flex(row, flex-start);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
margin-bottom: @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 @spacing-base;
|
||||||
|
border: 1px solid @border-color-dark;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @white;
|
||||||
|
color: @text-color;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: @primary-color;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-favorite {
|
||||||
|
border-color: @warning-color;
|
||||||
|
color: @warning-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta-item {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: 4px;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__video {
|
||||||
|
.flex(column, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
width: 320px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @heading-color;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__video-time {
|
||||||
|
color: @text-color-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
src/views/cases/cpns/CaseRichText.vue
Normal file
57
src/views/cases/cpns/CaseRichText.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Case } from '@/types/business/cases'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: Case
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ key: 'background', title: '项目背景', field: 'background' },
|
||||||
|
{ key: 'painPoints', title: '客户痛点', field: 'painPoints' },
|
||||||
|
{ key: 'requirements', title: '需求描述', field: 'requirements' },
|
||||||
|
{ key: 'deliverables', title: '交付物', field: 'deliverables' },
|
||||||
|
] as const
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="case-rich">
|
||||||
|
<section v-for="section in sections" :key="section.key" class="case-rich__section">
|
||||||
|
<h3 class="case-rich__title">
|
||||||
|
{{ section.title }}
|
||||||
|
</h3>
|
||||||
|
<p class="case-rich__text">{{ item[section.field] }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.case-rich {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: @spacing-base;
|
||||||
|
|
||||||
|
&__section {
|
||||||
|
padding: @spacing-lg @spacing-xl;
|
||||||
|
border-radius: @radius-lg;
|
||||||
|
background: @white;
|
||||||
|
box-shadow: @shadow-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin-bottom: @spacing-sm;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
padding-left: @spacing-sm;
|
||||||
|
border-left: 3px solid @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: @font-size-base;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: @text-color;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
324
src/views/cases/cpns/CasesFilter.vue
Normal file
324
src/views/cases/cpns/CasesFilter.vue
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { ElDatePicker } from 'element-plus'
|
||||||
|
import { FilterPanel, type FilterChip } from '@/components'
|
||||||
|
import { formatBusinessType, formatDate, formatScale } from '@/utils/format'
|
||||||
|
import type { BusinessType } from '@/types/api'
|
||||||
|
import type { CaseQuery, ScaleRange } from '@/types/business/cases'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
query: CaseQuery
|
||||||
|
counts: Partial<Record<BusinessType, number>>
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:query': [query: CaseQuery]
|
||||||
|
search: []
|
||||||
|
reset: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom'
|
||||||
|
|
||||||
|
const activeShortcut = ref<TimeShortcut>('all')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.query.dateRange,
|
||||||
|
(value) => {
|
||||||
|
if (!value) activeShortcut.value = 'all'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const categoryOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '互联网软件', value: 'internet' },
|
||||||
|
{ label: '硬件电路', value: 'hardware' },
|
||||||
|
{ label: '物联网全案', value: 'iot' },
|
||||||
|
{ label: '生产制造', value: 'manufacturing' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const industryOptions = [
|
||||||
|
'电商零售', '教育培训', '医疗健康', '硬件电路', '能源环保',
|
||||||
|
'房地产', '自动化', '工厂服务', '智能设备', '园区服务',
|
||||||
|
]
|
||||||
|
|
||||||
|
const scaleOptions: Array<{ label: string; value: ScaleRange }> = [
|
||||||
|
{ label: '50万以下', value: 'lt50w' },
|
||||||
|
{ label: '50-100万', value: '50to100w' },
|
||||||
|
{ label: '100-300万', value: '100to300w' },
|
||||||
|
{ label: '300-500万', value: '300to500w' },
|
||||||
|
{ label: '500万以上', value: 'gt500w' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const timeShortcuts: Array<{ label: string; value: TimeShortcut }> = [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '近3个月', value: '3m' },
|
||||||
|
{ label: '近半年', value: '6m' },
|
||||||
|
{ label: '近一年', value: '1y' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const keyword = computed({
|
||||||
|
get: () => props.query.keyword,
|
||||||
|
set: (value: string) => emit('update:query', { ...props.query, keyword: value }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const chips = computed<FilterChip[]>(() => {
|
||||||
|
const list: FilterChip[] = []
|
||||||
|
if (props.query.category !== 'all') {
|
||||||
|
list.push({ key: 'category', label: formatBusinessType(props.query.category) })
|
||||||
|
}
|
||||||
|
for (const industry of props.query.industries) {
|
||||||
|
list.push({ key: `industry:${industry}`, label: industry })
|
||||||
|
}
|
||||||
|
for (const scale of props.query.scales) {
|
||||||
|
list.push({ key: `scale:${scale}`, label: formatScale(scale) })
|
||||||
|
}
|
||||||
|
if (props.query.dateRange) {
|
||||||
|
const shortcutLabel = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
|
||||||
|
list.push({
|
||||||
|
key: 'time',
|
||||||
|
label: shortcutLabel && shortcutLabel !== '全部'
|
||||||
|
? shortcutLabel
|
||||||
|
: `${props.query.dateRange[0]}—${props.query.dateRange[1]}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
function patch(partial: Partial<CaseQuery>): void {
|
||||||
|
emit('update:query', { ...props.query, ...partial })
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCategory(value: BusinessType | 'all'): void {
|
||||||
|
patch({ category: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleIndustry(industry: string): void {
|
||||||
|
const next = props.query.industries.includes(industry)
|
||||||
|
? props.query.industries.filter((item) => item !== industry)
|
||||||
|
: [...props.query.industries, industry]
|
||||||
|
patch({ industries: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScale(scale: ScaleRange): void {
|
||||||
|
const next = props.query.scales.includes(scale)
|
||||||
|
? props.query.scales.filter((item) => item !== scale)
|
||||||
|
: [...props.query.scales, scale]
|
||||||
|
patch({ scales: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShortcut(value: TimeShortcut): void {
|
||||||
|
activeShortcut.value = value
|
||||||
|
if (value === 'all') {
|
||||||
|
patch({ dateRange: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date()
|
||||||
|
if (value === '3m') start.setMonth(start.getMonth() - 3)
|
||||||
|
if (value === '6m') start.setMonth(start.getMonth() - 6)
|
||||||
|
if (value === '1y') start.setFullYear(start.getFullYear() - 1)
|
||||||
|
patch({
|
||||||
|
dateRange: [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateRange(value: [Date, Date] | null): void {
|
||||||
|
activeShortcut.value = 'custom'
|
||||||
|
patch({
|
||||||
|
dateRange: value
|
||||||
|
? [formatDate(value[0], 'YYYY-MM-DD'), formatDate(value[1], 'YYYY-MM-DD')]
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemoveChip(key: string): void {
|
||||||
|
if (key === 'category') {
|
||||||
|
setCategory('all')
|
||||||
|
}
|
||||||
|
else if (key === 'time') {
|
||||||
|
activeShortcut.value = 'all'
|
||||||
|
patch({ dateRange: null })
|
||||||
|
}
|
||||||
|
else if (key.startsWith('industry:')) {
|
||||||
|
const industry = key.slice('industry:'.length)
|
||||||
|
patch({ industries: props.query.industries.filter((item) => item !== industry) })
|
||||||
|
}
|
||||||
|
else if (key.startsWith('scale:')) {
|
||||||
|
const scale = key.slice('scale:'.length) as ScaleRange
|
||||||
|
patch({ scales: props.query.scales.filter((item) => item !== scale) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClearChips(): void {
|
||||||
|
activeShortcut.value = 'all'
|
||||||
|
emit('update:query', {
|
||||||
|
...props.query,
|
||||||
|
category: 'all',
|
||||||
|
industries: [],
|
||||||
|
scales: [],
|
||||||
|
dateRange: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FilterPanel
|
||||||
|
v-model:keyword="keyword"
|
||||||
|
:chips="chips"
|
||||||
|
:show-result-count="true"
|
||||||
|
:result-count="total"
|
||||||
|
search-label="确定筛选"
|
||||||
|
@search="emit('search')"
|
||||||
|
@reset="emit('reset')"
|
||||||
|
@remove-chip="onRemoveChip"
|
||||||
|
@clear-chips="onClearChips"
|
||||||
|
>
|
||||||
|
<!-- 业务类型(单选 + 计数) -->
|
||||||
|
<div class="cases-filter__group">
|
||||||
|
<span class="cases-filter__group-title">业务类型</span>
|
||||||
|
<div class="cases-filter__options">
|
||||||
|
<button
|
||||||
|
v-for="option in categoryOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="cases-filter__option"
|
||||||
|
:class="{ 'is-active': query.category === option.value }"
|
||||||
|
@click="setCategory(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
<span class="cases-filter__count">
|
||||||
|
{{ option.value === 'all' ? total : (counts[option.value] ?? 0) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 所属行业(多选) -->
|
||||||
|
<div class="cases-filter__group">
|
||||||
|
<span class="cases-filter__group-title">所属行业</span>
|
||||||
|
<div class="cases-filter__options">
|
||||||
|
<button
|
||||||
|
class="cases-filter__option"
|
||||||
|
:class="{ 'is-active': query.industries.length === 0 }"
|
||||||
|
@click="patch({ industries: [] })"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="industry in industryOptions"
|
||||||
|
:key="industry"
|
||||||
|
class="cases-filter__option"
|
||||||
|
:class="{ 'is-active': query.industries.includes(industry) }"
|
||||||
|
@click="toggleIndustry(industry)"
|
||||||
|
>
|
||||||
|
{{ industry }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 项目规模(多选) -->
|
||||||
|
<div class="cases-filter__group">
|
||||||
|
<span class="cases-filter__group-title">项目规模</span>
|
||||||
|
<div class="cases-filter__options">
|
||||||
|
<button
|
||||||
|
class="cases-filter__option"
|
||||||
|
:class="{ 'is-active': query.scales.length === 0 }"
|
||||||
|
@click="patch({ scales: [] })"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="scale in scaleOptions"
|
||||||
|
:key="scale.value"
|
||||||
|
class="cases-filter__option"
|
||||||
|
:class="{ 'is-active': query.scales.includes(scale.value) }"
|
||||||
|
@click="toggleScale(scale.value)"
|
||||||
|
>
|
||||||
|
{{ scale.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 完成时间(快捷区间 + 自定义) -->
|
||||||
|
<div class="cases-filter__group">
|
||||||
|
<span class="cases-filter__group-title">完成时间</span>
|
||||||
|
<div class="cases-filter__options">
|
||||||
|
<button
|
||||||
|
v-for="shortcut in timeShortcuts"
|
||||||
|
:key="shortcut.value"
|
||||||
|
class="cases-filter__option"
|
||||||
|
:class="{ 'is-active': activeShortcut === shortcut.value }"
|
||||||
|
@click="setShortcut(shortcut.value)"
|
||||||
|
>
|
||||||
|
{{ shortcut.label }}
|
||||||
|
</button>
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="query.dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="—"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
class="cases-filter__date"
|
||||||
|
@change="onDateRange"
|
||||||
|
/> </div>
|
||||||
|
</div>
|
||||||
|
</FilterPanel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.cases-filter {
|
||||||
|
&__group {
|
||||||
|
.flex(row, flex-start);
|
||||||
|
gap: @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__group-title {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 70px;
|
||||||
|
padding-top: 5px;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__options {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: @spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__option {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 @spacing-sm;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: @radius-sm;
|
||||||
|
background: @bg-color;
|
||||||
|
color: @text-color;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
border-color: @primary-color;
|
||||||
|
background: @primary-light;
|
||||||
|
color: @primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
margin-left: 4px;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
43
src/views/cases/cpns/CasesGrid.vue
Normal file
43
src/views/cases/cpns/CasesGrid.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CaseCard } from '@/components'
|
||||||
|
import type { Case } from '@/types/business/cases'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cases: Case[]
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ view: [id: string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cases-grid">
|
||||||
|
<template v-if="cases.length > 0">
|
||||||
|
<CaseCard
|
||||||
|
v-for="item in cases"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
@view="emit('view', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<p v-else class="cases-grid__empty text-secondary">
|
||||||
|
暂无符合条件的案例,请调整筛选条件
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.cases-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: @spacing-base;
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: @spacing-xxl 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
src/views/cases/detail.vue
Normal file
90
src/views/cases/detail.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { AppButton } from '@/components'
|
||||||
|
// 页面容器:数据只经 store,不直接 import api
|
||||||
|
import { useCasesStore } from '@/stores/modules/cases'
|
||||||
|
import CaseDetailHeader from './cpns/CaseDetailHeader.vue'
|
||||||
|
import CaseRichText from './cpns/CaseRichText.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const casesStore = useCasesStore()
|
||||||
|
|
||||||
|
const item = computed(() => casesStore.currentCase)
|
||||||
|
const caseId = computed(() => String(route.params.id))
|
||||||
|
|
||||||
|
function load(): void {
|
||||||
|
void casesStore.fetchCaseDetail(caseId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFavorite(id: string): Promise<void> {
|
||||||
|
await casesStore.toggleFavorite(id)
|
||||||
|
ElMessage.success(item.value?.isFavorite ? '已加入收藏' : '已取消收藏')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onShare(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(window.location.href)
|
||||||
|
ElMessage.success('链接已复制,快去分享吧')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
ElMessage.error('复制失败,请手动复制地址栏链接')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(caseId, load)
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="case-detail">
|
||||||
|
<nav class="case-detail__breadcrumb text-secondary">
|
||||||
|
<RouterLink to="/cases" class="link">
|
||||||
|
案例库
|
||||||
|
</RouterLink>
|
||||||
|
<span> > </span>
|
||||||
|
<span>案例详情</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<template v-if="item">
|
||||||
|
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
|
||||||
|
<CaseRichText :item="item" />
|
||||||
|
|
||||||
|
<div class="case-detail__cta">
|
||||||
|
<AppButton type="primary" size="lg" @click="router.push('/demand')">
|
||||||
|
一键填报类似案例
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-else class="case-detail__empty text-secondary">
|
||||||
|
案例不存在或已下线
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.case-detail {
|
||||||
|
padding-top: @spacing-lg;
|
||||||
|
padding-bottom: @spacing-xxl;
|
||||||
|
|
||||||
|
&__breadcrumb {
|
||||||
|
margin-bottom: @spacing-base;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cta {
|
||||||
|
.flex(row, center);
|
||||||
|
padding-top: @spacing-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
padding: @spacing-xxl 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/views/cases/index.ts
Normal file
5
src/views/cases/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default } from './index.vue'
|
||||||
|
export { default as CasesFilter } from './cpns/CasesFilter.vue'
|
||||||
|
export { default as CasesGrid } from './cpns/CasesGrid.vue'
|
||||||
|
export { default as CaseDetailHeader } from './cpns/CaseDetailHeader.vue'
|
||||||
|
export { default as CaseRichText } from './cpns/CaseRichText.vue'
|
||||||
90
src/views/cases/index.vue
Normal file
90
src/views/cases/index.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { PaginationBar } from '@/components'
|
||||||
|
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
|
||||||
|
import { useCasesStore } from '@/stores/modules/cases'
|
||||||
|
import type { CaseQuery } from '@/types/business/cases'
|
||||||
|
import CasesFilter from './cpns/CasesFilter.vue'
|
||||||
|
import CasesGrid from './cpns/CasesGrid.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const casesStore = useCasesStore()
|
||||||
|
const { query, list, total, counts } = storeToRefs(casesStore)
|
||||||
|
|
||||||
|
function onUpdateQuery(next: CaseQuery): void {
|
||||||
|
casesStore.setQuery(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch(): void {
|
||||||
|
void casesStore.search()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReset(): void {
|
||||||
|
void casesStore.resetQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(page: number): void {
|
||||||
|
void casesStore.changePage(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onViewDetail(id: string): void {
|
||||||
|
void router.push(`/cases/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void Promise.all([casesStore.fetchCases(), casesStore.fetchCounts()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cases">
|
||||||
|
<header class="cases__head">
|
||||||
|
<h1 class="cases__title">
|
||||||
|
成功案例库
|
||||||
|
</h1>
|
||||||
|
<p class="cases__slogan text-secondary">
|
||||||
|
海量软硬件研发与量产落地真实案例,借鉴成熟实践,降低项目试错成本。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<CasesFilter
|
||||||
|
:query="query"
|
||||||
|
:counts="counts"
|
||||||
|
:total="total"
|
||||||
|
@update:query="onUpdateQuery"
|
||||||
|
@search="onSearch"
|
||||||
|
@reset="onReset"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CasesGrid :cases="list" @view="onViewDetail" />
|
||||||
|
|
||||||
|
<PaginationBar
|
||||||
|
:page="query.page"
|
||||||
|
:page-size="query.pageSize"
|
||||||
|
:total="total"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.cases {
|
||||||
|
.flex(column, flex-start);
|
||||||
|
gap: @spacing-base;
|
||||||
|
padding-top: @spacing-xl;
|
||||||
|
padding-bottom: @spacing-xxl;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: @font-size-xxl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__slogan {
|
||||||
|
margin-top: @spacing-xs;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user