Compare commits

...

41 Commits

Author SHA1 Message Date
Ironsp
f7722e19df fix skill 2026-10-14 14:19:12 +08:00
Ironsp
0b974bd36a feat 登录页面整改 2026-10-13 16:13:56 +08:00
Ironsp
a067fd8357 feat 在线客服和详细中心整改 2026-10-13 16:08:55 +08:00
Ironsp
367380e44c feat 个人中心_帮助中心整改 2026-10-13 15:56:12 +08:00
Ironsp
77dbabb1f1 feat 个人中心_地址整改 2026-10-13 15:48:57 +08:00
Ironsp
f917062d76 feat 个人中心_地址整改 2026-10-13 15:45:50 +08:00
Ironsp
093cf9acd0 feat 首页_业务服务整改 2026-10-13 15:42:08 +08:00
Ironsp
5fa940e9ba feat 案例库样式整改 2026-10-13 15:34:55 +08:00
Ironsp
f552dd370c feat 公共样式整改 2026-10-13 15:20:51 +08:00
Ironsp
fe9492ab69 feat 个人中心样式整改 2026-10-13 15:16:13 +08:00
Ironsp
d0779fe37c feat 案例样式整改 2026-10-13 15:14:47 +08:00
Ironsp
b0e9fe6450 feat 样式整改 2026-10-13 15:12:51 +08:00
Ironsp
631d54f269 fix Appheader 2026-10-12 17:14:17 +08:00
Ironsp
0f038e7818 feat 个人中心 2026-10-10 09:57:56 +08:00
Ironsp
53f23d4e69 feat 消息中心 2026-10-10 08:12:28 +08:00
Ironsp
6ecc649a1c feat 在线客服 2026-10-10 08:12:18 +08:00
Ironsp
6af8fd7b09 feat 财务中心 2026-10-09 17:17:56 +08:00
Ironsp
123d259f92 feat 合同中心 2026-10-09 17:17:43 +08:00
Ironsp
fd1eca95a6 feat 项目中心表格调整 2026-10-09 14:54:34 +08:00
Ironsp
a96f62d60b feat 项目中心样式调整 2026-10-09 14:28:17 +08:00
Ironsp
50636d0e06 feat 项目中心样式调整 2026-10-09 14:20:26 +08:00
Ironsp
e6e37d3b0e feat 项目中心样式调整 2026-10-09 14:20:20 +08:00
Ironsp
1187f20fb1 feat 项目中心 2026-10-09 11:41:50 +08:00
Ironsp
1bbbe50bd9 feat 项目中心 2026-10-09 11:11:10 +08:00
Ironsp
bb897ccc94 feat 合同中心 2026-10-09 10:32:49 +08:00
Ironsp
f73103a2a6 feat 需求提报 2026-10-09 09:29:08 +08:00
Ironsp
aa440d6bb5 feat 首页 2026-10-08 17:26:33 +08:00
Ironsp
a3b04485dd feat 开放本地端口,局域网查看 2026-10-08 10:16:13 +08:00
Ironsp
9f4a5c7cda feat 完善注释 2026-10-01 17:24:57 +08:00
Ironsp
2b59f68fd7 feat 完善注释 2026-10-01 16:58:22 +08:00
Ironsp
0d2389e49a feat 页面页尾 2026-10-01 14:23:58 +08:00
Ironsp
b99b2a0a88 feat 登录页面 2026-10-01 14:02:50 +08:00
Ironsp
eda0bc2200 feat 首页头部间距 2026-10-01 11:20:57 +08:00
Ironsp
863fdafcc5 feat 首页头部 2026-10-01 11:16:01 +08:00
Ironsp
d856fb623a feat 首页页脚 2026-10-01 11:04:30 +08:00
Ironsp
1601bdd82c feat ui设计稿bug修复 2026-10-01 10:53:13 +08:00
Ironsp
9ab6253c81 feat ui设计稿 2026-09-30 15:55:36 +08:00
wangzzpeng
ce8210fb3f 项目中心-支付模块 2026-09-30 08:04:16 +08:00
wangzzpeng
6b88c3805f 项目中心 2026-09-28 17:47:25 +08:00
wangzzpeng
b014fffacf 项目中心 2026-09-28 17:47:07 +08:00
wangzzpeng
43c2dc0d8d 恢复修改的数据 2026-09-28 10:49:08 +08:00
245 changed files with 16683 additions and 18595 deletions

View File

@@ -1,382 +1,335 @@
--- ---
name: vue-project-structure name: vue-project-structure
description: cz-web 项目(Vue 3 + TypeScript + Vite 网页端站点)的目录结构与编码规范强制 skill。当在本项目中新增、创建、移动、重构或修改任何 src/ 下的代码文件(.vue/.ts/.less)、路由、store、API、组件、页面时,必须加载本 skill,先读取项目目录、再按本规范确定文件归属与写法。本项目是**网页端站点(非中后台)**:页面按名称组织(home/news/product…),接口层在 api/、数据层在 Pinia、页面只经 store 取数,样式使用**less**。凡是涉及"新文件放哪"、"页面数据怎么拿"、"页面/公共组件归属"、"命名"、"功能对应哪些文件"的任务也必须使用本 skill。 description: Vue 3 与 uni-app(Vue3) 项目的目录结构与编码规范通用 skill。当在任意 Vue 3 或 uni-app(Vue3) 项目中新增、创建、移动、重构、修改 src/ 下的代码文件(.vue/.ts/.less/.scss)、路由、Pinia store、API、组件、页面、分包时,必须先加载本 skill。核心内容:先探测项目形态(Vue3 网页端/中后台 或 uni-app 小程序),再套用对应目录骨架;页面统一为「index.vue + cpns/」两件套(不使用 index.ts barrel);数据流固定为 页面 → store → api 单向;含 pages.json 注册与分包规范。凡是涉及"新文件放哪""页面数据怎么拿""公共组件 vs 页面私有组件归属""命名""分包""功能对应哪些文件"的任务也必须使用本 skill。本 skill 与具体仓库无关,可在任何 Vue 3 / uni-app 项目中直接复用。
version: 2.0.0 version: 4.0.0
--- ---
# cz-web 项目目录结构规范(强制 · 网页端) # Vue 3项目目录结构规范(通用 · 先探测后套用)
> 完整版调试文档见 `docs/vue-project-guide.md`(其中中后台相关部分已废弃),**本 skill 为唯一现行规范,以其为准**;未覆盖场景参照 Vue 3 官方目录约定。 > 适用范围:**Vue 3**(含 uni-app 的 Vue 3 版本)。不涉及 Vue 2。
> 本 skill 与具体仓库无关:执行顺序永远是 **① 探测现状 → ② 选定骨架变体 → ③ 按规范落位**。项目内已有显式文档(`AGENTS.md`/`CLAUDE.md`、`docs/` 下的结构说明)优先级高于本 skill。
## 第一步:读取项目目录(每次加载必做) ---
动手写任何代码前,先用工具映射当前项目结构,确认正在操作的正确层级: ## 第零步:探测项目形态(每次加载必做)
1. 用 `codegraph_files` 或 `glob` 列出 `src/` 下的现行目录结构(如某目录尚不存在,应在对应职责目录创建,而不是自创新顶层目录)。 ### 0.1 读取证据
2. 读取目标文件及其同级文件,确认现有命名风格与组织方式(页面名、store 名、api 名必须一致)。
3. 只有在目录树确认完毕后,才允许创建/修改文件。
## 核心原则 | 要看什么 | 怎么看 |
| ---------- | --------------------------------------------------------------------------- |
| 依赖清单 | `package.json`:是否有 `@dcloudio/*`、`pinia`、`vue-router` |
| 是否小程序 | 存在 `pages.json`、`manifest.json`;`App.vue` 含 `onLaunch` |
| 构建工具 | `vite.config.*`(Vue 3 必配);`vue.config.*` 为 Vue CLI |
| 目录现状 | 列出 `src/`(uni-app 通常无 src 包裹,直接根目录)的实际结构 |
| 样式方案 | 现有 `.less` → less;`.scss` → scss |
| 语言 | 有 `tsconfig.json` → TypeScript |
| 目标平台 | `manifest.json` 的 `mp-weixin`/`h5`/`app` 等;`pages.json` 的 `subPackages` |
1. **三层数据流(页面 → store → api)**:页面组件只通过 **Pinia store** 获取数据;store 是数据层,负责调用 **api 层**接口取数并维护状态;api 层只发请求。**页面禁止直接 import api**。 ### 0.2 判定形态
2. **按页面/页面名组织(一页一套)**:`views/<page>/`、`api/<page>.ts`、`stores/modules/<page>.ts`、`types/business/<page>.d.ts` 四者同名对应,方便分层查看。
3. **组件两级归属**:`components/` 只放**跨页面复用的公共组件**;`views/<page>/cpns/` 放**该页面独有的组件**。
4. **网页端(非中后台)**:无侧边栏菜单/权限路由/TabsBar 体系;页面以业务(首页、新闻、产品、关于我们…)划分。
5. **样式使用 less**:全局样式为 `.less` 文件,组件内 `<style lang="less" scoped>`。
## 数据流架构(强约束) ```mermaid
graph TD
A[读 package.json + 目录] --> B{含 @dcloudio/* ?}
B -->|是| U[形态 B: uni-app Vue3]
B -->|否| D{有 layout 壳 + 权限路由?}
D -->|是| M[形态 A2: 中后台]
D -->|否| W[形态 A1: 网页端站点]
U --> U1{pages.json 有 subPackages?}
U1 -->|是| U2[大应用: 必须分包]
U1 -->|否| U3[小应用: 主包即可]
```
| 形态 | 判据 | 骨架 |
| ---------------------- | ---------------------------------------------- | ----------------------------------- |
| **A1 Vue3 网页端站点** | 无权限路由,业务按页面名划分 | 骨架 A |
| **A2 Vue3 中后台** | 有 `layout/` 壳、`permission.ts`、表格表单密集 | 骨架 A + 中后台补充 |
| **B uni-app(Vue3)** | 依赖含 `@dcloudio/*`,存在 `pages.json` | 骨架 B(**页面注册用 pages.json**) |
### 0.3 探测结论必须先复述
动手改代码前,先输出:
```
探测结论:形态=B uni-app(Vue3 + Vite + TS + Pinia + scss,含 subPackages 3 个)
页面结构:index.vue + cpns/(无 index.ts)
样式方案:scss(uni.scss 已存在)
```
探测不明确时**先问用户**,不要猜。
---
## 第一步:读取目标区域(探测后必做)
1. 列出将要操作目录的现行结构(目录不存在则新建,**不要自创新顶层目录**)。
2. 读取目标文件 + 同级 2 个文件,确认既有命名/导出/样式风格。
3. 确认该业务域"页面 / api / store / 类型"在本项目中的实际路径。
---
## 核心原则(形态无关)
1. **三层数据流:页面 → store → api**。页面只通过 store 取数;store 调用 api 并维护状态;api 只发请求。**页面禁止直接 import api**。
2. **业务同名单点**:同一业务域的 `页面目录` / `api 文件` / `store 文件` / `类型文件` 同名对应。
3. **页面两件套(无 barrel)**:页面目录 = `index.vue` + `cpns/`。**不建 `index.ts`**,路由直接指向 `@/views/<page>/index.vue`。
4. **组件两级归属**:跨 ≥2 个页面/分包复用 → 全局 `components/`;单页面独有 → 该页面的 `cpns/`。
5. **技术选型跟随现状**:用 Pinia 就不引 Vuex;用 scss 就不写 less;用 TS 就不落 `.js`。**不擅自更换技术栈**。
6. **最小改动**:只改与本次需求相关的行;无关死代码只报告、不删除。
---
## 骨架 A:Vue 3 网页端 / 中后台
```
src/
├── api/<domain>.ts # 接口层:按业务命名,一域一文件
├── assets/ # 参与构建的静态资源(images/ icons/ fonts/)
├── components/ # 跨页面复用的公共组件(禁含具体业务)
│ └── index.ts # barrel 统一导出(仅公共组件目录需要)
├── composables/ # 通用组合式逻辑(useLoading / useScroll / useCountDown)
├── directives/ # 自定义指令(lazy / debounce / throttle)+ index.ts 注册
├── layout/ # 全站骨架(网页端:Header+router-view+Footer)
├── router/ # index.ts(createRouter)、routes.ts、guard.ts
├── stores/
│ ├── index.ts # pinia 实例
│ └── modules/<domain>.ts
├── styles/ # variables / mixins / reset / common + 入口
├── types/ # api.d.ts / router.d.ts / business/<domain>.d.ts
├── utils/ # request.ts(唯一 axios 实例)/ storage / validate / format
└── views/
└── <page>/ # ★ 页面 = index.vue + cpns/
├── index.vue # 页面主组件(路由直接指向它)
├── detail.vue # 同页面模块的次级页面(如列表/详情)
└── cpns/ # 仅本页使用的组件
├── Head.vue
└── Banner.vue
```
路由写法(**不经过 index.ts**):
```ts
{
path: '/news',
name: 'News',
component: () => import('@/views/news/index.vue'), // 直接指向 .vue
meta: { title: '新闻列表' },
}
{
path: '/news/:id',
name: 'NewsDetail',
component: () => import('@/views/news/detail.vue'),
meta: { title: '新闻详情' },
}
```
### A2 中后台补充(在骨架 A 上增加)
```
src/
├── layout/
│ ├── index.vue # 后台外壳:侧边栏 + 顶栏 + tab 缓存 + <router-view/>
│ └── components/ # Sider / Header / Breadcrumb / TagsView
├── router/
│ ├── routes.ts # constantRoutes(无权限)
│ └── modules.ts # asyncRoutes(按角色过滤)
├── permission.ts # 守卫:登录态 → 白名单 → 动态路由注入
├── stores/modules/ # user(token/角色/权限)、permission(动态路由)、app(侧栏/主题)
└── views/<module>/<page>/ # 按模块再分页面,如 views/system/user/index.vue
```
- 权限:`meta: { title, icon, roles: [], perms: [] }`,由 `permission.ts` 统一处理,页面用 `hasPermi('sys:user:add')` 判断。
- 表格页统一结构:`搜索区 + 工具栏 + 表格 + 分页`,分页用公共 `Pagination` 组件。
- 动态路由必须 `router.addRoute` 注入后 `next({ ...to, replace: true })`,避免空白页。
---
## 骨架 B:uni-app(Vue 3)
```
├── pages.json # ★ 唯一页面注册入口(路径、导航栏、分包)
├── manifest.json # 应用配置(appid、平台配置、原生插件)
├── uni.scss # ★ 全局 scss 变量(uni-app 约定入口)
├── App.vue # 全局生命周期 onLaunch/onShow/onHide
├── main.ts # createApp、注册 pinia、引入全局样式
├── api/<domain>.ts # 接口层(uni.request 封装为函数)
├── stores/modules/<domain>.ts # 状态层
├── utils/
│ ├── request.ts # 封装 uni.request 的唯一请求层
│ └── storage.ts # uni.getStorageSync 封装
├── components/ # 跨页面/跨分包复用的自定义组件
├── static/ # 不参与编译的静态资源(不要用 assets)
├── composables/
├── types/
├── pages/ # ★ 主包页面
│ ├── index/
│ │ ├── index.vue
│ │ └── cpns/
│ └── login/
│ ├── index.vue
│ └── cpns/
└── subpackages/ # 分包页面(仅分包项目)
└── news/
├── detail.vue
└── cpns/
```
uni-app 硬性约定:
- 页面**必须**在 `pages.json` 的 `pages` / `subPackages[].pages` 注册,路径不带扩展名、不以 `/` 开头。
- 页面目录同样遵循 **`index.vue` + `cpns/`**,不建 `index.ts`。
- 主包体积敏感:**非首屏必需**页面一律放 `subPackages`;静态大图放 `static/`。
- 跨分包随意 import 受限;公共依赖优先放主包或做分包异步化。
- 平台差异(`#ifdef MP-WEIXIN / H5 / APP-PLUS`)集中封装并注释,禁止散落全项目。
- 用 `uni.request` / `uni.chooseImage` / `uni.navigateTo`,不混用 `wx.*`;tabBar 页用 `switchTab`(不能带参数)。
- 组件模板优先 `view/text/image`,不用 `div/span/img`。
---
## 数据流架构(强约束,两形态通用)
```text ```text
页面组件 (views/home/index.vue) 页面组件 (views/news/index.vue 或 pages/news/index.vue)
│ ① 页面只调用 store 的 action / 读取 store 的 state(模板渲染数据) │ ① 只读 store state / 调 store action
▼ ▼
Pinia store (stores/modules/home.ts) —— 数据层 状态层 (stores/modules/news.ts) —— 数据层:取数 + 状态 + 缓存 + 跨页共享
│ ② store 的 action 内部调用 api 接口函数 → await → 写入 state / 返回结果 │ ② action 内部调用 api 函数 → await → 写 state
▼ ▼
API (api/home.ts) —— 接口层 API (api/news.ts) —— 接口层:只发请求、只返回带类型的数据
│ ③ 经 utils/request.ts 发 HTTP 请求 │ ③ request.ts(axios / uni.request 封装)
▼ ▼
后端服务器 后端
``` ```
铁律: 铁律:
- **页面禁止 `import` api 层文件**;获取数据必须调对应页面 store 的方法。 - 页面**禁止** import api;取数必须走 store。
- **api 层是纯接口层**:只发请求、返回数据(带类型),不做业务状态处理、不缓存。 - api 是**纯接口层**:不发业务判断、不缓存、不 toast。
- **store 承担取数 + 数据状态 + 页面间共享 + 缓存**;与页面同名的 store 责任边界即该页面。 - store 承担取数、状态、缓存、跨页共享;本地缓存用 `storage.ts`,不直接写 `localStorage` / `uni.setStorageSync`。
- 调用链必须是 `页面 → store → api` 单向,禁止跳过 store 或反向依赖。 - 依赖方向单向:`页面 → store → api`,禁止反向与跨层直连。
## src/ 目录结构(强制划分) ---
``` ## 各层职责速查
src/
├── api/ # ★ 接口层:按页面命名,一页一文件(home.ts、news.ts、product.ts、about.ts) | 层 | 目录 | 一句话职责 | 禁止 |
├── assets/ # 参与构建的静态资源(images/、icons/、fonts/) | ------------ | --------------------------------------------------------- | ---------------------------------------- | ------------------------------ |
├── components/ # ★ 公共组件:仅跨页面复用的全站通用组件(禁含具体页面业务) | 页面 | `views/<page>/index.vue`(uni:`pages/<page>/index.vue`) | 渲染 + 交互 + 编排 | 直接调 api、直接 axios/request |
│ ├── AppHeader.vue # 全站头部(导航) | 页面私有组件 | `views/<page>/cpns/`(uni 同名) | 仅本页用 | 被其他页 import |
│ ├── AppFooter.vue # 全站页脚 | 公共组件 | `components/` | ≥2 处复用 | 含单页业务逻辑 |
│ ├── AppButton.vue # 通用按钮 | 状态层 | `stores/modules/<domain>.ts` | 取数 + 状态 + 缓存 | 拼 URL、写 UI 逻辑 |
│ ├── AppSwiper.vue # 通用轮播 | 接口层 | `api/<domain>.ts` | 请求分发 + 类型 | 状态、弹窗提示 |
│ └── index.ts # 统一导出 | 请求层 | `utils/request.ts` | 唯一实例/封装:token、统一解包、错误提示 | 被页面直接 import axios |
├── composables/ # 通用组合式逻辑(useScroll、useLoading、useCountDown、useIntersection) | 工具 | `utils/`(纯函数) / `composables/`(响应式) | — | 互相混放 |
├── directives/ # 自定义指令(lazy 图片懒加载、debounce、throttle + index.ts 统一注册)
├── layout/ # 全站布局骨架:index.vue = <AppHeader/> + <router-view/> + <AppFooter/> > **页面私有组件目录名统一用 `cpns/`**(不再用 `components/`,避免与全局 `components/` 混淆)。
├── router/ # 路由:index.ts(入口)、routes.ts(静态路由表)、guard.ts(守卫,可选)
├── stores/ # ★ 数据层(Pinia):按页面命名,一页一 store ### 组件归属判定(唯一判据)
│ ├── index.ts # pinia 实例(main.ts 中 app.use(pinia))
│ └── modules/ ```mermaid
│ ├── home.ts # useHomeStore:首页数据(banner、推荐、公告) graph TD
│ ├── news.ts # useNewsStore:新闻列表/分页/详情 A[新组件] --> B{被几个页面/分包使用?}
│ ├── product.ts # useProductStore:产品列表/详情 B -->|1 个| C[放该页面 cpns/]
│ └── site.ts # useSiteStore:站点公共数据(站点配置、全局 loading),非页面级 B -->|≥2 个| D{业务无关还是业务相关?}
├── styles/ # ★ 全局样式(less) D -->|业务无关<br/>按钮/弹窗/上传| E[全局 components/]
│ ├── index.less # 样式入口(main.ts 引入),组织以下文件 D -->|业务相关| F[全局 components/<domain>/]
│ ├── variables.less # ★ less 变量唯一来源(颜色/间距/字号/圆角),禁止组件内魔法色值 F --> G[差异用 props 注入]
│ ├── mixins.less # 混合宏(flex、文本省略、滚动条)
│ ├── reset.less # 样式重置
│ └── common.less # 通用工具类(容器宽度、间距类)
├── types/ # 类型定义
│ ├── global.d.ts # 全局声明(Window 扩展、import.meta.env 兜底)
│ ├── api.d.ts # 接口通用结构:ApiResponse<T>、PageResult<T>
│ ├── router.d.ts # RouteMeta 扩展
│ └── business/ # 按页面与 api 对应:home.d.ts / news.d.ts / product.d.ts
├── utils/ # 纯函数工具(无 Vue 依赖):request.ts、storage.ts、validate.ts、format.ts
└── views/ # ★ 页面:一页一目录
├── home/
│ ├── index.vue # 页面主组件(路由通过 index.ts 加载)
│ ├── index.ts # 页面出口(barrel):export default 页面 + 导出本页独有组件
│ └── cpns/ # 页面独有组件(head、banner、news-list 等,仅本页面使用)
│ ├── Head.vue
│ └── Banner.vue
├── news/
│ ├── index.vue # 列表页
│ ├── detail.vue # 详情页
│ ├── index.ts
│ └── cpns/
├── product/ # 同上结构
├── about/ # 同上结构
└── login/ # 登录(仅存在会员体系时)
``` ```
**命名一致性(可分层互查)**:页面 `home` → `api/home.ts` + `stores/modules/home.ts`(`useHomeStore`) + `types/business/home.d.ts` + `views/home/`,四者同名。 ### 命名一致性
## src/ 每个文件的作用(新建/修改文件前先对照本节定位) 同一业务域 `news` 的四层落位:
### 入口与根文件 | 层 | Vue 形态 | uni-app 形态 |
| -------- | ----------------------------------------- | ------------------------------------------ |
| 页面 | `views/news/index.vue` | `pages/news/index.vue` + `pages.json` 注册 |
| 私有组件 | `views/news/cpns/` | `pages/news/cpns/` |
| 接口 | `api/news.ts` | `api/news.ts` |
| 状态 | `stores/modules/news.ts` → `useNewsStore` | 同左 |
| 类型 | `types/business/news.d.ts` | `types/business/news.d.ts` |
| 文件 | 作用 | ---
| -------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `src/main.ts` | 应用入口:`createApp(App)`,注册 `pinia`、`router`、自定义指令(`setupDirectives`)、引入全局 `styles/index.less`。不写业务逻辑。 |
| `src/App.vue` | 根组件,承载 `<router-view/>` 与全局过渡;不含业务代码。 |
| `src/env.d.ts` | Vite 环境变量类型声明(`ImportMetaEnv` / `ImportMeta`)。 |
### `api/` —— 接口层(一页一文件,与页面同名) ## 规范速查(形态无关的硬性项)
| 文件 | 作用 |
| ---------------- | ------------------------------------------------------ |
| `api/home.ts` | 首页接口:`getHomeBanner`、`getRecommend`、`getNotice` |
| `api/news.ts` | 新闻接口:`getNewsList`、`getNewsDetail` |
| `api/product.ts` | 产品接口:`getProductList`、`getProductDetail` |
| `api/about.ts` | 关于我们/公司介绍接口 |
| `api/auth.ts` | 登录/注册/用户信息(仅会员体系需要时) |
> **纯接口层**:函数只负责 `request<T>({ url, method, params/data })`,不做数据加工、不维护状态。新增页面 → 新建 `api/<page>.ts`。
### `components/` —— 公共组件(仅跨页面复用)
| 文件 | 作用 |
| -------------------------- | ------------------------------------------------------------ |
| `components/AppHeader.vue` | 全站头部:logo、导航菜单(路由跳转) |
| `components/AppFooter.vue` | 全站页脚:版权、友链 |
| `components/AppButton.vue` | 通用按钮(disabled/loading/类型统一) |
| `components/AppSwiper.vue` | 通用轮播(首页 banner、产品图) |
| `components/index.ts` | 统一导出(barrel),页面与页面组件统一从 `@/components` 导入 |
> 判定:被 ≥2 个页面使用 → 放这里;仅 1 个页面使用 → 放 `views/<page>/cpns/`。
### `composables/` —— 通用组合式逻辑(与 UI 解耦)
| 文件 | 作用 |
| -------------------------------- | ------------------------ |
| `composables/useScroll.ts` | 滚动监听/吸顶/回到顶部 |
| `composables/useLoading.ts` | 请求 loading 状态管理 |
| `composables/useCountDown.ts` | 倒计时(验证码等) |
| `composables/useIntersection.ts` | 元素可见性(配合懒加载) |
### `directives/` —— 自定义指令
| 文件 | 作用 |
| ------------------------ | --------------------------------------------- |
| `directives/lazy.ts` | `v-lazy` 图片懒加载(网页端常用) |
| `directives/debounce.ts` | `v-debounce` 防抖 |
| `directives/throttle.ts` | `v-throttle` 节流 |
| `directives/index.ts` | `setupDirectives(app)` 统一注册,main.ts 调用 |
### `layout/` —— 全站布局骨架
| 文件 | 作用 |
| -------------------- | -------------------------------------------------------------------------- |
| `layout/index.vue` | 全站公共布局:`<AppHeader/>` + `<router-view/>` + `<AppFooter/>`,仅此一层 |
| `layout/components/` | 布局内部组件(如返回顶部按钮);需要全站复用的上移 `components/` |
### `router/` —— 路由系统(网页端:静态路由为主)
| 文件 | 作用 |
| ------------------ | ------------------------------------------------------------------------- |
| `router/index.ts` | 创建 `createRouter`(createWebHistory)+ 注册静态路由 + 挂守卫 |
| `router/routes.ts` | 静态路由表:`/home`、`/news`、`/news/:id`、`/product`、`/about`、404 兜底 |
| `router/guard.ts` | 路由守卫(可选):登录态校验(会员页)、滚动位置恢复、document.title |
```ts
// 路由加载页面(指向 views/home/index.ts —— 页面出口)
{
path: '/home',
name: 'Home',
component: () => import('@/views/home'), // 解析到 index.ts 的 default(index.vue)
meta: { title: '首页' },
}
```
### `stores/modules/` —— 数据层(Pinia,一页一 store,与页面同名)
| 文件 | 作用 |
| --------------------------- | ------------------------------------------------------------------------------------------- |
| `stores/modules/home.ts` | `useHomeStore`:state(banner/推荐/公告)+ actions(`fetchHomeData`,内部调 `api/home.ts`) |
| `stores/modules/news.ts` | `useNewsStore`:列表/分页/详情 state + `fetchNewsList`、`fetchNewsDetail` |
| `stores/modules/product.ts` | `useProductStore`:产品列表/详情/分类筛选 |
| `stores/modules/site.ts` | `useSiteStore`:站点配置、全局 loading、主题(非页面级公共数据) |
| `stores/modules/user.ts` | `useUserStore`:登录态/token(仅会员体系需要时) |
```ts
// stores/modules/home.ts —— 数据层写法示例(页面 → store → api)
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getHomeBanner, getRecommend } from '@/api/home'
import type { Banner, RecommendItem } from '@/types/business/home'
export const useHomeStore = defineStore('home', () => {
const banners = ref<Banner[]>([])
const recommends = ref<RecommendItem[]>([])
async function fetchHomeData() {
// ① store 调 api 层拿数据
const [bannerData, recommendData] = await Promise.all([
getHomeBanner(),
getRecommend(),
])
// ② 写入 state——完成页面所需数据
banners.value = bannerData
recommends.value = recommendData
}
return { banners, recommends, fetchHomeData }
})
```
### `styles/` —— 全局样式(less)
| 文件 | 作用 |
| ----------------------- | ------------------------------------------------------------------------------------ |
| `styles/index.less` | 入口,main.ts `import '@/styles/index.less'` |
| `styles/variables.less` | ★ 变量唯一来源:`@primary-color`、`@text-color`、`@font-size-base`、`@spacing-base`… |
| `styles/mixins.less` | 混合宏(flex、文本省略) |
| `styles/reset.less` | 重置 |
| `styles/common.less` | 通用工具类 |
组件内样式:`<style lang="less" scoped>`,变量经 vite `css.preprocessorOptions.less.additionalData` 自动注入 `@import '@/styles/variables.less';`,组件内直接使用 `@primary-color`。
### `types/` —— 类型定义
| 文件 | 作用 |
| -------------------------- | --------------------------------------------------------------------- |
| `types/api.d.ts` | `ApiResponse<T> { code, msg, data }`、`PageResult<T> { list, total }` |
| `types/router.d.ts` | `declare module 'vue-router'` 扩展 `RouteMeta`(title 等) |
| `types/business/home.d.ts` | 首页实体类型(Banner/RecommendItem),与 `api/home.ts` 对应 |
| `types/business/news.d.ts` | 新闻实体(NewsItem/NewsQuery),与 `api/news.ts` 对应 |
> 新页面 → 新建 `types/business/<page>.d.ts`;禁止在组件内散落实体类型。
### `utils/` —— 纯函数工具
| 文件 | 作用 |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `utils/request.ts` | ★ 唯一 axios 实例:拦截器注入 token/统一解包 `ApiResponse`/统一错误提示,导出 `request<T>`。组件与页面禁止再 import axios |
| `utils/storage.ts` | localStorage/sessionStorage 泛型封装 |
| `utils/validate.ts` | 校验规则(手机号/邮箱/URL) |
| `utils/format.ts` | 格式化(金额/日期/千分位) |
### `views/` —— 页面(一页一目录,三件套:index.vue + index.ts + cpns/)
| 文件/目录 | 作用 |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `views/home/index.vue` | 首页主组件:组合 `cpns/` 的独有组件,数据由 `useHomeStore` 提供 |
| `views/home/index.ts` | **页面出口(barrel)**:`export { default } from './index.vue'` + 导出 `cpns/` 组件;路由与外部引用统一走它"展示"页面 |
| `views/home/cpns/Head.vue` | 首页独有头部区块(banner 下导航条等),仅本页使用 |
| `views/home/cpns/Banner.vue` | 首页独有轮播区块(渲染 store 的 banners) |
| `views/news/index.vue` | 新闻列表页 |
| `views/news/detail.vue` | 新闻详情页(与 index.vue 同目录,同页面模块) |
| `views/news/index.ts` | 出口 barrel(同上) |
| `views/news/cpns/` | 新闻页独有组件(筛选栏、列表项卡片) |
```ts
// views/home/index.ts —— 页面出口(barrel)写法
export { default } from './index.vue' // 页面主组件(default)
export { default as HomeHead } from './cpns/Head.vue'
export { default as HomeBanner } from './cpns/Banner.vue'
```
> 页面独有组件的归属判定:**只属于某一个页面 → 进该页面 `cpns/`;被 ≥2 个页面共用 → 上移 `components/`**。
## 项目功能模块图(功能 → 涉及文件)
| 功能 | 涉及文件 | 说明 |
| --------------------- | --------------------------------------------------------------------------------------- | ------------------------------ |
| 全站布局(头部/页脚) | `layout/index.vue` + `components/AppHeader.vue` + `components/AppFooter.vue` | 路由首层挂在 layout 下 |
| 首页 | `api/home.ts` + `stores/modules/home.ts` + `views/home/`(index.vue + index.ts + cpns) | 页面 → store → api 取数 |
| 新闻中心 | `api/news.ts` + `stores/modules/news.ts` + `views/news/` | 列表 + 详情 |
| 产品展示 | `api/product.ts` + `stores/modules/product.ts` + `views/product/` | 列表 + 详情 + 分类 |
| 关于我们 | `api/about.ts` + `stores/modules/about.ts` + `views/about/` | 公司介绍内容 |
| 会员登录(可选) | `api/auth.ts` + `stores/modules/user.ts` + `views/login/` + `router/guard.ts` | 登录态/token |
| 轮播/动效 | `components/AppSwiper.vue` + `composables/useScroll.ts` + `directives/lazy.ts` | 首页 banner、图片懒加载 |
| 站点公共数据 | `stores/modules/site.ts` + `styles/variables.less` | 站点配置、全局 loading、主题色 |
## 统一规范速查
### 路由规范
- 路由 `component` 指向页面出口:`() => import('@/views/<page>')`(解析到 `index.ts` 的 default = `index.vue`)
- 每个路由必须声明 `meta.title`;`RouteMeta` 扩展在 `src/types/router.d.ts`
- 网页端以静态路由为主,无需动态权限路由;嵌套页面(news 列表/详情)在同一页面目录下
- 公共页面挂载在 `layout/index.vue` 下,页面自身不重复写 header/footer
### 状态管理(Pinia)规范 —— 数据层
- store 文件:`src/stores/modules/<page>.ts`,命名 `use<Page>Store`(PascalCase),与 api/views 同名
- **统一组合式(setup)写法**:state 用 `ref`,getters 用 `computed`,actions 用普通函数,最后 return 全部
- **store 的 action 负责调用对应页面 api 层的接口取数**,页面只调 action
- 组件内:state/getters 用 `storeToRefs` 解构;actions 直接解构调用
- 简单展示型数据也走 store(保持数据层统一),页面禁止直接 import api
### 接口 API 规范 —— 接口层
- **全项目唯一 axios 实例**在 `src/utils/request.ts`,禁止页面/组件 import axios
- 统一响应结构 `ApiResponse<T> { code, msg, data }`(见 `types/api.d.ts`)
- 接口函数名 = 动词 + 资源:`getHomeBanner` / `getNewsList` / `getNewsDetail` / `getProductList`
- `api/<page>.ts` 只做请求分发,不做数据处理;处理与状态归 store
- baseURL 用环境变量 `import.meta.env.VITE_API_BASE_URL` 区分环境(.env / .env.development / .env.production)
- 错误统一由拦截器兜底,store/页面仅在需特殊处理时 catch
### 组件规范 ### 组件规范
- 公共组件 `components/`:文件 PascalCase(AppHeader.vue);被 ≥2 页面使用才放这里 - 统一 `<script setup lang="ts">`。
- 页面独有组件 `views/<page>/cpns/`:**严禁被其他页面 import**;语义化命名(Head.vue、Banner.vue) - props:`defineProps<Props>()` + `withDefaults`;emits:`defineEmits<{ change: [val: number] }>()`,事件名 camelCase。
- 页面结构三件套:`index.vue`(页面) + `index.ts`(出口 barrel) + `cpns/`(独有组件) - 模板内不写复杂表达式(超过一次三元/逻辑运算即抽方法或 computed)。
- 组件统一 `<script setup lang="ts">`;props 用 `defineProps<Props>()` + `withDefaults`;emits 用 `defineEmits<{ 事件名: [参数类型] }>()` - 单文件 > 400 行 → 拆到同页面 `cpns/`。
- 组件事件 camelCase 命名;模板内不放复杂表达式;样式默认 `<style lang="less" scoped>` - uni-app 组件模板用 `view/text/image`。
- 单文件超 400 行即拆分到 `cpns/`
### 样式规范(less) ### 样式规范
- 全项目使用 **less**,禁止新增 `.scss/.css` 业务样式 - **跟随项目既有方案**(less 或 scss),不混用两套预处理器。
- 全局变量统一在 `styles/variables.less`;组件内禁止魔法色值 - 变量单一来源:Vue 项目 `src/styles/variables.*`;uni-app 项目 `uni.scss`。
- vite `css.preprocessorOptions.less` 配置 `additionalData` 自动注入 variables - 组件内禁止魔法色值/魔法间距。
- 依赖:`pnpm add -D less`(vite 内置 less 编译支持) - Vue 项目:`<style scoped lang="less|scss">`;uni-app 页面样式**不加 `scoped`**(小程序不支持),靠类名隔离。
### 工程化硬性约定 ### 类型规范
- **导入一律 `@/` 别名**;禁止跨目录相对路径(`../` 超过一层即违规) - 实体类型 → `types/business/<domain>.d.ts`;接口通用结构 → `types/api.d.ts`。
- utils 与 composables 判据:**需要响应式状态或生命周期 → composables;否则 → utils** - **禁止** `as any` / `@ts-ignore` / `@ts-expect-error`。
- 类型归位:实体类型 → `types/business/<page>.d.ts`(与 api 同名);接口结构 → `types/api.d.ts`
- **禁止 `as any` / `@ts-ignore` / `@ts-expect-error`**
## 后续使用方式(工作流) ### 导入规范
### 场景速查:想做什么 → 去找哪个文件 - 有别名(`@/`)的项目一律用别名;禁止多级跨层 `../../../`。
- uni-app 需在 `vite.config` 的 `resolve.alias` 或 `tsconfig` 的 `paths` 配置 `@/`。
| 想做的事 | 动哪个文件 | 注意 | ### 请求规范
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| 给首页加一块新数据 | `api/home.ts` 加接口函数 → `stores/modules/home.ts` 加 action/state → `views/home/index.vue` 调 store action,结构放 `views/home/cpns/` 新组件 | 遵循数据流:页面 → store → api |
| 新增一个页面模块(如 news) | `types/business/news.d.ts` → `api/news.ts` → `stores/modules/news.ts` → `views/news/`(index.vue+index.ts+cpns) → `router/routes.ts` 注册 | 四者同名,走七步流程 |
| 页面要取数据 | 调对应 `use<Page>Store` 的 action(`storeToRefs` 取 state) | **禁止直接 import api** |
| 一个组件多个页面要用 | 放 `components/`(公共组件) | 从 `@/components` 导入 |
| 一个组件只有本页面用 | 放 `views/<page>/cpns/` | 禁止其他页面引用 |
| 改全站颜色/字体/间距 | `styles/variables.less` | 禁止组件内魔法值 |
| 图片懒加载 | 模板 `v-lazy`(`directives/lazy.ts`) | 勿手写滚动判断 |
| 通用逻辑(响应式) | `composables/` | 纯函数则 `utils/` |
| 弹窗/表单等小块 | 页面内 `cpns/` | 复用时上移 components/ |
| 路由新增页面 | `router/routes.ts` 注册 | component 指向 `@/views/<page>` |
### 新增页面模块七步流程(以"新闻 news"为例) - 全项目**唯一**请求实例/封装:`utils/request.ts`;页面与组件禁止直接 `axios` / `uni.request` / `fetch`。
- baseURL 走环境变量(`import.meta.env.VITE_*`)。
- 错误统一由拦截器兜底,业务侧仅在需要特殊处理时 `catch`。
1. **类型**:`types/business/news.d.ts` 定义 `NewsItem / NewsQuery / PageResult` ---
2. **接口层**:`api/news.ts`(`getNewsList`、`getNewsDetail`,走 `request<T>`)
3. **数据层**:`stores/modules/news.ts`(`useNewsStore`,action 内调用 api 函数取数)
4. **页面**:`views/news/index.vue`(列表)+ `views/news/detail.vue`(详情)+ `views/news/index.ts`(出口 barrel)+ `views/news/cpns/`(独有组件)
5. **路由**:`router/routes.ts` 注册(`component: () => import('@/views/news')`,配 `meta.title`)
6. **复用检查**:被 ≥2 页面使用的组件上移 `components/`
7. **验证**:`pnpm build`(含 vue-tsc 类型检查)零错误
### 日常开发姿势 ## 工作流
- **每次动手前**:按"第一步"读取目录,确认目标文件存在与现状,再决定新建还是修改。 ### 场景速查:想做什么 → 改哪些文件
- **新建文件前**:对照"src/ 每个文件的作用"确认归属(api/stores/views 四者同名)。
- **改数据相关代码**:先确认数据流方向——页面只碰 store,store 只碰 api,api 只发请求。 | 想做的事 | 动作 |
- **组件归属**:先自问"有几个页面用它"——1 个 → cpns/;≥2 个 → components/。 | ---------------------- | ------------------------------------------------------------------------------------------ |
- **规格冲突时**:本 skill 为唯一现行规范;未覆盖场景 → 询问用户或按最贴近的既有模式执行后复述假设。 | 给已有页面加一块新数据 | api 加函数 → store 加 action/state → 页面调 action,UI 放该页 `cpns/` 新组件 |
| 新增业务模块 | 类型 → api → store → 页面目录(index.vue + cpns/) → 路由(Vue)或 `pages.json`(uni-app) |
| 页面要取数据 | 调 `use<Domain>Store`;**禁止**直接 import api |
| 组件多页要用 | 上移 `components/` |
| 组件仅本页用 | 留在该页面 `cpns/` |
| 改全局颜色/间距 | 样式变量文件(`variables.*` / `uni.scss`) |
| 小程序主包体积超标 | 把非首屏页面迁入 `subPackages` |
### 新增业务模块流程(以 `news` 为例)
1. **类型** `types/business/news.d.ts`
2. **接口** `api/news.ts`
3. **状态** `stores/modules/news.ts`(action 内调 api)
4. **页面** `views/news/index.vue`(或 `pages/news/index.vue`)+ `cpns/`
5. **注册** `router/routes.ts`(`component: () => import('@/views/news/index.vue')`)或 `pages.json`
6. **复用检查** ≥2 处复用的上移 `components/`
7. **验证** 类型检查 + 构建零错误(`pnpm build` / `npm run build` / `vue-tsc --noEmit`)
### 最小改动纪律
- 每次动手前先读现状,确认是"新建"还是"修改"。
- 只碰与需求相关的行;不顺手重构、不顺手换技术栈。
- 规范冲突时:项目内显式文档 > 本 skill;本 skill 未覆盖时,先说明假设再执行。
---
## 修改代码时的强制检查清单 ## 修改代码时的强制检查清单
修改任何已存在文件时,逐条核对(不满足即视为违反规范,必须先修正再继续): 修改任何已存在文件时逐条核对,不满足必须先修正:
- [ ] 文件是否位于其职责对应的目录(接口在 api/、数据在 stores/modules/、页面在 views/<page>/ 等) - [ ] 文件位于其职责对应目录(接口在 api/、状态在 stores/、页面在 views|pages/)
- [ ] **页面是否直接 import 了 api 层?(违规——数据必须经 store)** - [ ] **页面是否直接 import 了 api?**(违规)
- [ ] **store 名 / api 文件名 / 页面目录名 / 类型文件名 是否与页面一致(可分层互查)** - [ ] api / store / 页面 / 类型 是否同业务域命名一致
- [ ] 导入是否使用 `@/` 别名 - [ ] 页面目录是否只有 `index.vue`(+ 同模块次级页面)+ `cpns/`,**没有多余 `index.ts`**
- [ ] 组件归属是否正确:≥2 页面共用 → components/;单页面独有 → views/<page>/cpns/ - [ ] 私有组件是否在 `cpns/` 且无其他页面 import
- [ ] 不在组件里直接 new axios 实例 / 直接调用原生 fetch - [ ] 导入使用 `@/` 别名,无多级跨层相对路径
- [ ] 样式是否使用 **less**(`<style lang="less" scoped>` / \*.less),颜色是否取自 variables.less - [ ] 未在组件内直接 `axios` / `uni.request` / `fetch`
- [ ] 类型是否齐全(无 `as any`、无 `@ts-ignore`) - [ ] 样式沿用项目既有预处理器,色值取自变量文件
- [ ] 新页面是否有完整三件套(index.vue + index.ts + cpns/)并在 router 注册 - [ ] uni-app:新页面已在 `pages.json` 注册;未把非首屏页放进主包;页面样式未加 `scoped`
- [ ] 运行时 `pnpm build`(含 vue-tsc 类型检查)零错误 - [ ] 无 `as any` / `@ts-ignore`
- [ ] 构建/类型检查零错误
> 本规范为项目强约束:**新增与修改的代码必须严格遵循**。发现不符合结构约定的代码,应指出问题并要求按规范调整,而不是放任通过。 > 发现不符合规范的历史代码:**指出问题并给出改法**,除非本次需求正好涉及,否则不擅自批量重构。

5
.gitignore vendored
View File

@@ -15,6 +15,11 @@ RTK.md
.playwright-mcp .playwright-mcp
.omo .omo
.opencode .opencode
.workbuddy
specs2
.shots
docs
.claude
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

31
.mock/afterSales.json Normal file
View File

@@ -0,0 +1,31 @@
{
"code": 200,
"msg": "ok",
"data": {
"id": "1",
"projectNo": "20260813ZK2Q5f45",
"projectName": "社区康养智能服务系统",
"businessType": "互联网软件开发",
"status": "IN_PROGRESS",
"createTime": "2026-8-17 14:00",
"submitUserName": "张三",
"afterSale": {
"id": "1",
"requirementId": "1",
"type": "BUG问题",
"reason": "具体请看清单",
"remark": null,
"status": "IN_PROGRESS",
"requestUserName": "张三",
"requestTime": "2026-8-17 17:00",
"processUserName": null,
"processNote": null,
"processTime": null,
"withdrawReason": null
},
"afterSaleAttachments": [
{ "id": "1", "requirementId": "1", "phase": "AFTER_SALE", "ossId": "11", "originalName": "项目需求规格说明书.pdf", "url": "https://example.com/a.pdf", "fileSize": 2516582, "sortOrder": 1, "createTime": "2026-8-17 17:00" },
{ "id": "2", "requirementId": "1", "phase": "AFTER_SALE", "ossId": "12", "originalName": "项目需求说明书.word", "url": "https://example.com/b.docx", "fileSize": 2516582, "sortOrder": 2, "createTime": "2026-8-17 17:00" }
]
}
}

102
.mock/bills.json Normal file
View File

@@ -0,0 +1,102 @@
{
"code": 200,
"msg": "ok",
"data": [
{
"id": "1001",
"billNo": "20260813Z2Z5f245",
"projectId": "1",
"projectName": "某大型银行数字化转型方案",
"userId": "1",
"amount": 20000,
"billType": "ADVANCE",
"periodName": "第一期 · 付款 比例33%",
"periodRatio": 33,
"status": "PAID",
"payChannel": "WECHAT",
"transactionNo": "T2026081915303578K2M001",
"payTime": "2026-8-17 17:00",
"createTime": "2026-8-17 14:00"
},
{
"id": "1002",
"billNo": "20260813Z2Z5f245",
"projectId": "2",
"projectName": "智慧城市大数据平台建设项目",
"userId": "1",
"amount": 20000,
"billType": "PROGRESS",
"periodName": "第二期 · 付款 比例33%",
"periodRatio": 33,
"status": "PENDING",
"payChannel": null,
"transactionNo": null,
"payTime": null,
"createTime": "2026-8-17 14:00"
},
{
"id": "1003",
"billNo": "20260813Z2Z5f245",
"projectId": "3",
"projectName": "三甲医院暗审医疗信息化系统",
"userId": "1",
"amount": 20000,
"billType": "FINAL",
"periodName": "第三期 · 尾款 比例33%",
"periodRatio": 33,
"status": "UNSTARTED",
"payChannel": null,
"transactionNo": null,
"payTime": null,
"createTime": "2026-8-17 14:00"
},
{
"id": "1004",
"billNo": "20260813Z2Z5f246",
"projectId": "4",
"projectName": "社区康养智能服务系统",
"userId": "1",
"amount": 20000,
"billType": "ADVANCE",
"periodName": "第一期 · 预付款 比例30%",
"periodRatio": 30,
"status": "PAID",
"payChannel": "ALIPAY",
"transactionNo": "T2026081915303578K2M002",
"payTime": "2026-8-18 10:20",
"createTime": "2026-8-17 14:00"
},
{
"id": "1005",
"billNo": "20260813Z2Z5f247",
"projectId": "5",
"projectName": "智慧园区物联网平台",
"userId": "1",
"amount": 20000,
"billType": "PROGRESS",
"periodName": "第二期 · 进度款 比例35%",
"periodRatio": 35,
"status": "PENDING",
"payChannel": null,
"transactionNo": null,
"payTime": null,
"createTime": "2026-8-17 14:00"
},
{
"id": "1006",
"billNo": "20260813Z2Z5f248",
"projectId": "6",
"projectName": "某大型银行数字化转型方案",
"userId": "1",
"amount": 20000,
"billType": "ADVANCE",
"periodName": "第一期 · 预付款 比例33%",
"periodRatio": 33,
"status": "UNSTARTED",
"payChannel": null,
"transactionNo": null,
"payTime": null,
"createTime": "2026-8-17 14:00"
}
]
}

19
.mock/files.json Normal file
View File

@@ -0,0 +1,19 @@
{
"code": 200,
"msg": "ok",
"data": {
"id": "1",
"projectNo": "20260813ZK2Q5f45",
"projectName": "社区康养智能服务系统",
"businessType": "互联网软件开发",
"status": "IN_PROGRESS",
"createTime": "2026-8-17 14:00",
"submitUserName": "张三",
"requirementAttachments": [
{ "id": "1", "requirementId": "1", "phase": "REQUIREMENT", "ossId": "1", "originalName": "项目需求规格说明书.pdf", "url": "https://example.com/a.pdf", "fileSize": 2516582, "sortOrder": 1, "createTime": "2026-8-17 17:00" },
{ "id": "2", "requirementId": "1", "phase": "REQUIREMENT", "ossId": "2", "originalName": "项目需求说明书.word", "url": "https://example.com/b.docx", "fileSize": 2516582, "sortOrder": 2, "createTime": "2026-8-17 17:00" },
{ "id": "3", "requirementId": "1", "phase": "REQUIREMENT", "ossId": "3", "originalName": "项目需求规格说明书.pdf", "url": "https://example.com/c.pdf", "fileSize": 2516582, "sortOrder": 3, "createTime": "2026-8-17 17:00" },
{ "id": "4", "requirementId": "1", "phase": "REQUIREMENT", "ossId": "4", "originalName": "项目需求说明书.word", "url": "https://example.com/d.docx", "fileSize": 2516582, "sortOrder": 4, "createTime": "2026-8-17 17:00" }
]
}
}

27
.mock/finance.json Normal file
View File

@@ -0,0 +1,27 @@
{
"code": 200,
"msg": "ok",
"data": {
"id": "1",
"projectNo": "20260813ZK2Q5f45",
"projectName": "社区康养智能服务系统",
"businessType": "互联网软件开发",
"status": "IN_PROGRESS",
"createTime": "2026-8-17 14:00",
"submitUserName": "张三",
"bills": [
{ "id": "1001", "billNo": "2026083222P345", "projectId": "1", "projectNo": "20260813ZK2Q5f45", "projectName": "社区康养智能服务系统", "userId": "1", "amount": "20100.00", "contractId": null, "billType": "ADVANCE", "periodName": "第一期", "periodRatio": "33.00", "status": "PAID", "payChannel": "WECHAT", "transactionNo": "123D06891B30030/8K2M001", "tradeName": null, "payTime": "2026-8-17 17:00", "dueDate": null },
{ "id": "1002", "billNo": "2026083222P345", "projectId": "1", "projectNo": "20260813ZK2Q5f45", "projectName": "社区康养智能服务系统", "userId": "1", "amount": "20100.00", "contractId": null, "billType": "PROGRESS", "periodName": "第二期", "periodRatio": "33.00", "status": "PENDING", "payChannel": null, "transactionNo": null, "tradeName": null, "payTime": null, "dueDate": null },
{ "id": "1003", "billNo": "2026083222P345", "projectId": "1", "projectNo": "20260813ZK2Q5f45", "projectName": "社区康养智能服务系统", "userId": "1", "amount": "20100.00", "contractId": null, "billType": "FINAL", "periodName": "第三期", "periodRatio": "33.00", "status": "UNSTARTED", "payChannel": null, "transactionNo": null, "tradeName": null, "payTime": null, "dueDate": null }
],
"billPlans": [],
"invoices": [
{ "id": "1", "billId": "1001", "invoiceTitle": "测试", "taxNo": null, "invoiceType": "电子普通发票", "amount": "20100.00", "status": "ISSUED", "invoiceNo": null, "invoiceFile": "https://example.com/inv.pdf", "applyTime": "2026-8-17 17:00", "invoiceDate": null, "createTime": "2026-8-17 17:00" },
{ "id": "2", "billId": "1001", "invoiceTitle": "测试", "taxNo": null, "invoiceType": "电子普通发票", "amount": "20100.00", "status": "ISSUED", "invoiceNo": null, "invoiceFile": "https://example.com/inv2.pdf", "applyTime": "2026-8-17 17:00", "invoiceDate": null, "createTime": "2026-8-17 17:00" }
],
"contracts": [
{ "id": "1", "contractNo": "HT20260813001", "projectId": "1", "title": "项目需求规格说明书.pdf", "amount": "60300.00", "signStatus": "SIGNED", "status": "SIGNED", "signDate": "2026-8-17", "file": "https://example.com/contract.pdf", "createTime": "2026-8-17 17:00" },
{ "id": "2", "contractNo": "HT20260813002", "projectId": "1", "title": "项目需求说明书.word", "amount": "60300.00", "signStatus": "SIGNED", "status": "SIGNED", "signDate": "2026-8-17", "file": "https://example.com/contract2.docx", "createTime": "2026-8-17 17:00" }
]
}
}

42
.mock/progress.json Normal file
View File

@@ -0,0 +1,42 @@
{
"code": 200,
"msg": "ok",
"data": {
"id": "1",
"projectNo": "20260813ZK2Q5f45",
"projectName": "社区康养智能服务系统",
"businessType": "互联网软件开发",
"status": "IN_PROGRESS",
"createTime": "2026-8-17 14:00",
"submitUserName": "张三",
"projectStage": {
"id": "1",
"projectId": "1",
"requirementId": "1",
"stageName": "开发实现",
"status": "IN_PROGRESS",
"startTime": "2026-8-17",
"planTime": "2026-8-17",
"finishTime": null,
"developmentProgress": 65,
"sort": 3
},
"developmentProgress": 65,
"projectMilestones": [
{ "id": "1", "projectId": "1", "milestoneName": "需求认证", "sortOrder": 1, "status": "COMPLETED", "startTime": "2026-8-13 11:04:11", "endTime": "2026-8-13 11:04:11", "parallelFlag": false },
{ "id": "2", "projectId": "1", "milestoneName": "原型设计", "sortOrder": 2, "status": "COMPLETED", "startTime": "2026-8-13 11:04:11", "endTime": "2026-8-13 11:04:11", "parallelFlag": false },
{ "id": "3", "projectId": "1", "milestoneName": "开发实现", "sortOrder": 3, "status": "IN_PROGRESS", "startTime": null, "endTime": null, "parallelFlag": false },
{ "id": "4", "projectId": "1", "milestoneName": "需求验收", "sortOrder": 4, "status": "UNSTARTED", "startTime": null, "endTime": null, "parallelFlag": false },
{ "id": "5", "projectId": "1", "milestoneName": "项目交付", "sortOrder": 5, "status": "UNSTARTED", "startTime": null, "endTime": null, "parallelFlag": false }
],
"members": [
{ "id": "1", "projectId": "1", "userId": "1", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": true },
{ "id": "2", "projectId": "1", "userId": "2", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false },
{ "id": "3", "projectId": "1", "userId": "3", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false },
{ "id": "4", "projectId": "1", "userId": "4", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false },
{ "id": "5", "projectId": "1", "userId": "5", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false },
{ "id": "6", "projectId": "1", "userId": "6", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false },
{ "id": "7", "projectId": "1", "userId": "7", "userName": "张三", "roleKey": "pm", "roleName": "项目经理", "isOwner": false }
]
}
}

View File

@@ -1,412 +0,0 @@
# PRD — 虫子智造 研发智造一体化平台(Web 端)
> 来源:`docs/prototype/` 共 10 个墨刀演示原型(无蓝湖设计稿、无接口文档,本文档为唯一需求基线)。
> 项目定位:一站式研发智造一体化服务平台,打通需求提报 → 项目管控 → 合同签署 → 财务结算 → 售后迭代全流程。平台自营,对项目质量全权兜底。
## 1. 全局框架
### 1.1 顶部导航(AppHeader,全站公共)
| 导航项 | 路由 | 说明 |
| --- | --- | --- |
| 首页 | `/` | 门户首页 |
| 案例库 | `/cases` | 成功案例展示 |
| 需求提报 | `/demand` | 需求填报表单 |
| 项目中心 | `/project` | 项目列表/详情 |
| 合同中心 | `/contract` | 合同列表/详情/签署 |
| 财务中心 | `/finance` | 账单/支付/发票 |
| 个人中心 | `/profile` | 账号与资料管理 |
- 右侧:用户头像/昵称、消息铃铛(未读数角标,示例 3)、搜索等。
- 未登录访问受保护路由 → 跳转 `/login`(支持游客模式的部分页面除外)。
### 1.2 全局浮动组件
- **快捷提报**(右侧悬浮按钮):一键跳转 `/demand`。
- **在线客服**(右侧悬浮按钮):一键跳转 `/service`。
### 1.3 全局页脚(AppFooter)
- 版权声明:©2026 虫子智造 研发智造一体化平台,源码/图纸受平台知识产权保护。
- ICP 备案号、公安备案号。
- 联系方式:商务合作 business@chongzizhizao.com、技术支持 support@chongzizhizao.com、客服热线 400-XXX-XXXX、工作时间 周一–周五 9:00–18:00。
- 快速导航:首页 / 案例中心 / 需求提报 / 下载 APP / 帮助中心。
- 核心业务:互联网软件开发 / 硬件电路开发 / 物联网软硬一体化全案 / PCB 制版 & SMT 贴片生产 / 元器件集采与整机装配。
- 平台简介文案(研发智造一体化自营平台…)。
## 2. 页面清单及功能描述
| # | 模块 | 页面 | 原型文件 | 访问权限 |
| --- | --- | --- | --- | --- |
| 1 | home | 首页 | 首页-演示.html | 公开 |
| 2 | home | 业务详情 | 首页-演示.html(业务详情画板) | 公开 |
| 3 | cases | 案例库列表 | 案例库-演示.html | 公开 |
| 4 | cases | 案例详情 | 案例库-演示.html | 公开 |
| 5 | demand | 需求提报 | 需求填报-演示.html | 公开(提交需登录) |
| 6 | login | 登录/注册/找回密码 | 登录-演示.html | 游客 |
| 7 | project | 项目中心列表 | 项目中心-演示.html | 登录 |
| 8 | project | 项目详情 | 项目中心-演示.html | 登录 |
| 9 | project | 申请售后 | 项目中心-演示.html | 登录 |
| 10 | contract | 合同中心列表 | 合同管理-演示.html | 登录 |
| 11 | contract | 合同详情 | 合同管理-演示.html | 登录 |
| 12 | contract | 签署合同 | 合同管理-演示.html | 登录 |
| 13 | finance | 财务中心概览 | 财务中心-演示.html | 登录 |
| 14 | finance | 账单明细 | 财务中心-演示.html | 登录 |
| 15 | finance | 账单详情 | 财务中心-演示.html | 登录 |
| 16 | finance | 扫码支付/支付结果 | 财务中心-演示.html | 登录 |
| 17 | finance | 发票申请 | 财务中心-演示.html | 登录 |
| 18 | finance | 发票抬头管理 | 财务中心-演示.html | 登录 |
| 19 | message | 消息中心 | 消息中心-演示.html | 登录 |
| 20 | message | 系统通知详情 | 消息中心-演示.html | 登录 |
| 21 | service | 在线客服 | 在线客服-演示.html | 登录 |
| 22 | profile | 账号安全 | 个人中心-演示.html | 登录 |
| 23 | profile | 地址管理 | 个人中心-演示.html | 登录 |
| 24 | profile | 收藏列表 | 个人中心-演示.html | 登录 |
| 25 | profile | 资质列表 | 个人中心-演示.html | 登录 |
| 26 | profile | 意见反馈 | 个人中心-演示.html | 登录 |
| 27 | profile | 帮助中心 | 个人中心-演示.html | 登录 |
| 28 | profile | 关于我们 | 个人中心-演示.html | 登录 |
| 29 | profile | 专属客服热线 | 个人中心-演示.html | 登录 |
---
## 3. 各页面交互逻辑
### 3.1 首页(home)
- **欢迎区(登录后)**:问候语「中午好」+ 当日摘要「今天有2个项目正在进行」。
- **数据看板**:待您处理 3 / 进行中 3 / 累计项目 6。
- **快捷入口**:财务台账、合同台账、客服工作台、案例库管理、工单需求池(跳对应模块)。
- **业务服务**:4 张卡片(互联网软件 / 硬件电路 / 物联网全案 / 生产制造),每张含描述 + 「了解详细 >」→ 业务详情页。
- **案例库精选**:按业务类型 Tab(全部/互联网软件/硬件电路/物联网全案/生产制造)筛选案例卡片,「查看更多 >」→ 案例库,「查看详情 >」→ 案例详情。
- **服务区(Tab 切换)**:服务类型(Web应用/小程序/APP/二次开发/桌面工具)与服务内容(五类服务条款富文本 + 视频)。
### 3.2 业务详情(home detail)
- 面包屑「首页 > 业务详情」。
- 业务线介绍(标题 + slogan,如「专业的软件定制开发服务,从需求到上线全流程保障」)。
- 服务内容富文本 + 视频(预览播放)。
- 服务类型子项切换(Web/小程序/APP/二次开发/桌面工具)。
### 3.3 案例库列表(cases)
- 头部:标题「成功案例库」+ 副文案。
- **筛选面板**:
- 业务类型:全部/互联网软件/硬件电路/物联网全案/生产制造(单选,带计数 25/25/25/25/100)。
- 所属行业:全部/电商零售/教育培训/医疗健康/硬件电路/能源环保/房地产(多选按钮组)。
- 项目规模:全部/50万以下/50-100万/100-300万/300-500万/500万以上(多选)。
- 完成时间:全部/近3个月/近半年/近一年/开始时间—结束时间(日期范围)。
- 关键字搜索 + 重置。
- 已选条件回显(「已选条件(4个)」+ 条件 chip × 移除 + 清空)、「确定筛选(100个结果)」。
- **案例卡片**:封面图、业务类型标签、标题、查看详情 >;字段:完成时间/服务团队/规模/所属行业/项目周期/客户类型。
- 分页器(‹ 1 2 3 4 5 ›)。
- **收藏列表**(profile 模块复用):同卡片布局,展示已收藏案例。
### 3.4 案例详情(cases detail)
- 面包屑「案例库 > 案例详情」。
- 项目概览:标题、业务类型、完成时间/服务团队/规模/所属行业/项目周期/客户类型。
- 操作:**收藏**(收藏/已收藏切换)、**分享**。
- 视频区(预览播放)。
- 富文本区块:项目背景、客户痛点、需求描述、交付物清单。
- 底部 CTA:**一键填报类似案例** → 跳转 `/demand` 并预填业务类型。
### 3.5 需求提报(demand)
分步表单(1 选择业务类型 → 2 填写需求信息 → 3 确认信息并补充备注):
1. **选择业务类型***(单选 5 项):互联网软件 / 物联网全案 / 生产制造 / 硬件电路 / 其他(「若没有对应的服务内容请选择该选项」),每项含图标 + 描述。
2. **填写需求信息***:
- 项目名称*(文本)。
- 附件上传:支持图片/文档/压缩包,大小不超过 1G;支持上传按钮 + 文件列表(名称/大小/上传时间/预览/下载/删除)。
- 核心需求描述*(多行,0/500 字计数)。
- 预计开发周期(工作日,数字)。
- 预算范围(按钮单选:1万以下 / 1-5万 / 5-10万 / 10万+)。
3. **确认信息并补充备注**:
- 用户名称(默认当前登录用户「王永志」)。
- 用户手机号(脱敏 1866\*\*\*\*9618,可手填)。
- 补充备注(0/500 字计数)。
- 底部:提示语「提交后我们的专业顾问将在一个工作日内与您联系」。
- 操作按钮:**立即提报** / **保存草稿** / **重置** / 手填。
### 3.6 登录/注册/找回密码(login)
统一视觉:左侧品牌区(logo + 「虫子智造研发一体化平台」+ slogan「安全 · 高效 · 可控」),右侧表单区;顶部「游客模式」入口、「没有账号?立即注册 / 已有账号?立即登录」切换。
- **账号密码登录**:账号、密码、协议勾选(《用户协议》《隐私政策》)、登录按钮。
- **手机验证码登录**:手机号、验证码、「获取验证码 / 60秒后重新获取」倒计时、协议勾选、登录按钮。
- **注册**:手机号、验证码、设置密码(6-20 位,**密码强度指示:弱/中/强**,强度过低提示「密码等级过低,请重新输入」)、确认密码、协议勾选、注册按钮。
- **找回密码**:手机号、验证码、设置密码、确认密码、「确认修改」。
- 状态反馈:「注册成功,请登录」「修改成功,请登录」「登录失败,请先注册账号」,**3 秒后自动跳转**。
### 3.7 项目中心列表(project)
- 标题 + 副文案「查看和管理您的所有项目,实时跟踪项目进度」。
- 筛选:关键字搜索、业务类型(全部/互联网软件/硬件电路/物联网全案/生产制造)、项目状态(全部/待立项/进行中/待验收/已完成/售后中/已取消,带计数 25/25/25/25/100)、提交时间(全部/近3个月/近半年/近一年/起止日期)、已选条件回显、确定筛选/重置。
- 表格列:序号、项目编号、项目名称、业务类型、进度(64%)、状态、操作(详情;已完成时另有**申请售后**)。
- 状态枚举:待立项 / 进行中 / 待验收 / 已完成 / 售后中 / 已取消。
- 分页器。
### 3.8 项目详情(project detail)
- 面包屑「项目中心 > 项目详情」。
- 头部信息卡:项目名称(社区康养智能服务系统)、项目编号、业务类型、提交时间、状态标签。
- 项目团队:项目经理卡片(头像/姓名/角色,张三)。
- **Tab 四页签**:
1. **项目进度**:总体进度 65% + 开发进度滑杆;里程碑时间轴(需求确认 → 原型设计 → 开发实现 → 测试验收 → 项目交付),每节点状态(待开始/进行中/已完成)+ 完成时间;开始/结束时间。
2. **项目文件**:文件列表(项目需求规格说明书.pdf 等,2.4MB · 上传时间),操作:**预览 / 下载 / 删除**;**上传文档**按钮。
3. **合同财务**:付款计划卡片列表(第一期·预付款 比例33% / 第二期·付款 33% / 第三期·尾款 33%,含账单号、关联项目、金额 ¥20100,状态:已支付(含交易流水号)/待支付(**立即支付**按钮)/未到期);发票记录(预览/下载);合同文件(预览/下载)。
4. **售后信息**:售后工单状态卡(「售后工单处理中——工程师正在处理您的修改需求」);**申请售后 / 取消售后**按钮。
### 3.9 申请售后(project → after-sales)
- 分步表单:1 选择业务类型*、2 填写问题描述*、3 补充备注。
- 字段:项目名称*(默认当前项目)、售后类型*(推荐选项 + 手动输入,示例「BUG问题」)、问题描述*(0/500 字,「请详细描述您的问题...」)、附件上传(图片/文档/压缩包 ≤1G,文件列表预览/下载/删除)、补充备注(0/500 字)。
- 操作:**申请售后**(提交,生成售后工单) / **重置** / **取消**。
### 3.10 合同中心列表(contract)
- 标题「合同中心」+ 副文案「项目电子合同在线签署、归档与查询管理」。
- 关键字搜索 + 搜索/重置。
- 表格列:合同编号、合同名称、合同类型、合同金额、关联项目、项目编号、签署状态、生效时间、到期时间、操作(**去签署**(待签署时)/ 详情)。
- 签署状态枚举:履行中 / 已过期 / 待签署。
- 分页器。
### 3.11 合同详情(contract detail)
- 面包屑「合同中心 > 合同详情」。
- 合同信息(只读表单):合同名称、合同类型、合同编号、签订日期、生效日期、到期日期、合同状态。
- 关联项目:项目编号、项目名称、「查看详情」→ 项目详情。
- 合同文件:文件卡(预览/下载)。
- 操作:**返回**。
### 3.12 签署合同(contract sign)
- 面包屑「合同中心 > 合同详情 > 签署合同」。
- 左侧二维码(**04:32 倒计时失效**,过期可点击刷新)。
- 右侧「扫码验证签署操作步骤」四步引导:
1. 打开手机支付宝(右上角 + → 扫一扫)。
2. 验证身份信息(实名验证,确保本人操作)。
3. 确认合同内容并签署(指定位置签署)。
4. 完成签署(页面自动跳转至合同详情页)。
### 3.13 财务中心概览(finance)
- 标题「财务中心」+ 副文案「账单、支付、发票统一财务管理中心」。
- **累计消费总额**卡片(¥1,726,823,900)+ 月份选择(2026年1月 SELECT)+ 时间维度切换(本季度/本年)。
- **业务类型分布**图表(生产智造/物联网全案/硬件电路/互联网软件 饼图)。
- 待支付角标(3)。
- **快捷入口**:发票申请、账单明细、报表导出。
### 3.14 账单明细(finance bills)
- 面包屑「财务中心 > 账单明细」。
- 状态 Tab:全部 / 待支付 / 已支付 / 未到期(带计数)。
- 关键字搜索 + 搜索/重置。
- 表格列:多选框、序号、账单名称(第一期·预付款 比例33%)、账单号、交易流水号、项目名称、订单金额、状态、操作(**去支付**(待支付)/ 详情)。
- 汇总条:总笔数 10、总金额 123212、待支付 3。
- 多选后:「已选择1笔订单 · 合计:¥20000」+ **立即支付**。
- 分页器。
### 3.15 账单详情(finance bill detail)
- 面包屑「财务中心 > 账单明细 > 账单详情」。
- 订单信息卡:账单名称、账单号、关联项目、金额。
- 待支付订单态:**立即支付**入口。
- 已支付订单态:支付信息(付款金额、支付方式、支付时间、交易流水号)。
- 未到期态:状态提示。操作:**返回**。
### 3.16 扫码支付 + 支付结果(finance pay)
- 面包屑「财务中心 > 账单明细 > 账单详情 > 扫码支付」。
- 左侧支付二维码(**04:32 倒计时**,过期可刷新),安全提示「本页面已通过安全验证,请勿向他人泄露付款码」。
- 支付方式:微信支付 / 支付宝支付(**切换支付方式**弹窗:单选,「推荐使用」「其他用户选择」标签)。
- 右侧:支付金额(¥20000)+ 订单明细列表(账单号/关联项目/期数/金额)。
- 「扫码支付操作步骤」三步引导(微信/支付宝各自文案)。
- **支付结果页**:支付成功(✓、付款金额、共N个订单、支付信息:支付方式/支付时间/交易流水号、已支付订单列表)。
### 3.17 发票申请(finance invoices)
- 面包屑「财务中心 > 发票申请」。
- **发票抬头卡**:默认抬头(企业名称 + 税号 + 「企业」标签 + 「默认」标签)、「管理抬头 >」。
- 状态 Tab:全部 / 已开票 / 开票中 / 未开票。
- 表格列:多选框、序号、账单名称、账单号、交易流水号、项目名称、订单金额、状态、申请时间、操作:
- 已开票:**预览 / 下载 / 发送邮箱**;
- 开票中:无操作(进行中);
- 未开票:**申请开票**。
- 关键字搜索 + 分页器。
### 3.18 发票抬头管理(finance invoice-titles)
- 列表卡:抬头类型(企业单位/非企业单位)、发票抬头、税号、默认标签。
- 操作:**+ 新增抬头**、编辑、删除、设为默认 / 取消默认。
- 弹窗(新增/编辑):抬头类型(单选:企业单位/非企业单位)、发票抬头*、税号*。
- 删除二次确认弹窗:「删除后无法恢复,是否继续?」(取消/确认删除)。
### 3.19 消息中心(message)
- 标题「消息中心」+ 副文案「统一接收项目进度、售后工单、账单待办等站内消息,重要业务动态实时提醒」。
- 分类 Tab:全部 / 在线客服 / 状态通知 / 系统通知(未读数角标 3/3/3/9)。
- 关键字搜索 + 搜索/重置 + **全部已读**按钮。
- 消息条目:类型图标、类型标签(系统通知/合同生效/在线客服)、摘要、「刚刚」相对时间、未读标记。
- 分页器。
### 3.20 系统通知详情(message detail)
- 面包屑「消息中心 > 系统通知详情」。
- 标题 + 发布时间 + 富文本正文(功能亮点/使用场景/入口位置等)。
- 附件(PDF 2.5MB,预览/下载)。
- 操作:**返回**。
### 3.21 在线客服(service)
- 标题「在线客服」+ 副文案「在线客服为您提供需求咨询、项目进度查询、业务答疑…」。
- 聊天窗口:客服头像/名称(专属客服)、欢迎语、**历史消息分割线**、「未读」标记。
- 消息类型:文本、文件(物联网全案服务介绍.pdf 2.5MB)、时间戳(今天 10:23)。
- 输入区:多行输入(placeholder「输入消息,按Enter发送,Shift+Enter换行...」)、**Enter 发送**、**拖拽上传文件**、发送按钮。
### 3.22 个人中心(profile)— 左侧菜单布局
左侧菜单:账号安全 / 地址管理 / 收藏列表 / 资质列表 / 意见反馈 / 帮助中心 / 关于我们 / 专属客服热线。头部用户卡:默认名称 + 头像 + 手机号(脱敏,可编辑昵称)。
1. **账号安全**:
- 修改密码(上次修改:15天前;手机号 + 验证码 + 设置密码 + 确认密码 + 强度提示 + 协议勾选 + 确认修改)。
- 绑定手机(已绑定 186\*\*\*\*618;换绑流程:验证并换绑 → 新手机号 + 验证码,提示「换绑新手机号之后,可以用新的手机号及当前密码登录」)。
- 绑定邮箱(已绑定/未绑定 yongzh\*\*\*\*\*\*4@163.com;绑定/更换绑定邮箱/取消绑定,邮箱 + 邮箱验证码)。
- **注销账号**:注销条件校验(账号无未完成的订单/未结清的款项/进行中的合同/未处理的工单)→ 风险提示 4 条 → 同意《账号注销协议》→ 安全身份验证(发送验证码至 186\*\*\*\*9618)→ 二次确认弹窗「确定要注销账号吗?」(暂不注销/注销账号)→ 「注销成功」3 秒后返回登录页。
2. **地址管理**:地址卡列表(收货人姓名/联系方式/所在地区/详细地址/默认标签);+ 新增地址、编辑、删除(二次确认)、设为默认/取消默认;分页。
3. **收藏列表**:收藏的案例卡片(同 3.3 卡片),筛选面板同案例库。
4. **资质列表**:资质卡(资质类型/证书名称/证书编号/发证单位/有效期/状态:已认证、审核中、未通过);+ 新增资质/提交认证、编辑、删除(二次确认)、重新上传(未通过时);分页。
- 新增/编辑弹窗:资质类型*(SELECT:营业执照/生产许可证/体系认证/检测报告/授权书/其他资质)、证书名称*、证书编号*、发证单位*、有效期*(起止日期)、附件上传(图片/文档/压缩包,添加附件)。
5. **意见反馈**:反馈标题*、详细描述*(0/500 字)、附件上传(≤1G,预览/删除)、**提交反馈**/重置;「提交成功」提示。
6. **帮助中心**:FAQ 折叠组(1.账户与登录、2.功能使用指南、3.系统与权限,各含 Q/A)。
7. **关于我们**:平台简介、我们的使命、核心优势(4 条)、联系信息(客服热线/工作时间/平台官网链接 www.chongzizhizao.com/公司地址 山东省日照市)、版本信息(V1.0.0,更新时间 2026年09月)。
8. **专属客服热线**:电话 000-000-0000 + 工作时间(工作日 8:00-12:00 14:00-18:00)。
---
## 4. 数据字段定义(核心实体)
### 4.1 Case(案例)— `types/business/cases.d.ts`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | string | 案例 ID |
| title | string | 项目名称 |
| category | BusinessType | 业务类型 |
| cover | string | 封面图 |
| finishDate | string | 完成时间 |
| teamSize | number | 服务团队(人) |
| scale | ScaleRange | 规模区间 |
| industry | string | 所属行业 |
| duration | string | 项目周期 |
| clientType | string | 客户类型 |
| background / painPoints / requirements / deliverables | string | 详情富文本 |
| video | string | 视频地址 |
| isFavorite | boolean | 是否收藏 |
### 4.2 Demand(需求)— `types/business/demand.d.ts`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| businessType | BusinessType | 必填,单选(含「其他」) |
| projectName | string | 必填 |
| attachments | FileInfo[] | ≤1G |
| coreDescription | string | 必填,≤500 字 |
| expectedDuration | number | 预计开发周期(工作日) |
| budgetRange | 'lt1w' \| '1to5w' \| '5to10w' \| 'gt10w' | 预算范围 |
| userName / userPhone | string | 联系人 |
| remark | string | ≤500 字 |
| status | 'draft' \| 'submitted' | 草稿/已提交 |
### 4.3 Project(项目)— `types/business/project.d.ts`
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| projectNo | string | 项目编号 |
| projectName | string | 项目名称 |
| businessType | BusinessType | 业务类型 |
| progress | number | 进度 % |
| status | ProjectStatus | 待立项/进行中/待验收/已完成/售后中/已取消 |
| submitTime / startTime / endTime | string | 时间 |
| milestones | Milestone[] | 需求确认/原型设计/开发实现/测试验收/项目交付(status + time) |
| team | TeamMember[] | 项目经理等 |
| files | FileInfo[] | 项目文件 |
| paymentPlans | Bill[] | 付款计划 |
| invoiceRecords | Invoice[] | 发票记录 |
| contractFile | FileInfo | 合同文件 |
| afterSales | AfterSales | 售后工单 |
### 4.4 Contract(合同)— `types/business/contract.d.ts`
contractNo、contractName、contractType、contractAmount、relatedProject({ id, name })、projectNo、signStatus(履行中/已过期/待签署)、signDate、effectiveDate、expiryDate、files: FileInfo[]。
### 4.5 Bill / Payment / Invoice / InvoiceTitle — `types/business/finance.d.ts`
- **Bill**:billName(含期数与比例)、billNo、tradeNo、projectName、amount、status(待支付/已支付/未到期)、createTime。
- **Payment**:method(wechat/alipay)、amount、payTime、tradeNo、qrCode、expiresAt(倒计时)。
- **Invoice**:bill(关联账单)、status(已开票/开票中/未开票)、applyTime、file。
- **InvoiceTitle**:type(企业单位/非企业单位)、title、taxNo、isDefault。
### 4.6 Message(消息)— `types/business/message.d.ts`
id、type(在线客服/状态通知/系统通知)、title、summary、content(富文本)、publishTime、isRead、attachments: FileInfo[]。
### 4.7 用户与账号 — `types/business/user.d.ts` / `types/business/profile.d.ts`
- **User**:id、nickname、avatar、phone(脱敏展示)、email(脱敏展示)、passwordModifiedAt。
- **Address**:id、receiverName、phone、region、detailAddress、isDefault。
- **Qualification**:id、type(营业执照/生产许可证/体系认证/检测报告/授权书/其他)、certName、certNo、issuer、validFrom、validTo、status(已认证/审核中/未通过)、attachments: FileInfo[]。
### 4.8 通用 — `types/api.d.ts`
- `ApiResponse<T> { code, msg, data }`、`PageResult<T> { list, total }`、`FileInfo { name, size, uploadTime, url }`。
---
## 5. 组件拆分建议
### 5.1 公共组件(`src/components/`,≥2 页面使用)
| 组件 | 使用页面 |
| --- | --- |
| AppHeader.vue | 全站(导航 + 消息铃铛 + 用户入口) |
| AppFooter.vue | 全站 |
| AppButton.vue | 全站按钮统一 |
| AppSwiper.vue | 首页 banner、业务服务卡片轮播 |
| CaseCard.vue | 案例库、首页精选、收藏列表 |
| FilterPanel.vue | 案例库、项目中心、收藏列表(筛选 + 已选条件回显) |
| SearchBar.vue | 案例库/项目/合同/账单/发票/消息列表 |
| PaginationBar.vue | 所有列表页 |
| FileList.vue | 需求填报、项目文件、售后、资质、意见反馈(文件卡 + 预览/下载/删除) |
| UploadArea.vue | 同上(拖拽/点击上传 + 1G 限制提示) |
| SmsCodeInput.vue | 登录/注册/找回密码/账号安全 |
| PasswordStrength.vue | 注册/修改密码(弱/中/强指示) |
| QrCodePanel.vue | 合同签署、扫码支付(倒计时 + 刷新) |
| StatusTag.vue | 项目状态/签署状态/账单状态/发票状态/资质状态 |
| ConfirmDialog.vue | 删除二次确认、注销确认 |
| StepForm.vue | 需求提报、申请售后(分步表单骨架) |
### 5.2 页面独有组件(`views/<page>/cpns/`)
| 页面 | 组件 |
| --- | --- |
| home | HomeWelcome.vue、HomeStats.vue、HomeQuickEntries.vue、HomeBusinessCards.vue、HomeCaseShowcase.vue、HomeServiceTabs.vue、BusinessDetail.vue |
| cases | CasesFilter.vue、CasesGrid.vue、CaseDetailHeader.vue、CaseRichText.vue |
| demand | DemandStep1Business.vue、DemandStep2Info.vue、DemandStep3Confirm.vue |
| login | LoginForm.vue、SmsLoginForm.vue、RegisterForm.vue、ResetForm.vue |
| project | ProjectFilter.vue、ProjectTable.vue、ProjectInfoCard.vue、ProjectTeam.vue、ProjectMilestones.vue、ProjectFiles.vue、ProjectFinance.vue、ProjectAfterSales.vue、AfterSalesForm.vue |
| contract | ContractTable.vue、ContractInfoCard.vue、ContractSignPanel.vue、SignSteps.vue |
| finance | FinanceOverview.vue、FinanceChart.vue、BillTable.vue、BillSummaryBar.vue、BillPayPanel.vue、PayMethodDialog.vue、PayResult.vue、InvoiceTable.vue、InvoiceTitleCard.vue、InvoiceTitleDialog.vue |
| message | MessageTabs.vue、MessageList.vue、MessageItem.vue、NoticeDetail.vue |
| service | ChatWindow.vue、ChatMessageItem.vue、ChatFileCard.vue、ChatInput.vue |
| profile | ProfileSider.vue、ProfileUserCard.vue、SecurityPanel.vue、ChangePasswordDialog.vue、UnbindAccountDialog.vue、AddressCard.vue、AddressDialog.vue、FavoritesGrid.vue、QualificationCard.vue、QualificationDialog.vue、FeedbackForm.vue、HelpFaq.vue、AboutPanel.vue、HotlinePanel.vue |
---
## 6. 待决事项(接口文档/蓝湖到位后补充)
1. 接口路径/入参出参以接口文档为准;当前仅按本 PRD 定义前端实体类型。
2. 蓝湖视觉参数(色值/字号/间距/圆角/阴影)待设计稿提供,暂以原型提取值近似。
3. 支付(微信/支付宝)与电子签章(支付宝扫码)对接方式待后端确认。
4. 「下载 APP」「报表导出」「发送邮箱」的交互细节待确认。

View File

@@ -1,170 +0,0 @@
# 开发规划 — cz-web(auto-dev 全流程跟踪表)
## 使用说明(工作规则)
1. **状态标记**:`[x]` = 开发完成并验证通过;`[~]` = 开发中;`[ ]` = 未开始;`[!]` = 阻塞(注明原因)。
2. **每完成一项立即打 `[x]`**(开发结束标记),不得批量补标。
3. **之后只做未完成(`[ ]`/`[~]`/`[!]`)的部分**,已完成项不再重做,除非回归失败。
4. 每个模块收尾必须过 `pnpm build`(vue-tsc 零错误)才算可打 `[x]`。
5. 当前约束:**无蓝湖设计稿、无接口文档** → 阶段二/接口细节 `[!]` 阻塞中;页面代码按 PRD + 原型降级开发(结构/交互先行,视觉参数用 `styles/variables.less` 近似值,接口用 store 层 mock 临时实现并集中标注 `// TODO(api)`,文档到位后仅替换 api 层)。
## 阶段总览
| 阶段 | 内容 | 状态 |
| --- | --- | --- |
| 阶段一 | PRD 生成(`docs/PRD.md`) | [x] |
| 阶段二 | 蓝湖 MCP 设计稿读取(标注参数/切图) | [!] 无设计稿,阻塞 |
| 阶段三 | 页面结构规划(`docs/page-architecture.md`) | [x] |
| 阶段四 | 页面代码生成 + 组件拆分 | [x] |
| 阶段五 | API + Store 逻辑层 | [x] |
| 阶段六 | 公共组件提取 | [x] |
| 阶段七 | 自动验证与修正(`pnpm build` 零错误) | [x] |
| 补齐 | 接口文档落地后替换 mock api 层 | [!] 无接口文档,阻塞 |
| 补齐 | 蓝湖到位后校准视觉(色值/间距/字号/切图) | [!] 无设计稿,阻塞 |
## P0 基础工程(全模块前置)
- [x] vite.config.ts:配置 `@/` 别名、`css.preprocessorOptions.less.additionalData` 注入 variables.less、安装 less
- [x] src/styles/:index.less + variables.less(色/字/间距/圆角近似值)+ mixins.less + reset.less + common.less
- [x] src/types/api.d.ts:ApiResponse\<T\>、PageResult\<T\>、FileInfo
- [x] src/types/router.d.ts:RouteMeta 扩展(title、requiresAuth)
- [x] src/types/global.d.ts + src/env.d.ts
- [x] src/utils/:request.ts(唯一 axios 实例 + 拦截器)、storage.ts、validate.ts、format.ts
- [x] src/composables/:useCountDown.ts、useLoading.ts、useScroll.ts、useIntersection.ts
- [x] src/directives/:lazy.ts、debounce.ts、throttle.ts、index.ts(setupDirectives)
- [x] src/stores/index.ts(pinia 实例)+ stores/modules/site.ts(全局 loading/站点配置)
- [x] src/router/index.ts + routes.ts + guard.ts(Home + 404 先行注册,其余路由随 M1–M10 各自注册步骤接入)
- [x] src/layout/index.vue(AppHeader + router-view + AppFooter 骨架)
- [x] src/components/:AppHeader.vue、AppFooter.vue、AppButton.vue、index.ts
- [x] src/main.ts:注册 pinia/router/指令、引入 styles/index.less
- [x] 验证:`pnpm build` 零错误(vue-tsc + vite build 通过;Playwright 冒烟:header/footer/占位页渲染正常,控制台 0 错误)
## 模块开发清单(每模块七步,完成即打 [x])
### M1 home(首页 + 业务详情)
- [x] types/business/home.d.ts(含 cases.d.ts 案例实体与 api.d.ts BusinessType 共享枚举,首页案例卡提前复用)
- [x] api/home.ts(getHomeStats、getBusinessLines、getFeaturedCases、getBusinessDetail)`// TODO(api)`
- [x] stores/modules/home.ts(useHomeStore)
- [x] views/home/index.vue + index.ts + cpns/(HomeWelcome、HomeStats、HomeQuickEntries、HomeBusinessCards、HomeCaseShowcase、HomeServiceTabs)
- [x] views/home/detail.vue(业务详情)
- [x] router 注册 `/` 与 `/business/:type`
- [x] 复用检查 + `pnpm build` 通过(Playwright QA:首页六区块渲染、Tab 筛选生效、业务详情页内容完整、控制台 0 错误)
### M2 cases(案例库)
- [x] types/business/cases.d.ts(M1 已建,M2 直接复用)
- [x] api/cases.ts(getCaseList、getCaseDetail、toggleFavorite、getCaseCounts)`// TODO(api)`(8 条 mock + 筛选/分页)
- [x] stores/modules/cases.ts(useCasesStore:列表/筛选/分页/收藏)
- [x] views/cases/index.vue + detail.vue + index.ts + cpns/(CasesFilter、CasesGrid、CaseDetailHeader、CaseRichText)
- [x] 公共组件 CaseCard(M1 已建)、FilterPanel、PaginationBar 上移 components/
- [x] router 注册 `/cases`、`/cases/:id`
- [x] 复用检查 + `pnpm build` 通过(Playwright QA:4 组筛选 + 计数 8/4/2/1、筛选 8→4 卡 + chip 回显、详情四段富文本、收藏切换 + toast、控制台 0 错误)
### M3 demand(需求提报)
- [x] types/business/demand.d.ts
- [x] api/demand.ts(submitDemand、saveDraft、uploadAttachment)`// TODO(api)`
- [x] stores/modules/demand.ts(useDemandStore:表单态/草稿/提交)
- [x] views/demand/index.vue + index.ts + cpns/(DemandStep1Business、DemandStep2Info、DemandStep3Confirm)
- [x] 公共组件 StepForm、FileList、UploadArea、CaseCard 复用接入
- [x] router 注册 `/demand`
- [x] 复用检查 + `pnpm build` 通过
### M4 login(登录/注册/找回密码)
- [x] types/business/user.d.ts
- [x] api/auth.ts(loginByAccount、loginBySms、sendSmsCode、register、resetPassword)`// TODO(api)`
- [x] stores/modules/user.ts(useUserStore:token/用户信息/登录态)
- [x] views/login/index.vue(登录)+ register.vue + reset.vue + index.ts + cpns/(LoginForm、SmsLoginForm、RegisterForm、ResetForm)
- [x] 公共组件 SmsCodeInput、PasswordStrength 上移
- [x] router 注册 `/login`、`/register`、`/reset-password` + guard.ts 登录守卫
- [x] 复用检查 + `pnpm build` 通过
### M5 project(项目中心)
- [x] types/business/project.d.ts
- [x] api/project.ts(getProjectList、getProjectDetail、uploadProjectFile、deleteProjectFile、submitAfterSales、cancelAfterSales)`// TODO(api)`
- [x] stores/modules/project.ts(useProjectStore:列表/筛选/详情/文件/售后)
- [x] views/project/index.vue + detail.vue + after-sales.vue + index.ts + cpns/(ProjectFilter、ProjectTable、ProjectInfoCard、ProjectTeam、ProjectMilestones、ProjectFiles、ProjectFinance、ProjectAfterSales、AfterSalesForm)
- [x] 公共组件 StatusTag、FileList、StepForm 复用接入
- [x] router 注册 `/project`、`/project/:id`、`/project/:id/after-sales`
- [x] 复用检查 + `pnpm build` 通过
### M6 contract(合同中心)
- [x] types/business/contract.d.ts
- [x] api/contract.ts(getContractList、getContractDetail、getSignQr、refreshSignQr)`// TODO(api)`
- [x] stores/modules/contract.ts(useContractStore:列表/详情/签署二维码倒计时)
- [x] views/contract/index.vue + detail.vue + sign.vue + index.ts + cpns/(ContractTable、ContractInfoCard、ContractSignPanel、SignSteps)
- [x] 公共组件 QrCodePanel、StatusTag 复用接入
- [x] router 注册 `/contract`、`/contract/:id`、`/contract/:id/sign`
- [x] 复用检查 + `pnpm build` 通过
### M7 finance(财务中心)
- [x] types/business/finance.d.ts
- [x] api/finance.ts(getFinanceStats、getBillList、getBillDetail、createPayment、getPayResult、getInvoiceList、applyInvoice、sendInvoiceEmail、getInvoiceTitles、createInvoiceTitle、updateInvoiceTitle、deleteInvoiceTitle、setDefaultTitle、exportReport)`// TODO(api)`
- [x] stores/modules/finance.ts(useFinanceStore:概览统计/账单/支付/发票/抬头)
- [x] views/finance/index.vue(概览)+ bills.vue + bill-detail.vue + pay.vue + pay-result.vue + invoices.vue + invoice-titles.vue + index.ts + cpns/(FinanceOverview、FinanceChart、BillTable、BillSummaryBar、BillPayPanel、PayMethodDialog、PayResult、InvoiceTable、InvoiceTitleCard、InvoiceTitleDialog)
- [x] 公共组件 QrCodePanel、StatusTag、ConfirmDialog、PaginationBar 复用接入
- [x] router 注册 `/finance` 及 6 个子路由
- [x] 复用检查 + `pnpm build` 通过
### M8 message(消息中心)
- [x] types/business/message.d.ts
- [x] api/message.ts(getMessageList、getMessageDetail、markAllRead、markRead)`// TODO(api)`
- [x] stores/modules/message.ts(useMessageStore:列表/分类未读数/详情)
- [x] views/message/index.vue + detail.vue + index.ts + cpns/(MessageTabs、MessageList、MessageItem、NoticeDetail)
- [x] AppHeader 消息铃铛角标接入 message store
- [x] router 注册 `/message`、`/message/:id`
- [x] 复用检查 + `pnpm build` 通过
### M9 service(在线客服)
- [x] types/business/service.d.ts
- [x] api/service.ts(getChatHistory、sendChatMessage、uploadChatFile)`// TODO(api)`
- [x] stores/modules/service.ts(useServiceStore:会话/消息/发送)
- [x] views/service/index.vue + index.ts + cpns/(ChatWindow、ChatMessageItem、ChatFileCard、ChatInput)
- [x] 全局浮动「在线客服」入口接入
- [x] router 注册 `/service`
- [x] 复用检查 + `pnpm build` 通过
### M10 profile(个人中心)
- [x] types/business/profile.d.ts
- [x] api/profile.ts(updateNickname、changePassword、bindPhone、unbindPhone、bindEmail、unbindEmail、deleteAccount、verifyIdentity、getAddresses、createAddress、updateAddress、deleteAddress、setDefaultAddress、getFavorites、getQualifications、createQualification、updateQualification、deleteQualification、submitFeedback)`// TODO(api)`
- [x] stores/modules/profile.ts(useProfileStore:账号/地址/收藏/资质/反馈)
- [x] views/profile/index.vue(左菜单布局)+ security.vue + address.vue + favorites.vue + qualifications.vue + feedback.vue + help.vue + about.vue + hotline.vue + index.ts + cpns/(ProfileSider、ProfileUserCard、SecurityPanel、ChangePasswordDialog、UnbindAccountDialog、AddressCard、AddressDialog、FavoritesGrid、QualificationCard、QualificationDialog、FeedbackForm、HelpFaq、AboutPanel、HotlinePanel)
- [x] 公共组件 SmsCodeInput、PasswordStrength、ConfirmDialog、FileList 复用接入
- [x] router 注册 `/profile` 嵌套 8 子路由
- [x] 复用检查 + `pnpm build` 通过
## 收尾阶段
- [x] 阶段六:全局扫描,跨页面重复 UI 上移 components/,更新 components/index.ts barrel
- [x] 阶段七:逐页对照 PRD/原型自检(布局/交互/字段/组件拆分/规范合规:无 as any、页面不直接 import api)
- [x] `pnpm build`(vue-tsc)全量零错误
- [ ] 蓝湖到位后:校准 variables.less 色值/字号/间距,替换切图,逐页对照设计稿修正 → 补打 [x]
- [ ] 接口文档到位后:逐 api 文件替换 `// TODO(api)` 为真实路径/入参出参,走真实联调 → 补打 [x]
## 完成度速查
| 单元 | 状态 |
| --- | --- |
| P0 基础工程 | [x] |
| M1 home | [x] |
| M2 cases | [x] |
| M3 demand | [x] |
| M4 login | [x] |
| M5 project | [x] |
| M6 contract | [x] |
| M7 finance | [x] |
| M8 message | [x] |
| M9 service | [x] |
| M10 profile | [x] |
| 阶段六 公共组件提取 | [x] |
| 阶段七 验证修正 | [x] |
| 蓝湖视觉补齐 | [!] |
| 接口联调补齐 | [!] |

View File

@@ -1,206 +0,0 @@
# 页面架构规划 — cz-web(auto-dev 阶段三产出)
> 依据 `docs/PRD.md` 梳理。规范遵循 vue-project-structure skill:三层数据流(页面 → store → api)、四者同名(views/api/store/types)、less 样式、`@/` 别名。
## 1. 页面清单表(模块 → 页面 → 功能点 → 数据字段)
### 模块 home(首页)
| 页面 | 功能点 | 数据字段 |
| --- | --- | --- |
| 首页 `/` | 欢迎区、数据看板、快捷入口、业务服务卡片、案例精选、服务 Tab、页脚 | stats{pending,inProgress,total}、quickLinks[]、businessLines[]、cases[] |
| 业务详情 `/business/:type` | 面包屑、业务介绍、服务内容富文本+视频、服务类型子 Tab | businessLine{id,name,slogan,serviceContent,serviceTypes[],video} |
### 模块 cases(案例库)
| 页面 | 功能点 | 数据字段 |
| --- | --- | --- |
| 案例列表 `/cases` | 四维筛选(业务类型/行业/规模/完成时间)+关键字、已选条件回显、案例卡片网格、分页 | Case(见 PRD 4.1)、CaseQuery{category,industries[],scales[],dateRange,keyword,page} |
| 案例详情 `/cases/:id` | 概览、收藏/分享、视频、富文本四段、一键填报类似案例 | Case 全量、isFavorite |
### 模块 demand(需求提报)
| 页面 | 功能点 | 数据字段 |
| --- | --- | --- |
| 需求提报 `/demand` | 三步表单:业务类型单选→需求信息+附件→确认备注;立即提报/保存草稿/重置 | Demand(见 PRD 4.2) |
### 模块 login(认证)
| 页面 | 功能点 | 数据字段 |
| --- | --- | --- |
| 登录 `/login` | 账号密码/手机验证码双模式、游客模式、协议勾选 | account,password,phone,smsCode,agreeTerms |
| 注册 `/register` | 手机+验证码+密码(强度指示)+确认密码、协议勾选 | phone,smsCode,password,confirmPassword |
| 找回密码 `/reset-password` | 手机+验证码+新密码+确认、确认修改 | phone,smsCode,newPassword,confirmPassword |
### 模块 project(项目中心)
| 页面 | 功能点 | 数据字段 |
| --- | --- | --- |
| 项目列表 `/project` | 三维筛选+关键字、状态 Tab、项目表格、分页 | Project(PRD 4.3)、ProjectQuery{businessType,status,dateRange,keyword,page} |
| 项目详情 `/project/:id` | 信息卡、项目团队、四 Tab(进度里程碑/项目文件/合同财务/售后信息) | Project 全量(milestones,team,files,paymentPlans,invoiceRecords,afterSales) |
| 申请售后 `/project/:id/after-sales` | 三步表单(业务类型/问题描述+附件/备注)、提交生成工单 | AfterSales{type,description,attachments,remark} |
### 模块 contract(合同中心)
| 页面 | 功能点 | 数据字段 |
| --- | --- | --- |
| 合同列表 `/contract` | 关键字搜索、合同表格、去签署/详情、分页 | Contract(PRD 4.4)、ContractQuery{keyword,page} |
| 合同详情 `/contract/:id` | 合同信息只读表单、关联项目跳转、合同文件、返回 | Contract 全量 |
| 签署合同 `/contract/:id/sign` | 支付宝扫码二维码(04:32 倒计时+刷新)、四步签署指引、完成自动跳转 | signQr{url,expiresAt} |
### 模块 finance(财务中心)
| 页面 | 功能点 | 数据字段 |
| --- | --- | --- |
| 财务概览 `/finance` | 累计消费总额+月份/季度/年度切换、业务类型分布图、待支付角标、快捷入口 | financeStats{totalExpense,month,distribution[],pendingCount} |
| 账单明细 `/finance/bills` | 状态 Tab、搜索、账单表格(多选)、汇总条、批量立即支付、分页 | Bill(PRD 4.5)、BillQuery{status,keyword,page} |
| 账单详情 `/finance/bills/:id` | 订单信息卡、待支付/已支付/未到期三态展示、返回 | Bill 全量、Payment |
| 扫码支付 `/finance/bills/:id/pay` | 支付二维码(倒计时+刷新)、微信/支付宝切换弹窗、订单明细、三步指引 | Payment{method,qrCode,expiresAt,amount} |
| 支付结果 `/finance/bills/:id/pay/result` | 支付成功卡、支付信息、已支付订单列表 | Payment 结果 |
| 发票申请 `/finance/invoices` | 默认抬头卡、状态 Tab、发票表格、申请开票/预览/下载/发送邮箱、分页 | Invoice(PRD 4.5) |
| 发票抬头管理 `/finance/invoice-titles` | 抬头列表卡、新增/编辑/删除(二次确认)、设为默认 | InvoiceTitle(PRD 4.5) |
### 模块 message(消息中心)
| 页面 | 功能点 | 数据字段 |
| --- | --- | --- |
| 消息中心 `/message` | 分类 Tab(全部/在线客服/状态通知/系统通知+未读数)、搜索、全部已读、消息列表、分页 | Message(PRD 4.6)、MessageQuery{type,keyword,page} |
| 通知详情 `/message/:id` | 标题/发布时间/富文本正文/附件、返回 | Message 全量 |
### 模块 service(在线客服)
| 页面 | 功能点 | 数据字段 |
| --- | --- | --- |
| 在线客服 `/service` | 聊天窗、历史消息分割线、未读标记、文本/文件消息、Enter 发送、拖拽上传 | ChatMessage{id,role,content,file?,time} |
### 模块 profile(个人中心,左菜单嵌套布局)
| 页面 | 功能点 | 数据字段 |
| --- | --- | --- |
| 账号安全 `/profile/security` | 修改密码、绑定手机换绑、绑定/解绑邮箱、注销账号(条件校验→风险提示→身份验证→二次确认) | User(PRD 4.7) |
| 地址管理 `/profile/address` | 地址卡列表、新增/编辑/删除(二次确认)、设为默认、分页 | Address(PRD 4.7) |
| 收藏列表 `/profile/favorites` | 收藏案例卡片网格 + 筛选、分页 | Case[](复用 cases 实体) |
| 资质列表 `/profile/qualifications` | 资质卡列表、新增/编辑/删除/重新上传、审核状态、分页 | Qualification(PRD 4.7) |
| 意见反馈 `/profile/feedback` | 标题+描述+附件表单、提交反馈/重置 | Feedback{title,description,attachments} |
| 帮助中心 `/profile/help` | FAQ 折叠组(三类 Q/A) | FaqGroup[](静态) |
| 关于我们 `/profile/about` | 简介/使命/核心优势/联系信息/版本信息 | 静态+site 配置 |
| 专属客服热线 `/profile/hotline` | 热线电话+工作时间 | 静态 |
## 2. 路由规划(`src/router/routes.ts`)
```ts
export const routes = [
// ── 公开 ──
{ 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: '/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: '/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 } },
],
},
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue'), meta: { title: '页面不存在' } },
]
```
> 路由守卫(`router/guard.ts`):`meta.requiresAuth` 未登录 → 重定向 `/login`;登录页已登录 → 重定向 `/`;切换路由时设置 `document.title`。
## 3. 四者同名映射表(模块 → 文件)
| 模块 | views/ | api/ | stores/modules/ | types/business/ | Store 名 |
| --- | --- | --- | --- | --- | --- |
| home | `views/home/`(index.vue + detail.vue) | `api/home.ts` | `stores/modules/home.ts` | `types/business/home.d.ts` | useHomeStore |
| cases | `views/cases/`(index.vue + detail.vue) | `api/cases.ts` | `stores/modules/cases.ts` | `types/business/cases.d.ts` | useCasesStore |
| demand | `views/demand/` | `api/demand.ts` | `stores/modules/demand.ts` | `types/business/demand.d.ts` | useDemandStore |
| login | `views/login/`(index.vue + register.vue + reset.vue) | `api/auth.ts` | `stores/modules/user.ts` | `types/business/user.d.ts` | useUserStore |
| project | `views/project/`(index.vue + detail.vue + after-sales.vue) | `api/project.ts` | `stores/modules/project.ts` | `types/business/project.d.ts` | useProjectStore |
| contract | `views/contract/`(index.vue + detail.vue + sign.vue) | `api/contract.ts` | `stores/modules/contract.ts` | `types/business/contract.d.ts` | useContractStore |
| finance | `views/finance/`(index.vue + bills.vue + bill-detail.vue + pay.vue + pay-result.vue + invoices.vue + invoice-titles.vue) | `api/finance.ts` | `stores/modules/finance.ts` | `types/business/finance.d.ts` | useFinanceStore |
| message | `views/message/`(index.vue + detail.vue) | `api/message.ts` | `stores/modules/message.ts` | `types/business/message.d.ts` | useMessageStore |
| service | `views/service/` | `api/service.ts` | `stores/modules/service.ts` | `types/business/service.d.ts` | useServiceStore |
| profile | `views/profile/`(index.vue 布局 + 8 子页) | `api/profile.ts` | `stores/modules/profile.ts` | `types/business/profile.d.ts` | useProfileStore |
| 站点公共 | — | — | `stores/modules/site.ts` | — | useSiteStore(全局 loading/站点配置) |
## 4. 复用性分析
### 4.1 跨页面复用 → `src/components/`
| 组件 | 复用范围 | 判定依据 |
| --- | --- | --- |
| AppHeader / AppFooter | 全站 | layout 挂载 |
| AppButton / AppSwiper | 全站 | 通用基础件 |
| CaseCard | 首页精选、案例库、收藏列表 | ≥3 页面 |
| FilterPanel(含已选条件回显) | 案例库、项目中心、收藏列表 | ≥2 页面 |
| SearchBar | 案例/项目/合同/账单/发票/消息 | ≥2 页面 |
| PaginationBar | 所有列表页 | ≥2 页面 |
| FileList + UploadArea | 需求填报、项目文件、售后、资质、意见反馈 | ≥2 页面 |
| SmsCodeInput | 登录/注册/找回/换绑手机/换绑邮箱/注销验证 | ≥2 页面 |
| PasswordStrength | 注册、修改密码 | ≥2 页面 |
| QrCodePanel | 合同签署、扫码支付 | ≥2 页面 |
| StatusTag | 项目/合同/账单/发票/资质状态 | ≥2 页面 |
| ConfirmDialog | 删除/注销等二次确认 | ≥2 页面 |
| StepForm 骨架 | 需求提报、申请售后 | ≥2 页面 |
### 4.2 页面独有 → `views/<page>/cpns/`
详见 `docs/PRD.md` 第 5.2 节组件清单。判定:仅 1 个页面使用即入 cpns/,严禁跨页面 import。
### 4.3 逻辑复用
| 逻辑 | 归属 | 使用方 |
| --- | --- | --- |
| useCountDown(验证码倒计时/二维码失效倒计时) | composables/ | SmsCodeInput、QrCodePanel |
| useLoading | composables/ | 各 store 请求态(可选) |
| useScroll(吸顶/回到顶部) | composables/ | 长列表页 |
| useIntersection(懒加载) | composables/ | 案例卡片图片 |
| v-lazy / v-debounce / v-throttle | directives/ | 图片懒加载、搜索防抖 |
| format.ts(金额/日期/千分位) | utils/ | 财务/项目展示 |
| validate.ts(手机号/邮箱) | utils/ | 表单校验 |
| storage.ts(token/草稿缓存) | utils/ | user store、需求草稿 |
## 5. 静态资源规划
```
src/assets/
├── images/ # 案例封面、业务卡片图、banner(原型导出切图后语义化命名)
├── icons/ # 业务类型图标、消息类型图标、状态图标
└── fonts/ # 如需自定义字体
```
> 切图来源:蓝湖到位前使用原型占位图;到位后按蓝湖图层语义化命名替换。

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -17,6 +17,7 @@
"axios": "^1.20.0", "axios": "^1.20.0",
"element-plus": "^2.14.5", "element-plus": "^2.14.5",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"qrcode-generator": "^2.0.4",
"vue": "^3.5.42", "vue": "^3.5.42",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },

8
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
pinia: pinia:
specifier: ^4.0.3 specifier: ^4.0.3
version: 4.0.3(@vue/devtools-api@6.6.4)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3)) version: 4.0.3(@vue/devtools-api@6.6.4)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))
qrcode-generator:
specifier: ^2.0.4
version: 2.0.4
vue: vue:
specifier: ^3.5.42 specifier: ^3.5.42
version: 3.5.42(typescript@6.0.3) version: 3.5.42(typescript@6.0.3)
@@ -642,6 +645,9 @@ packages:
prr@1.0.1: prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
qrcode-generator@2.0.4:
resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==}
rolldown@1.2.8: rolldown@1.2.8:
resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1282,6 +1288,8 @@ snapshots:
prr@1.0.1: prr@1.0.1:
optional: true optional: true
qrcode-generator@2.0.4: {}
rolldown@1.2.8: rolldown@1.2.8:
dependencies: dependencies:
'@oxc-project/types': 0.149.0 '@oxc-project/types': 0.149.0

View File

@@ -1,22 +0,0 @@
const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const filePath = process.argv[2]
if (!filePath) {
console.error('Error: 请提供原型文件路径')
process.exit(1)
}
const fullPath = path.resolve(filePath)
if (fs.existsSync(fullPath)) {
console.log('=== START PROTOTYPE CONTENT ===')
console.log(fs.readFileSync(fullPath, 'utf-8'))
console.log('=== END PROTOTYPE CONTENT ===')
}
else {
console.error(`Error: 未找到原型文件 ${fullPath}`)
process.exit(1)
}

View File

@@ -1,44 +1,51 @@
import { request, CLIENT_ID } from '@/utils/request'
import type { LoginResult as AuthLoginResult, RegisterResult } from '@/types/business/auth'
import type { import type {
LoginByAccountParams,
LoginBySmsParams, LoginBySmsParams,
LoginResult, LoginResult,
RegisterParams,
ResetPasswordParams, ResetPasswordParams,
User, User,
} from '@/types/business/user' } from '@/types/business/user'
import { isPhone, isValidPassword } from '@/utils/validate' import { isPhone, isValidPassword } from '@/utils/validate'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变) export const PC_CLIENT_ID = CLIENT_ID
export function loginByAccountApi(data: { username: string; password: string }): Promise<AuthLoginResult> {
return request<AuthLoginResult>({
url: '/auth/login',
method: 'post',
data: {
username: data.username,
password: data.password,
clientId: PC_CLIENT_ID,
grantType: 'password',
},
})
}
export function registerByAccountApi(data: { username: string; password: string }): Promise<RegisterResult> {
return request<RegisterResult>({
url: '/auth/register',
method: 'post',
data: {
username: data.username,
password: data.password,
userType: 'sys_user',
clientId: PC_CLIENT_ID,
grantType: 'password',
},
})
}
// TODO(api): 以下为原 mock 逻辑(保留,待后端对应接口就绪后替换)
const SIX_DIGIT_CODE = /^\d{6}$/ const SIX_DIGIT_CODE = /^\d{6}$/
/** 发送短信验证码 */
export function sendSmsCode(phone: string): Promise<void> { export function sendSmsCode(phone: string): Promise<void> {
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确')) if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
return Promise.resolve() 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> { export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> {
const { phone, code } = params const { phone, code } = params
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确')) if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
@@ -69,20 +76,6 @@ function validateAuthForm(
return null 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> { export function resetPassword(params: ResetPasswordParams): Promise<void> {
const message = validateAuthForm( const message = validateAuthForm(
params.phone, params.phone,

View File

@@ -1,227 +1,51 @@
import type { BusinessType, PageResult } from '@/types/api' //
import type { Case, CaseQuery, ScaleRange } from '@/types/business/cases' import { request, serializeQuery } from '@/utils/request'
import type {
CaseSelectionModel,
ProjectCaseAndCaseCountVo,
ProjectCaseUserBo,
ProjectCaseVo,
} from '@/types/business/cases'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用 export function getCaseList(
bo: ProjectCaseUserBo,
/** 案例 mock(可变,收藏状态在前端内存中翻转演示) */ pageNum: number,
const MOCK_CASES: Case[] = [ pageSize: number,
{ ): Promise<ProjectCaseAndCaseCountVo> {
id: 'case-001', return request<ProjectCaseAndCaseCountVo>({
title: '自动化生产线升级改造项目', url: '/web/projectCase/list',
category: 'manufacturing', method: 'get',
cover: '', params: { ...bo, pageNum, pageSize },
finishDate: '2026/8/10', paramsSerializer: { serialize: serializeQuery },
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 { export function getCaseDetail(id: string): Promise<ProjectCaseVo | null> {
if (!range) return true return request<ProjectCaseVo | null>({
const time = new Date(item.finishDate).getTime() url: `/web/projectCase/${id}`,
const start = new Date(range[0]).getTime() method: 'get',
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 collectCase(id: string): Promise<void> {
export function getCaseList(query: CaseQuery): Promise<PageResult<Case>> { return request<void>({
const filtered = applyFilters(query) url: '/web/projectCase/collect',
const start = (query.page - 1) * query.pageSize method: 'post',
return Promise.resolve({ data: { id },
list: filtered.slice(start, start + query.pageSize),
total: filtered.length,
}) })
} }
/** 案例详情(不存在返回 null) */ export function getCaseCategories(): Promise<CaseSelectionModel> {
export function getCaseDetail(id: string): Promise<Case | null> { return request<CaseSelectionModel>({
const found = MOCK_CASES.find((item) => item.id === id) ?? null url: '/web/projectCase/categories',
return Promise.resolve(found) method: 'get',
})
} }
/** 收藏/取消收藏,返回最新案例数据 */ export function getFeaturedCases(type?: string): Promise<ProjectCaseVo[]> {
export function toggleFavorite(id: string): Promise<Case> { return request<ProjectCaseVo[]>({
const found = MOCK_CASES.find((item) => item.id === id) url: '/web/projectCase/mostViewed',
if (!found) return Promise.reject(new Error('案例不存在')) method: 'get',
found.isFavorite = !found.isFavorite params: type ? { type } : undefined,
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)
} }

View File

@@ -3,15 +3,12 @@ import type { Contract, ContractQuery } from '@/types/business/contract'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用 // TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
/** 构造 mock 文件信息 */
function mockFile(name: string, size: number, uploadTime: string): FileInfo { function mockFile(name: string, size: number, uploadTime: string): FileInfo {
return { name, size, uploadTime, url: '' } return { name, size, uploadTime, url: '' }
} }
/** 详情 mock 所属合同 id */
const DETAIL_ID = 'contract-001' const DETAIL_ID = 'contract-001'
/** 合同列表 mock(3 条,覆盖全部签署状态) */
const MOCK_CONTRACTS: Contract[] = [ const MOCK_CONTRACTS: Contract[] = [
{ {
id: 'contract-001', id: 'contract-001',
@@ -56,7 +53,6 @@ const MOCK_CONTRACTS: Contract[] = [
}, },
] ]
/** 合同详情 mock */
const CONTRACT_DETAIL: Contract = { const CONTRACT_DETAIL: Contract = {
id: DETAIL_ID, id: DETAIL_ID,
contractNo: 'C8K2M202408001', contractNo: 'C8K2M202408001',
@@ -72,7 +68,6 @@ const CONTRACT_DETAIL: Contract = {
files: [mockFile('项目需求规格说明书.pdf', 2516582, '2024-10-15')], files: [mockFile('项目需求规格说明书.pdf', 2516582, '2024-10-15')],
} }
/** 合同列表(keyword 筛选 + 分页) */
export function getContractList(query: ContractQuery): Promise<PageResult<Contract>> { export function getContractList(query: ContractQuery): Promise<PageResult<Contract>> {
const filtered = MOCK_CONTRACTS.filter((item) => { const filtered = MOCK_CONTRACTS.filter((item) => {
if ( if (
@@ -92,19 +87,16 @@ export function getContractList(query: ContractQuery): Promise<PageResult<Contra
}) })
} }
/** 合同详情(不存在返回 null) */
export function getContractDetail(id: string): Promise<Contract | null> { export function getContractDetail(id: string): Promise<Contract | null> {
if (id === DETAIL_ID) return Promise.resolve({ ...CONTRACT_DETAIL, files: [...CONTRACT_DETAIL.files] }) if (id === DETAIL_ID) return Promise.resolve({ ...CONTRACT_DETAIL, files: [...CONTRACT_DETAIL.files] })
const found = MOCK_CONTRACTS.find((item) => item.id === id) ?? null const found = MOCK_CONTRACTS.find((item) => item.id === id) ?? null
return Promise.resolve(found ? { ...found, files: [...found.files] } : null) return Promise.resolve(found ? { ...found, files: [...found.files] } : null)
} }
/** 获取签署二维码(返回有效期) */
export function getSignQr(_id: string): Promise<{ expiresInSeconds: number }> { export function getSignQr(_id: string): Promise<{ expiresInSeconds: number }> {
return Promise.resolve({ expiresInSeconds: 272 }) return Promise.resolve({ expiresInSeconds: 272 })
} }
/** 刷新签署二维码(返回有效期) */
export function refreshSignQr(_id: string): Promise<{ expiresInSeconds: number }> { export function refreshSignQr(_id: string): Promise<{ expiresInSeconds: number }> {
return Promise.resolve({ expiresInSeconds: 272 }) return Promise.resolve({ expiresInSeconds: 272 })
} }

View File

@@ -1,25 +1,101 @@
import type { Demand } from '@/types/business/demand' //
import { storage } from '@/utils/storage' import service, { request } from '@/utils/request'
import type { ApiResponse } from '@/types/api'
import type {
Demand,
DemandDraftVo,
DictOption,
OssUploadVo,
OssUrlVo,
SubmissionBizTypeVo,
} from '@/types/business/demand'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变) export const BIZ_TYPE_DICT = 'vm_biz_type'
export const BUDGET_RANGE_DICT = 'vm_budget_range'
const DRAFT_KEY = 'demand-draft' export function getSubmissionBizTypes(): Promise<SubmissionBizTypeVo[]> {
return request<SubmissionBizTypeVo[]>({
url: '/web/projectSubmission/list',
method: 'get',
})
}
/** 提交需求(mock:返回提报单 id) */ export function getOssUrls(ids: (string | number)[]): Promise<OssUrlVo[]> {
export function submitDemand(demand: Demand): Promise<{ id: string }> { return request<OssUrlVo[]>({
if (!demand.businessType || !demand.projectName || !demand.coreDescription) { url: `/web/resource/oss/listByIds/${ids.join(',')}`,
return Promise.reject(new Error('需求信息不完整,无法提交')) method: 'get',
})
}
export function getBizTypeDict(): Promise<DictOption[]> {
return request<DictOption[]>({
url: `/web/system/dict/data/type/${BIZ_TYPE_DICT}`,
method: 'get',
})
}
export function getBudgetRangeDict(): Promise<DictOption[]> {
return request<DictOption[]>({
url: `/web/system/dict/data/type/${BUDGET_RANGE_DICT}`,
method: 'get',
})
}
export async function getDraftDemand(): Promise<DemandDraftVo | null> {
const resp = await service.request<ApiResponse<DemandDraftVo>>({
url: '/web/workOrderDraft/getDraft',
method: 'get',
})
if (resp.data?.code !== 200) return null
return resp.data.data ?? null
}
function toBoPayload(demand: Demand): Record<string, unknown> {
return {
id: demand.id,
userName: demand.userName,
userPhone: demand.userPhone,
bizType: demand.bizType,
budgetRange: demand.budgetRange,
developmentCycle:
demand.developmentCycle === '' ? null : demand.developmentCycle,
supplementaryRemarks: demand.supplementaryRemarks,
projectName: demand.projectName,
requirementDesc: demand.requirementDesc,
attachmentInfo: demand.attachmentInfo,
} }
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> { export function saveDraft(demand: Demand): Promise<void> {
storage.sessionSet(DRAFT_KEY, { ...demand, status: 'draft' }) return request<void>({
return Promise.resolve() url: '/web/workOrderDraft/add',
method: 'post',
data: toBoPayload(demand),
})
}
export function resetDraft(): Promise<void> {
return request<void>({
url: '/web/workOrderDraft/reset',
method: 'post',
})
}
export function uploadDemandFile(file: File): Promise<OssUploadVo> {
const data = new FormData()
data.append('file', file)
return request<OssUploadVo>({
url: '/web/workOrderDraft/upload',
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export function submitDemand(demand: Demand): Promise<void> {
return request<void>({
url: '/web/requirementPool/create',
method: 'post',
data: toBoPayload(demand),
})
} }

View File

@@ -2,19 +2,21 @@ import type { PageResult } from '@/types/api'
import type { import type {
Bill, Bill,
BillQuery, BillQuery,
BillVo,
FinanceStats, FinanceStats,
Invoice, Invoice,
InvoiceQuery, InvoiceQuery,
InvoiceTitle, InvoiceTitle,
InvoiceTitlePayload, InvoiceTitlePayload,
PayMethod, PayMethod,
PayOrderResponse,
Payment, Payment,
PaymentResult, PaymentResult,
} from '@/types/business/finance' } from '@/types/business/finance'
import { toBills, toPlanBills, type PlanLike } from '@/utils/billVo'
import { toPayMethod } from '@/utils/payMeta'
import { request } from '@/utils/request'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
/** 账单 mock(可变:支付后状态在前端内存中翻转演示) */
const MOCK_BILLS: Bill[] = [ const MOCK_BILLS: Bill[] = [
{ {
id: 'bill-001', id: 'bill-001',
@@ -74,7 +76,6 @@ const MOCK_BILLS: Bill[] = [
}, },
] ]
/** 发票 mock(可变:申请开票后状态在前端内存中翻转演示) */
const MOCK_INVOICES: Invoice[] = [ const MOCK_INVOICES: Invoice[] = [
{ {
id: 'invoice-001', id: 'invoice-001',
@@ -107,7 +108,6 @@ const MOCK_INVOICES: Invoice[] = [
}, },
] ]
/** 发票抬头 mock(可变,CRUD 在前端内存中演示) */
const MOCK_TITLES: InvoiceTitle[] = [ const MOCK_TITLES: InvoiceTitle[] = [
{ {
id: 'title-001', id: 'title-001',
@@ -125,10 +125,8 @@ const MOCK_TITLES: InvoiceTitle[] = [
}, },
] ]
/** 支付进行单(billId → 当前二维码) */
const MOCK_PAYMENTS = new Map<string, Payment>() const MOCK_PAYMENTS = new Map<string, Payment>()
/** 已产生支付结果的账单(billId → 结果),预置已支付账单 */
const MOCK_RESULTS = new Map<string, PaymentResult>() const MOCK_RESULTS = new Map<string, PaymentResult>()
MOCK_RESULTS.set('bill-001', { MOCK_RESULTS.set('bill-001', {
method: 'wechat', method: 'wechat',
@@ -157,12 +155,20 @@ const DEFAULT_STATS: FinanceStats = {
], ],
} }
/** 财务统计(period 为展示周期参数,mock 恒返回原型数据) */
export function getFinanceStats(period?: string): Promise<FinanceStats> { export function getFinanceStats(period?: string): Promise<FinanceStats> {
void period void period
return Promise.resolve(structuredClone(DEFAULT_STATS)) return Promise.resolve(structuredClone(DEFAULT_STATS))
} }
function isRealBillId(id: string): boolean {
return /^\d+$/.test(id)
}
async function fetchOwnBills(): Promise<Bill[]> {
const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get' })
return toBills(rows)
}
function matchBillKeyword(item: Bill, keyword: string): boolean { function matchBillKeyword(item: Bill, keyword: string): boolean {
if (!keyword) return true if (!keyword) return true
return ( return (
@@ -173,26 +179,25 @@ function matchBillKeyword(item: Bill, keyword: string): boolean {
) )
} }
/** 账单列表(筛选 + 分页) */ export async function getBillList(query: BillQuery): Promise<PageResult<Bill>> {
export function getBillList(query: BillQuery): Promise<PageResult<Bill>> { const all = await fetchOwnBills()
const filtered = MOCK_BILLS.filter((item) => { const filtered = all.filter((item) => {
if (query.status !== 'all' && item.status !== query.status) return false if (query.status !== 'all' && item.status !== query.status) return false
return matchBillKeyword(item, query.keyword) return matchBillKeyword(item, query.keyword)
}) })
const start = (query.page - 1) * query.pageSize const start = (query.page - 1) * query.pageSize
return Promise.resolve({ return { list: filtered.slice(start, start + query.pageSize), total: filtered.length }
list: filtered.slice(start, start + query.pageSize).map((item) => ({ ...item })),
total: filtered.length,
})
} }
/** 账单详情(不存在返回 null) */ export async function getBillDetail(id: string): Promise<Bill | null> {
export function getBillDetail(id: string): Promise<Bill | null> { const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get', params: { ids: id } })
const found = MOCK_BILLS.find((item) => item.id === id) ?? null return toBills(rows)[0] ?? null
return Promise.resolve(found ? { ...found } : null)
} }
/** 创建支付单(生成二维码支付信息) */ // ═══════════════════════════════════════════════════════════════════════
// ---------------------------------------------------------------------
// ═══════════════════════════════════════════════════════════════════════
export function createPayment(billId: string, method: PayMethod): Promise<Payment> { export function createPayment(billId: string, method: PayMethod): Promise<Payment> {
const bill = MOCK_BILLS.find((item) => item.id === billId) const bill = MOCK_BILLS.find((item) => item.id === billId)
if (!bill) return Promise.reject(new Error('账单不存在')) if (!bill) return Promise.reject(new Error('账单不存在'))
@@ -206,13 +211,26 @@ export function createPayment(billId: string, method: PayMethod): Promise<Paymen
return Promise.resolve({ ...payment }) return Promise.resolve({ ...payment })
} }
/** 切换支付方式(刷新二维码) */
export function switchPaymentMethod(billId: string, method: PayMethod): Promise<Payment> { export function switchPaymentMethod(billId: string, method: PayMethod): Promise<Payment> {
return createPayment(billId, method) return createPayment(billId, method)
} }
/** 查询支付结果(无支付记录且未支付返回 null;演示数据 payTime/tradeNo 固定) */ export async function getPayResult(billId: string): Promise<PaymentResult | null> {
export function getPayResult(billId: string): Promise<PaymentResult | null> { if (isRealBillId(billId)) {
const bill = await getBillDetail(billId)
if (!bill || bill.status !== '已支付') return null
return {
method: toPayMethod(bill.payChannel),
amount: bill.amount,
payTime: bill.payTime ?? '—',
tradeNo: bill.tradeNo ?? '—',
bills: [bill],
}
}
return getLegacyPayResult(billId)
}
function getLegacyPayResult(billId: string): Promise<PaymentResult | null> {
const existing = MOCK_RESULTS.get(billId) const existing = MOCK_RESULTS.get(billId)
if (existing) return Promise.resolve(structuredClone(existing)) if (existing) return Promise.resolve(structuredClone(existing))
@@ -220,7 +238,6 @@ export function getPayResult(billId: string): Promise<PaymentResult | null> {
const bill = MOCK_BILLS.find((item) => item.id === billId) const bill = MOCK_BILLS.find((item) => item.id === billId)
if (!payment || !bill) return Promise.resolve(null) if (!payment || !bill) return Promise.resolve(null)
// 演示:存在支付单即视为完成支付(createPayment 由「完成支付」触发)
const tradeNo = bill.tradeNo ?? 'T20260819153035678K2M001' const tradeNo = bill.tradeNo ?? 'T20260819153035678K2M001'
if (bill.status !== '已支付') { if (bill.status !== '已支付') {
bill.status = '已支付' bill.status = '已支付'
@@ -247,7 +264,6 @@ function matchInvoiceKeyword(item: Invoice, keyword: string): boolean {
) )
} }
/** 发票列表(筛选 + 分页) */
export function getInvoiceList(query: InvoiceQuery): Promise<PageResult<Invoice>> { export function getInvoiceList(query: InvoiceQuery): Promise<PageResult<Invoice>> {
const filtered = MOCK_INVOICES.filter((item) => { const filtered = MOCK_INVOICES.filter((item) => {
if (query.status !== 'all' && item.status !== query.status) return false if (query.status !== 'all' && item.status !== query.status) return false
@@ -260,7 +276,6 @@ export function getInvoiceList(query: InvoiceQuery): Promise<PageResult<Invoice>
}) })
} }
/** 申请开票(演示:状态置为开票中),返回最新发票 */
export function applyInvoice(id: string): Promise<Invoice> { export function applyInvoice(id: string): Promise<Invoice> {
const found = MOCK_INVOICES.find((item) => item.id === id) const found = MOCK_INVOICES.find((item) => item.id === id)
if (!found) return Promise.reject(new Error('发票记录不存在')) if (!found) return Promise.reject(new Error('发票记录不存在'))
@@ -268,12 +283,10 @@ export function applyInvoice(id: string): Promise<Invoice> {
return Promise.resolve({ ...found }) return Promise.resolve({ ...found })
} }
/** 发票抬头列表 */
export function getInvoiceTitles(): Promise<InvoiceTitle[]> { export function getInvoiceTitles(): Promise<InvoiceTitle[]> {
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item }))) return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
} }
/** 新增发票抬头 */
export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<InvoiceTitle> { export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
const created: InvoiceTitle = { const created: InvoiceTitle = {
id: `title-${Date.now()}`, id: `title-${Date.now()}`,
@@ -284,7 +297,6 @@ export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<Invoic
return Promise.resolve({ ...created }) return Promise.resolve({ ...created })
} }
/** 编辑发票抬头 */
export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Promise<InvoiceTitle> { export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
const found = MOCK_TITLES.find((item) => item.id === id) const found = MOCK_TITLES.find((item) => item.id === id)
if (!found) return Promise.reject(new Error('抬头不存在')) if (!found) return Promise.reject(new Error('抬头不存在'))
@@ -294,7 +306,6 @@ export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Pr
return Promise.resolve({ ...found }) return Promise.resolve({ ...found })
} }
/** 删除发票抬头 */
export function deleteInvoiceTitle(id: string): Promise<void> { export function deleteInvoiceTitle(id: string): Promise<void> {
const index = MOCK_TITLES.findIndex((item) => item.id === id) const index = MOCK_TITLES.findIndex((item) => item.id === id)
if (index < 0) return Promise.reject(new Error('抬头不存在')) if (index < 0) return Promise.reject(new Error('抬头不存在'))
@@ -302,7 +313,6 @@ export function deleteInvoiceTitle(id: string): Promise<void> {
return Promise.resolve() return Promise.resolve()
} }
/** 设为默认抬头(唯一默认) */
export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> { export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> {
for (const item of MOCK_TITLES) { for (const item of MOCK_TITLES) {
item.isDefault = item.id === id item.isDefault = item.id === id
@@ -310,7 +320,58 @@ export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> {
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item }))) return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
} }
/** 导出报表(演示:稍后由页面提示成功) */
export function exportReport(): Promise<void> { export function exportReport(): Promise<void> {
return Promise.resolve() return Promise.resolve()
} }
// ═══════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════
export async function getCheckoutBills(requirementId: string, ids: string[] = []): Promise<Bill[]> {
if (!/^\d+$/.test(requirementId)) return []
const vo = await request<{ billPlans?: PlanLike[] }>({
url: `/web/requirementPool/get/finance/${requirementId}`,
method: 'get',
})
const all = toPlanBills(vo?.billPlans)
return ids.length > 0
? all.filter((bill) => ids.includes(bill.id))
: all.filter((bill) => bill.status === '待支付')
}
export async function getPlanPayResult(requirementId: string, planId: string): Promise<PaymentResult | null> {
const bills = await getCheckoutBills(requirementId, [planId])
const bill = bills[0]
if (!bill || bill.status !== '已支付') return null
return {
method: toPayMethod(bill.payChannel),
amount: bill.amount,
payTime: bill.payTime ?? '—',
tradeNo: bill.tradeNo ?? '—',
bills: [bill],
}
}
export function createWechatNativeOrder(
orderNo: string,
amount: number,
billIds: string[] = [],
): Promise<PayOrderResponse> {
return request<PayOrderResponse>({
url: '/pay/web/wechat/native',
method: 'post',
data: { orderNo, amount, billIds },
})
}
export function createAlipayPageOrder(
outTradeNo: string,
amount: number,
billIds: string[] = [],
): Promise<PayOrderResponse> {
return request<PayOrderResponse>({
url: '/pay/web/alipay/page',
method: 'post',
data: { outTradeNo, amount, billIds },
})
}

View File

@@ -1,180 +1,73 @@
import type { BusinessType } from '@/types/api' //
import type { Case } from '@/types/business/cases' import { request } from '@/utils/request'
import type { BusinessLine, HomeStats, ServiceType } from '@/types/business/home' import type {
AgreementFormVo,
BannerVo,
PortalEntryGroup,
PortalEntryVo,
ProjectStatusCount,
ProjectSubmissionInfo,
ProjectSubmissionVo,
} from '@/types/business/home'
// TODO(api): 待接口文档到位后,将以下 mock 返回替换为 request<T> 调用(页面 → store → api 数据流不变) export function getProjectStatusCount(): Promise<ProjectStatusCount> {
return request<ProjectStatusCount>({
const SERVICE_TYPES: ServiceType[] = [ url: '/web/home-page/project-status-count',
{ label: 'Web应用', desc: '响应式 Web 与后台管理系统定制开发' }, method: 'get',
{ 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 getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
export function getBusinessLines(): Promise<BusinessLine[]> { return request<ProjectSubmissionVo[]>({
return Promise.resolve(BUSINESS_LINES) url: '/web/home-page/service-type-list',
method: 'get',
})
} }
/** 业务详情(业务详情页) */ export function getServiceTypeDetail(
export function getBusinessDetail(type: BusinessType): Promise<BusinessLine> { id: number | string,
const found = BUSINESS_LINES.find((line) => line.id === type) ): Promise<ProjectSubmissionInfo> {
return Promise.resolve(found ?? BUSINESS_LINES[0]!) return request<ProjectSubmissionInfo>({
url: `/web/home-page/service-type-detail/${id}`,
method: 'get',
})
} }
/** 首页案例精选(category=all 时返回全部) */ export function getCarouselList(): Promise<BannerVo[]> {
export function getFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<Case[]> { return request<BannerVo[]>({
const list = category === 'all' url: '/web/home-page/carousel-list',
? FEATURED_CASES method: 'get',
: FEATURED_CASES.filter((item) => item.category === category) })
return Promise.resolve(list) }
export function getPlatformIntro(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/platform-intro',
method: 'get',
})
}
export function getContactInfo(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/contact-info',
method: 'get',
})
}
export function getPortalEntryList(): Promise<PortalEntryVo[]> {
return request<PortalEntryVo[]>({
url: '/web/vermins/portalEntryUser/list',
method: 'get',
})
}
export function switchPortalEntryGroup(
id: number,
type: PortalEntryGroup,
): Promise<void> {
return request<void>({
url: '/web/vermins/portalEntryUser/add',
method: 'post',
params: { id, type },
})
} }

View File

@@ -3,7 +3,6 @@ import type { Message, MessageQuery } from '@/types/business/message'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用 // TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
/** 消息 mock(可变,已读状态在前端内存中翻转演示) */
const MOCK_MESSAGES: Message[] = [ const MOCK_MESSAGES: Message[] = [
{ {
id: 'msg-001', id: 'msg-001',
@@ -107,7 +106,6 @@ function applyFilters(query: MessageQuery): Message[] {
}) })
} }
/** 消息列表(筛选 + 分页) */
export function getMessageList(query: MessageQuery): Promise<PageResult<Message>> { export function getMessageList(query: MessageQuery): Promise<PageResult<Message>> {
const filtered = applyFilters(query) const filtered = applyFilters(query)
const start = (query.page - 1) * query.pageSize const start = (query.page - 1) * query.pageSize
@@ -117,13 +115,11 @@ export function getMessageList(query: MessageQuery): Promise<PageResult<Message>
}) })
} }
/** 消息详情(不存在返回 null) */
export function getMessageDetail(id: string): Promise<Message | null> { export function getMessageDetail(id: string): Promise<Message | null> {
const found = MOCK_MESSAGES.find((item) => item.id === id) ?? null const found = MOCK_MESSAGES.find((item) => item.id === id) ?? null
return Promise.resolve(found ? { ...found } : null) return Promise.resolve(found ? { ...found } : null)
} }
/** 全部标记已读 */
export function markAllRead(): Promise<void> { export function markAllRead(): Promise<void> {
for (const item of MOCK_MESSAGES) { for (const item of MOCK_MESSAGES) {
item.isRead = true item.isRead = true
@@ -131,14 +127,12 @@ export function markAllRead(): Promise<void> {
return Promise.resolve() return Promise.resolve()
} }
/** 单条标记已读 */
export function markRead(id: string): Promise<void> { export function markRead(id: string): Promise<void> {
const found = MOCK_MESSAGES.find((item) => item.id === id) const found = MOCK_MESSAGES.find((item) => item.id === id)
if (found) found.isRead = true if (found) found.isRead = true
return Promise.resolve() return Promise.resolve()
} }
/** 各分类消息总数(tab 角标) */
export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状态通知: number; 系统通知: number }> { export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状态通知: number; 系统通知: number }> {
const counts = { all: MOCK_MESSAGES.length, 在线客服: 0, 状态通知: 0, 系统通知: 0 } const counts = { all: MOCK_MESSAGES.length, 在线客服: 0, 状态通知: 0, 系统通知: 0 }
for (const item of MOCK_MESSAGES) { for (const item of MOCK_MESSAGES) {
@@ -147,7 +141,6 @@ export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状
return Promise.resolve(counts) return Promise.resolve(counts)
} }
/** 未读消息总数(头部角标) */
export function getUnreadCount(): Promise<number> { export function getUnreadCount(): Promise<number> {
return Promise.resolve(MOCK_MESSAGES.filter((item) => !item.isRead).length) return Promise.resolve(MOCK_MESSAGES.filter((item) => !item.isRead).length)
} }

8
src/api/oss.ts Normal file
View File

@@ -0,0 +1,8 @@
import service from '@/utils/request'
export async function fetchOssPreviewBlob(ossId: string | number): Promise<Blob> {
const resp = await service.get(`/web/workOrderDraft/preview/${ossId}`, {
responseType: 'blob',
})
return resp.data as Blob
}

View File

@@ -14,7 +14,6 @@ import type {
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变) // TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
/** 账号资料 mock(可变,昵称/绑定等操作在前端内存中演示) */
const MOCK_PROFILE: Profile = { const MOCK_PROFILE: Profile = {
nickname: '王永志', nickname: '王永志',
phone: '186****618', phone: '186****618',
@@ -22,57 +21,48 @@ const MOCK_PROFILE: Profile = {
passwordModifiedAt: '15天前', passwordModifiedAt: '15天前',
} }
/** 获取账号资料 */
export function getProfile(): Promise<Profile> { export function getProfile(): Promise<Profile> {
return Promise.resolve({ ...MOCK_PROFILE }) return Promise.resolve({ ...MOCK_PROFILE })
} }
/** 修改昵称 */
export function updateNickname(name: string): Promise<Profile> { export function updateNickname(name: string): Promise<Profile> {
MOCK_PROFILE.nickname = name MOCK_PROFILE.nickname = name
return Promise.resolve({ ...MOCK_PROFILE }) return Promise.resolve({ ...MOCK_PROFILE })
} }
/** 修改密码(验证码 + 新密码) */
export function changePassword(params: ChangePasswordParams): Promise<void> { export function changePassword(params: ChangePasswordParams): Promise<void> {
MOCK_PROFILE.passwordModifiedAt = '刚刚' MOCK_PROFILE.passwordModifiedAt = '刚刚'
void params void params
return Promise.resolve() return Promise.resolve()
} }
/** 绑定/换绑手机号 */
export function bindPhone(phone: string, code: string): Promise<Profile> { export function bindPhone(phone: string, code: string): Promise<Profile> {
void code void code
MOCK_PROFILE.phone = phone MOCK_PROFILE.phone = phone
return Promise.resolve({ ...MOCK_PROFILE }) return Promise.resolve({ ...MOCK_PROFILE })
} }
/** 绑定/换绑邮箱 */
export function bindEmail(email: string, code: string): Promise<Profile> { export function bindEmail(email: string, code: string): Promise<Profile> {
void code void code
MOCK_PROFILE.email = email MOCK_PROFILE.email = email
return Promise.resolve({ ...MOCK_PROFILE }) return Promise.resolve({ ...MOCK_PROFILE })
} }
/** 取消绑定邮箱 */
export function unbindEmail(): Promise<Profile> { export function unbindEmail(): Promise<Profile> {
MOCK_PROFILE.email = '' MOCK_PROFILE.email = ''
return Promise.resolve({ ...MOCK_PROFILE }) return Promise.resolve({ ...MOCK_PROFILE })
} }
/** 注销前安全身份验证 */
export function verifyIdentity(phone: string, code: string): Promise<void> { export function verifyIdentity(phone: string, code: string): Promise<void> {
void phone void phone
void code void code
return Promise.resolve() return Promise.resolve()
} }
/** 注销账号 */
export function deleteAccount(): Promise<void> { export function deleteAccount(): Promise<void> {
return Promise.resolve() return Promise.resolve()
} }
/** 收货地址 mock(isDefault 混合) */
const MOCK_ADDRESSES: Address[] = [ const MOCK_ADDRESSES: Address[] = [
{ {
id: 'addr-1', id: 'addr-1',
@@ -116,12 +106,10 @@ function paginate<T>(list: T[], query: ListQuery): PageResult<T> {
} }
} }
/** 收货地址列表(分页) */
export function getAddresses(query: ListQuery): Promise<PageResult<Address>> { export function getAddresses(query: ListQuery): Promise<PageResult<Address>> {
return Promise.resolve(paginate(MOCK_ADDRESSES, query)) return Promise.resolve(paginate(MOCK_ADDRESSES, query))
} }
/** 新增收货地址 */
export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> { export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
const created: Address = { const created: Address = {
...input, ...input,
@@ -132,7 +120,6 @@ export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise
return Promise.resolve({ ...created }) return Promise.resolve({ ...created })
} }
/** 编辑收货地址 */
export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> { export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
const found = MOCK_ADDRESSES.find((item) => item.id === id) const found = MOCK_ADDRESSES.find((item) => item.id === id)
if (!found) return Promise.reject(new Error('地址不存在')) if (!found) return Promise.reject(new Error('地址不存在'))
@@ -140,14 +127,12 @@ export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault
return Promise.resolve({ ...found }) return Promise.resolve({ ...found })
} }
/** 删除收货地址 */
export function deleteAddress(id: string): Promise<void> { export function deleteAddress(id: string): Promise<void> {
const index = MOCK_ADDRESSES.findIndex((item) => item.id === id) const index = MOCK_ADDRESSES.findIndex((item) => item.id === id)
if (index >= 0) MOCK_ADDRESSES.splice(index, 1) if (index >= 0) MOCK_ADDRESSES.splice(index, 1)
return Promise.resolve() return Promise.resolve()
} }
/** 设为默认地址 */
export function setDefaultAddress(id: string): Promise<Address[]> { export function setDefaultAddress(id: string): Promise<Address[]> {
for (const item of MOCK_ADDRESSES) { for (const item of MOCK_ADDRESSES) {
item.isDefault = item.id === id item.isDefault = item.id === id
@@ -155,7 +140,6 @@ export function setDefaultAddress(id: string): Promise<Address[]> {
return Promise.resolve(MOCK_ADDRESSES.map((item) => ({ ...item }))) return Promise.resolve(MOCK_ADDRESSES.map((item) => ({ ...item })))
} }
/** 我的收藏 mock(4 条已收藏案例,字段与案例库原型一致) */
const MOCK_FAVORITES: Case[] = [ const MOCK_FAVORITES: Case[] = [
{ {
id: 'fav-1', id: 'fav-1',
@@ -168,12 +152,15 @@ const MOCK_FAVORITES: Case[] = [
industry: '自动化', industry: '自动化',
duration: '150个工作日', duration: '150个工作日',
clientType: '企业', clientType: '企业',
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。', sections: [
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。', { title: '项目背景', content: '<p>传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。</p>' },
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。', { title: '客户痛点', content: '<p>1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。</p>' },
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。', { title: '需求描述', content: '<p>建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。</p>' },
{ title: '交付物', content: '<p>1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。</p>' },
],
video: '', video: '',
isFavorite: true, isFavorite: true,
favoriteCount: 0,
}, },
{ {
id: 'fav-2', id: 'fav-2',
@@ -186,12 +173,15 @@ const MOCK_FAVORITES: Case[] = [
industry: '工厂服务', industry: '工厂服务',
duration: '200个工作日', duration: '200个工作日',
clientType: '企业', clientType: '企业',
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。', sections: [
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。', { title: '项目背景', content: '<p>园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。</p>' },
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。', { title: '客户痛点', content: '<p>1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。</p>' },
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。', { title: '需求描述', content: '<p>构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。</p>' },
{ title: '交付物', content: '<p>1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。</p>' },
],
video: '', video: '',
isFavorite: true, isFavorite: true,
favoriteCount: 0,
}, },
{ {
id: 'fav-3', id: 'fav-3',
@@ -204,12 +194,15 @@ const MOCK_FAVORITES: Case[] = [
industry: '智能设备', industry: '智能设备',
duration: '30个工作日', duration: '30个工作日',
clientType: '企业', clientType: '企业',
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。', sections: [
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。', { title: '项目背景', content: '<p>客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。</p>' },
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。', { title: '客户痛点', content: '<p>1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。</p>' },
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。', { title: '需求描述', content: '<p>完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。</p>' },
{ title: '交付物', content: '<p>1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。</p>' },
],
video: '', video: '',
isFavorite: true, isFavorite: true,
favoriteCount: 0,
}, },
{ {
id: 'fav-4', id: 'fav-4',
@@ -222,16 +215,18 @@ const MOCK_FAVORITES: Case[] = [
industry: '园区服务', industry: '园区服务',
duration: '200个工作日', duration: '200个工作日',
clientType: '企业', clientType: '企业',
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。', sections: [
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。', { title: '项目背景', content: '<p>传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。</p>' },
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。', { title: '客户痛点', content: '<p>1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。</p>' },
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。', { title: '需求描述', content: '<p>打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。</p>' },
{ title: '交付物', content: '<p>1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。</p>' },
],
video: '', video: '',
isFavorite: true, isFavorite: true,
favoriteCount: 0,
}, },
] ]
/** 我的收藏(关键字/分类筛选 + 分页) */
export function getFavorites(query: FavoritesQuery): Promise<PageResult<Case>> { export function getFavorites(query: FavoritesQuery): Promise<PageResult<Case>> {
const filtered = MOCK_FAVORITES.filter((item) => { const filtered = MOCK_FAVORITES.filter((item) => {
if (query.category !== 'all' && item.category !== query.category) return false if (query.category !== 'all' && item.category !== query.category) return false
@@ -245,7 +240,6 @@ export function getFavorites(query: FavoritesQuery): Promise<PageResult<Case>> {
}) })
} }
/** 收藏分类计数(分类快捷按钮角标) */
export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> { export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> {
const counts: Partial<Record<BusinessType, number>> = { const counts: Partial<Record<BusinessType, number>> = {
internet: 1, internet: 1,
@@ -256,7 +250,6 @@ export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>>
return Promise.resolve(counts) return Promise.resolve(counts)
} }
/** 资质 mock(状态混合:已认证/审核中/未通过) */
const MOCK_QUALIFICATIONS: Qualification[] = [ const MOCK_QUALIFICATIONS: Qualification[] = [
{ {
id: 'qual-1', id: 'qual-1',
@@ -312,12 +305,10 @@ const MOCK_QUALIFICATIONS: Qualification[] = [
}, },
] ]
/** 资质列表(分页) */
export function getQualifications(query: ListQuery): Promise<PageResult<Qualification>> { export function getQualifications(query: ListQuery): Promise<PageResult<Qualification>> {
return Promise.resolve(paginate(MOCK_QUALIFICATIONS, query)) return Promise.resolve(paginate(MOCK_QUALIFICATIONS, query))
} }
/** 新增资质(提交后进入审核中) */
export function createQualification(input: QualificationInput): Promise<Qualification> { export function createQualification(input: QualificationInput): Promise<Qualification> {
const created: Qualification = { const created: Qualification = {
...input, ...input,
@@ -328,7 +319,6 @@ export function createQualification(input: QualificationInput): Promise<Qualific
return Promise.resolve({ ...created }) return Promise.resolve({ ...created })
} }
/** 编辑资质(未通过重新提交后回到审核中) */
export function updateQualification(id: string, input: QualificationInput): Promise<Qualification> { export function updateQualification(id: string, input: QualificationInput): Promise<Qualification> {
const found = MOCK_QUALIFICATIONS.find((item) => item.id === id) const found = MOCK_QUALIFICATIONS.find((item) => item.id === id)
if (!found) return Promise.reject(new Error('资质不存在')) if (!found) return Promise.reject(new Error('资质不存在'))
@@ -343,74 +333,72 @@ export function updateQualification(id: string, input: QualificationInput): Prom
return Promise.resolve({ ...found }) return Promise.resolve({ ...found })
} }
/** 删除资质 */
export function deleteQualification(id: string): Promise<void> { export function deleteQualification(id: string): Promise<void> {
const index = MOCK_QUALIFICATIONS.findIndex((item) => item.id === id) const index = MOCK_QUALIFICATIONS.findIndex((item) => item.id === id)
if (index >= 0) MOCK_QUALIFICATIONS.splice(index, 1) if (index >= 0) MOCK_QUALIFICATIONS.splice(index, 1)
return Promise.resolve() return Promise.resolve()
} }
/** 提交意见反馈 */
export function submitFeedback(feedback: Feedback): Promise<void> { export function submitFeedback(feedback: Feedback): Promise<void> {
void feedback void feedback
return Promise.resolve() return Promise.resolve()
} }
/** 帮助中心 FAQ */ /** TODO(api): 后端帮助中心接口就绪后改为 request 调用;暂用设计稿假数据 */
export function getHelpFaqs(): Promise<HelpFaqGroup[]> { export function getHelpFaqs(): Promise<HelpFaqGroup[]> {
return Promise.resolve([ return Promise.resolve([
{ {
title: '1. 账户与登录', title: '账户与登录',
items: [ items: [
{ {
q: '如何注册账号?', q: '如何注册账号?',
a: '点击页面右上角"注册",输入手机号并获取验证码,设置 6-20 位登录密码即可完成注册。', a: '打开APP后点击“注册”,输入手机号获取验证码,设置登录密码即可完成注册。',
}, },
{ {
q: '忘记密码怎么办?', q: '忘记密码怎么办?',
a: '在登录页点击"忘记密码",通过绑定手机号验证码验证身份后即可重新设置密码。', a: '在登录页面点击“忘记密码”,输入注册手机号获取验证码,验证通过后即可重置新密码。',
}, },
{ {
q: '一个账号可以多地登录吗?', q: '一个账号可以多端登录吗?',
a: '同一账号同时仅允许在 1 台设备上保持在线,超出将自动踢出最早登录的设备。', a: '同一账号仅允许在 1 台设备上保持在线,超出将自动踢出最早登录的设备。',
}, },
{ {
q: '如何修改绑定手机号?', q: '如何修改绑定手机号?',
a: '进入 个人中心 → 账号安全 → 绑定手机,验证原手机号后即可绑定新号码。', a: '进入「我的 -> 设置 -> 账户安全 -> 更换手机号」,验证原手机号后即可绑定新号码。',
}, },
], ],
}, },
{ {
title: '2. 功能使用指南', title: '功能使用指南',
items: [ items: [
{ {
q: '如何提交需求?', q: '如何提交报修/工单?',
a: '进入"发布需求"页面,填写需求描述、预算、周期等信息并提交,专业顾问将在一个工作日内与您联系。', a: '首页点击「报修」 -> 选择报修类型 -> 填写问题描述并上传图片 -> 提交。提交后可在「我的 -> 我的工单」中查看处理进度。',
}, },
{ {
q: '如何查看我的消息?', q: '如何预约会议室?',
a: '登录后点击右上角消息图标,或进入 个人中心 → 消息通知,即可查看系统通知与顾问消息。', a: '进入「服务 -> 会议室预订」,选择日期、时间段及会议室,确认后提交预约,预约成功将收到消息通知。',
}, },
{ {
q: '如何申请售后?', q: '如何查看我的消息通知?',
a: '在"我的订单"中找到对应项目,点击"申请售后"填写问题描述并上传附件,平台将尽快为您处理。', a: '首页右上角铃铛图标进入消息中心,支持按“全部 / 未读 / 系统通知 / 业务提醒”分类查看。',
}, },
], ],
}, },
{ {
title: '3. 系统与权限', title: '系统与权限',
items: [ items: [
{ {
q: '无权限怎么办?', q: 'APP提示“无权限”怎么办?',
a: '请确认当前账号角色是否具备该模块权限;如仍无法访问,请联系专属客服或平台管理员为您开通。', a: '部分功能需要管理员授权后方可使用,请联系贵司系统管理员为您分配对应角色权限。',
}, },
{ {
q: '收不到通知怎么办?', q: '为什么收不到推送通知?',
a: '请检查浏览器通知权限与消息设置是否开启;若仍未收到,请联系客服核实账号通知配置。', a: '请检查:①手机系统设置中是否允许本APP发送通知;②APP内「我的 -> 设置 -> 消息推送」是否已开启。',
}, },
{ {
q: '支持哪些浏览器?', q: 'APP支持哪些系统版本?',
a: '推荐使用 Chrome、Edge、Firefox、Safari 等主流浏览器的最新版本访问本平台。', a: 'iOS 13.0 及以上版本,Android 8.0 及以上版本。建议使用最新系统版本以获得最佳体验。',
}, },
], ],
}, },

View File

@@ -1,270 +1,177 @@
import type { FileInfo, PageResult } from '@/types/api'
import type { import type {
AfterSales, AfterSalesAttachment,
AfterSalesPayload, AfterSalesPayload,
Project, AfterSalesUploadVo,
ProjectListResult,
ProjectQuery, ProjectQuery,
ProjectStatistics,
RdProjectVo,
RequirementDetailBundle,
} from '@/types/business/project' } from '@/types/business/project'
import { request, serializeQuery } from '@/utils/request'
import { formatDate } from '@/utils/format' import { formatDate } from '@/utils/format'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用 // ─────────────────────────────────────────────────────────────────────────────
// GET /web/requirementPool/list → 控制器 ProjectRequirementUserController#list
// → 服务层 ProjectRequirementServiceImpl#queryUserPageList
// 返回 { total, rows: RdProjectVo[] }
// key → 模糊匹配 projectName / projectNo / requirementDesc
// pageNum/pageSize → 分页参数 PageQuery
// ─────────────────────────────────────────────────────────────────────────────
/** 构造 mock 文件信息 */ // ─────────────────────────────────────────────────────────────────────────────
function mockFile(name: string, size: number, uploadTime: string): FileInfo { // GET /web/requirementPool/getFilterConditions
return { name, size, uploadTime, url: '' } // → 控制器 ProjectRequirementUserController#getFilterConditions
// → 服务层 ProjectRequirementServiceImpl#getFilterConditions
// 业务类型统计 businessTypeCounts: [{ businessTypeId, businessTypeLabel, count }]
// ─────────────────────────────────────────────────────────────────────────────
function pad(value: number): string {
return String(value).padStart(2, '0')
} }
/** 详情 mock 所属项目 id */ function toDateTime(date: Date, endOfDay: boolean): string {
const DETAIL_ID = 'project-004' const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
return `${ymd} ${endOfDay ? '23:59:59' : '00:00:00'}`
/** 项目列表 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 toTimeRange(query: ProjectQuery): { beginTime?: string; endTime?: string } {
function matchTimeShortcut(submitTime: string, shortcut: ProjectQuery['timeShortcut']): boolean { if (query.startDate || query.endDate) {
if (shortcut === 'all') return true return {
// mock 时间为 '2026-8-17 14:00',替换为 / 保证各环境可解析 beginTime: query.startDate ? `${query.startDate} 00:00:00` : undefined,
const time = new Date(submitTime.replaceAll('-', '/')).getTime() endTime: query.endDate ? `${query.endDate} 23:59:59` : undefined,
}
}
if (query.timeShortcut === 'all') return {}
const start = new Date() const start = new Date()
if (shortcut === '3m') start.setMonth(start.getMonth() - 3) if (query.timeShortcut === '3m') start.setMonth(start.getMonth() - 3)
if (shortcut === '6m') start.setMonth(start.getMonth() - 6) else if (query.timeShortcut === '6m') start.setMonth(start.getMonth() - 6)
if (shortcut === '1y') start.setFullYear(start.getFullYear() - 1) else start.setFullYear(start.getFullYear() - 1)
return time >= start.getTime() return { beginTime: toDateTime(start, false), endTime: toDateTime(new Date(), true) }
} }
/** 项目列表(筛选 + 分页,pageSize 固定 8) */ export function getProjectList(query: ProjectQuery): Promise<ProjectListResult> {
export function getProjectList(query: ProjectQuery): Promise<PageResult<Project>> { return request<ProjectListResult>({
const filtered = MOCK_PROJECTS.filter((item) => { url: '/web/requirementPool/list',
if ( method: 'get',
query.keyword paramsSerializer: { serialize: serializeQuery },
&& !item.projectName.includes(query.keyword) params: {
&& !item.projectNo.includes(query.keyword) pageNum: query.pageNum,
) { pageSize: query.pageSize,
return false key: query.keyword || undefined,
} businessTypes: query.businessTypes.length > 0 ? [...query.businessTypes] : undefined,
if (query.businessType !== 'all' && item.businessType !== query.businessType) return false status: query.status || undefined,
if (query.status !== 'all' && item.status !== query.status) return false ...toTimeRange(query),
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 getProjectFilterConditions(): Promise<ProjectStatistics> {
export function getProjectDetail(id: string): Promise<Project | null> { return request<ProjectStatistics>({
if (id === DETAIL_ID) return Promise.resolve({ ...PROJECT_DETAIL }) url: '/web/requirementPool/getFilterConditions',
const found = MOCK_PROJECTS.find((item) => item.id === id) ?? null method: 'get',
return Promise.resolve(found ? { ...found } : null) })
} }
/** 上传项目文件(追加进详情文件列表) */ export function getCompletedProjects(): Promise<RdProjectVo[]> {
export function uploadProjectFile(file: File): Promise<FileInfo> { return request<ProjectListResult>({
const info: FileInfo = { url: '/web/requirementPool/list',
name: file.name, method: 'get',
params: {
pageNum: 1,
pageSize: 200,
status: 'COMPLETED',
},
}).then((result) => result?.rows ?? [])
}
// ─────────────────────────────────────────────────────────────────────────────
// GET /web/requirementPool/get/progress/{id} → 进度详情 ProjectRequirementProgressVo
// GET /web/requirementPool/get/files/{id} → 文件资料 ProjectRequirementFilesVo
// GET /web/requirementPool/get/finance/{id} → 财务信息 ProjectRequirementFinanceVo
// GET /web/requirementPool/get/afterSales/{id} → 售后信息 ProjectRequirementAfterSalesVo
// → 控制器 ProjectRequirementUserController#getProgress / #getFiles / #getFinance / #getAfterSales
// → 服务层 ProjectRequirementServiceImpl#queryProgress / queryFiles / queryFinance / queryAfterSales
//
// ─────────────────────────────────────────────────────────────────────────────
export type ProjectDetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
const DETAIL_TAB_URL: Record<ProjectDetailTab, string> = {
progress: '/web/requirementPool/get/progress',
files: '/web/requirementPool/get/files',
finance: '/web/requirementPool/get/finance',
afterSales: '/web/requirementPool/get/afterSales',
}
export function getProjectDetailTab<T extends ProjectDetailTab>(
id: string,
tab: T,
): Promise<RequirementDetailBundle[T] | null> {
return request<RequirementDetailBundle[T] | null>({
url: `${DETAIL_TAB_URL[tab]}/${id}`,
method: 'get',
})
}
export function getProjectDetailBundle(id: string): Promise<RequirementDetailBundle | null> {
return Promise.all([
getProjectDetailTab(id, 'progress'),
getProjectDetailTab(id, 'files'),
getProjectDetailTab(id, 'finance'),
getProjectDetailTab(id, 'afterSales'),
]).then(([progress, files, finance, afterSales]) => {
if (!progress || !files || !finance || !afterSales) return null
return { progress, files, finance, afterSales }
})
}
// ─────────────────────────────────────────────────────────────────────────────
// → ProjectRequirementUserController#requestAfterSale / #withdrawAfterSale
// → ProjectRequirementServiceImpl#requestAfterSale / #withdrawAfterSale
//
//
// ─────────────────────────────────────────────────────────────────────────────
export function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
const data = new FormData()
data.append('file', file)
return request<AfterSalesUploadVo>({
url: '/web/workOrderDraft/upload',
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' },
}).then((vo) => ({
ossId: vo.ossId,
name: vo.originalName || file.name,
size: file.size, size: file.size,
url: vo.url,
uploadTime: formatDate(new Date()), 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( export function submitAfterSales(
projectId: string, id: string,
payload: AfterSalesPayload, payload: AfterSalesPayload,
): Promise<AfterSales> { ): Promise<void> {
const result: AfterSales = { return request<void>({
status: '处理中', url: '/web/requirementPool/afterSales/request',
method: 'post',
data: {
id,
type: payload.type, type: payload.type,
description: payload.description, reason: payload.reason,
remark: payload.remark, note: payload.note,
attachments: [...payload.attachments], attachmentOssIds: payload.attachmentOssIds,
createTime: formatDate(new Date()), },
} })
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
return Promise.resolve({ ...result, attachments: [...result.attachments] })
} }
/** 取消售后工单 */ export function cancelAfterSales(id: string, reason = ''): Promise<void> {
export function cancelAfterSales(projectId: string): Promise<AfterSales> { return request<void>({
const result: AfterSales = { url: '/web/requirementPool/afterSales/withdraw',
status: '无', method: 'post',
type: '', data: { id, reason },
description: '', })
remark: '',
attachments: [],
createTime: '',
}
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
return Promise.resolve({ ...result })
} }

View File

@@ -15,7 +15,6 @@ function nowTime(): string {
return `今天 ${pad(date.getHours())}:${pad(date.getMinutes())}` return `今天 ${pad(date.getHours())}:${pad(date.getMinutes())}`
} }
/** 历史会话(欢迎语 + 用户提问 + 文件资料) */
export function getChatHistory(): Promise<ChatMessage[]> { export function getChatHistory(): Promise<ChatMessage[]> {
return Promise.resolve([ return Promise.resolve([
{ {
@@ -40,7 +39,6 @@ export function getChatHistory(): Promise<ChatMessage[]> {
]) ])
} }
/** 发送文本消息(echo 用户消息,携带当前时间) */
export function sendChatMessage(content: string): Promise<ChatMessage> { export function sendChatMessage(content: string): Promise<ChatMessage> {
return Promise.resolve({ return Promise.resolve({
id: nextId('chat'), id: nextId('chat'),
@@ -50,7 +48,6 @@ export function sendChatMessage(content: string): Promise<ChatMessage> {
}) })
} }
/** 上传聊天文件(生成文件卡片消息) */
export function uploadChatFile(file: File): Promise<ChatMessage> { export function uploadChatFile(file: File): Promise<ChatMessage> {
return Promise.resolve({ return Promise.resolve({
id: nextId('chat'), id: nextId('chat'),
@@ -61,7 +58,6 @@ export function uploadChatFile(file: File): Promise<ChatMessage> {
}) })
} }
/** 客服自动回复 */
export function mockAutoReply(): Promise<ChatMessage> { export function mockAutoReply(): Promise<ChatMessage> {
return Promise.resolve({ return Promise.resolve({
id: nextId('chat'), id: nextId('chat'),

View File

@@ -1,18 +1,9 @@
// 用户资料接口(真实后端绑定)
// 后端:org.dromara.system.controller.system.SysUserController#profile → /system/user/profile
// 鉴权:需登录态
import { request } from '@/utils/request' import { request } from '@/utils/request'
import type { UserProfileVo } from '@/types/business/user' import type { UserProfileVo } from '@/types/business/user'
/**
* 当前登录用户资料(需求提报页预填联系人姓名 / 电话)。
*
* <p>⚠️ 后端返回的是 `R<Map>`,用户对象嵌在 **`data.user`** 里(同级还有 roles / posts),
* 不是平铺在 data 上——这里在 api 层解包,调用方拿到的是用户对象本身。</p>
*/
export async function getUserProfile(): Promise<UserProfileVo> { export async function getUserProfile(): Promise<UserProfileVo> {
const data = await request<{ user: UserProfileVo }>({ const data = await request<{ user: UserProfileVo }>({
url: '/system/user/profile', url: '/web/system/user/profile',
method: 'get', method: 'get',
}) })
return data.user return data.user

View File

@@ -1,62 +0,0 @@
// 用户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',
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
src/assets/home/chahua.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

View File

@@ -22,28 +22,28 @@ const coreBusiness = [
<template> <template>
<footer class="app-footer"> <footer class="app-footer">
<div class="app-footer__inner container"> <div class="app-footer__inner">
<div class="app-footer__about"> <div class="app-footer__about">
<div class="app-footer__brand"> <div class="app-footer__brand">
<!-- TODO(蓝湖): 替换 logo 切图 -->
<img src="@/assets/vue.svg" class="app-footer__logo" alt="虫子智造 logo">
<span class="app-footer__name">虫子智造</span> <span class="app-footer__name">虫子智造</span>
</div> </div>
<p class="app-footer__desc"> <p class="app-footer__desc">
研发智造一体化自营平台,打通研发‑仿真‑设计‑PCB‑SMT‑整机装配‑售后迭代全链路,为科技企业、硬件厂商、创客团队提供软硬一体化定制与量产落地服务。平台自营不开放第三方服务商入驻,对项目质量全权兜底。 研发智造一体化自营平台,打通研发‑仿真‑设计‑PCB‑SMT‑整机装配‑售后迭代全链路,为科技企业、硬件厂商、创客团队提供软硬一体化定制与量产落地服务。平台自营不开放第三方服务商入驻,对项目质量全权兜底。
</p> </p>
<p class="app-footer__legal">
©2026 虫子智造 研发智造一体化平台 版权所有|自营研发生产平台,所有项目源码、图纸受平台知识产权保护,禁止盗用、复制、二次分发
</p>
<p class="app-footer__legal">
{{ siteStore.siteInfo.icp }} | {{ siteStore.siteInfo.police }}
</p>
</div> </div>
<div class="app-footer__col"> <div class="app-footer__col app-footer__col--core">
<h4 class="app-footer__title"> <h4 class="app-footer__title">核心业务</h4>
快速导航 <span
</h4> v-for="item in coreBusiness"
:key="item"
class="app-footer__link"
>{{ item }}</span
>
</div>
<div class="app-footer__col app-footer__col--nav">
<h4 class="app-footer__title">快速导航</h4>
<RouterLink <RouterLink
v-for="link in quickLinks" v-for="link in quickLinks"
:key="link.label" :key="link.label"
@@ -54,93 +54,135 @@ const coreBusiness = [
</RouterLink> </RouterLink>
</div> </div>
<div class="app-footer__col"> <div class="app-footer__col app-footer__col--contact">
<h4 class="app-footer__title"> <h4 class="app-footer__title">联系我们</h4>
核心业务 <span class="app-footer__contact"
</h4> >商务合作:{{ siteStore.siteInfo.businessEmail }}</span
<span >
v-for="item in coreBusiness" <span class="app-footer__contact"
:key="item" >技术支持:{{ siteStore.siteInfo.supportEmail }}</span
class="app-footer__link" >
>{{ item }}</span> <span class="app-footer__contact"
>客服热线:{{ siteStore.siteInfo.hotline }}</span
>
<span class="app-footer__contact"
>工作时间:{{ siteStore.siteInfo.workTime }}</span
>
</div>
</div> </div>
<div class="app-footer__col"> <div class="app-footer__bottom">
<h4 class="app-footer__title"> <p class="app-footer__legal">
联系我们 ©2026 虫子智造 研发智造一体化平台
</h4> 版权所有|自营研发生产平台,所有项目源码、图纸受平台知识产权保护,禁止盗用、复制、二次分发
<span class="app-footer__contact">商务合作:{{ siteStore.siteInfo.businessEmail }}</span> </p>
<span class="app-footer__contact">技术支持:{{ siteStore.siteInfo.supportEmail }}</span> <p class="app-footer__legal app-footer__legal--icp">
<span class="app-footer__contact">客服热线:{{ siteStore.siteInfo.hotline }}</span> {{ siteStore.siteInfo.icp }} | {{ siteStore.siteInfo.police }}
<span class="app-footer__contact">工作时间:{{ siteStore.siteInfo.workTime }}</span> </p>
</div>
</div> </div>
</footer> </footer>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.app-footer { .app-footer {
background: @heading-color; background: @footer-bg;
color: @text-color-secondary; color: @white;
padding: @spacing-xl 0 @spacing-base;
&__inner { &__inner {
.flex(row, space-between, flex-start); .flex(row, space-between, flex-start);
flex-wrap: wrap;
gap: @spacing-xl; gap: @spacing-xl;
padding-top: @spacing-xxl; width: 100%;
padding-bottom: @spacing-xxl; max-width: @page-content;
margin: 0 auto;
box-sizing: border-box;
padding: 0 @page-gutter;
} }
&__about { &__about {
max-width: 480px; flex: 0 1 360px;
max-width: 420px;
min-width: 0;
} }
&__brand { &__brand {
.flex(row, flex-start); .flex(row, flex-start);
gap: @spacing-sm; margin-bottom: @spacing-sm;
margin-bottom: @spacing-base;
}
&__logo {
width: 28px;
height: 28px;
} }
&__name { &__name {
font-size: @font-size-lg; font-size: @font-size-lg;
font-weight: 600; font-weight: 500;
line-height: 1.4;
color: @white; color: @white;
} }
&__desc, &__desc {
&__legal { margin: 0;
margin-top: @spacing-sm;
font-size: @font-size-sm; font-size: @font-size-sm;
font-weight: 400;
line-height: 1.6; line-height: 1.6;
color: fade(@white, 72%);
} }
&__col { &__col {
.flex(column, flex-start); .flex(column, flex-start, flex-start);
gap: @spacing-sm; gap: 2px;
flex: 0 0 auto;
min-width: 0;
} }
&__title { &__title {
margin-bottom: @spacing-sm; margin: 0 0 @spacing-sm;
font-size: @font-size-base; font-size: @font-size-base;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
color: @white; color: @white;
} }
&__link { &__link {
font-size: @font-size-sm; font-size: @font-size-sm;
font-weight: 400;
line-height: 1.7;
white-space: nowrap;
cursor: pointer; cursor: pointer;
transition: color 0.2s; transition: color 0.2s;
color: fade(@white, 72%);
&:hover { &:hover {
color: @primary-color; color: @primary-light;
} }
} }
&__contact { &__contact {
font-size: @font-size-sm; font-size: @font-size-sm;
font-weight: 400;
line-height: 1.7;
color: fade(@white, 72%);
}
&__bottom {
width: 100%;
max-width: @page-content;
margin: @spacing-lg auto 0;
padding: @spacing-base @page-gutter 0;
border-top: 1px solid fade(@white, 10%);
text-align: center;
box-sizing: border-box;
}
&__legal {
margin: 0;
font-size: @font-size-xs;
font-weight: 400;
line-height: 1.6;
color: fade(@white, 55%);
&--icp {
margin-top: @spacing-xs;
}
} }
} }
</style> </style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useMessageStore } from '@/stores/modules/message' import { useMessageStore } from '@/stores/modules/message'
@@ -18,36 +19,64 @@ const navItems: NavItem[] = [
{ label: '个人中心', path: '/profile' }, { label: '个人中心', path: '/profile' },
] ]
// 首页 path 为 '/',是其它所有路径的前缀;用 Vue Router 默认的
// router-link-active(包含匹配)会导致首页在所有页面都被点亮。
// 这里自算激活态:首页仅严格等于 '/' 时高亮,其它菜单等于其路径或以 '/path/' 开头
// (覆盖子路由,如 /project/123 点亮「项目中心」)。
const route = useRoute()
const activePath = computed(() => route.path)
function isActive(item: NavItem): boolean {
const current = activePath.value
if (item.path === '/') return current === '/'
return current === item.path || current.startsWith(item.path + '/')
}
const messageStore = useMessageStore() const messageStore = useMessageStore()
const { unreadCount } = storeToRefs(messageStore) const { unreadCount } = storeToRefs(messageStore)
onMounted(() => void messageStore.fetchUnreadCount()) onMounted(() => void messageStore.fetchUnreadCount())
</script> </script>
<template> <template>
<header class="app-header"> <header class="app-header">
<div class="app-header__inner container"> <div class="app-header__inner">
<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"> <nav class="app-header__nav">
<RouterLink <RouterLink
v-for="item in navItems" v-for="item in navItems"
:key="item.path" :key="item.path"
:to="item.path" :to="item.path"
class="app-header__nav-item" class="app-header__nav-item"
:class="{ 'app-header__nav-item--active': isActive(item) }"
> >
{{ item.label }} {{ item.label }}
</RouterLink> </RouterLink>
</nav> </nav>
<div class="app-header__actions"> <div class="app-header__actions">
<RouterLink to="/message" class="app-header__bell" aria-label="消息中心"> <RouterLink to="/demand" class="app-header__action">
<img
src="@/assets/icons/quick-report.png"
class="app-header__action-icon"
alt=""
/>
快捷提报
</RouterLink>
<RouterLink to="/service" class="app-header__action">
<img
src="@/assets/icons/online-service.png"
class="app-header__action-icon"
alt=""
/>
在线客服
</RouterLink>
<RouterLink
to="/message"
class="app-header__bell"
aria-label="消息中心"
>
<span class="app-header__bell-icon">🔔</span> <span class="app-header__bell-icon">🔔</span>
<span v-if="unreadCount > 0" class="app-header__badge">{{ unreadCount }}</span> <span v-if="unreadCount > 0" class="app-header__badge">{{
unreadCount > 99 ? '99+' : unreadCount
}}</span>
</RouterLink> </RouterLink>
<RouterLink to="/login" class="app-header__login link"> <RouterLink to="/login" class="app-header__login link">
登录 登录
@@ -63,76 +92,113 @@ onMounted(() => void messageStore.fetchUnreadCount())
top: 0; top: 0;
z-index: 100; z-index: 100;
height: @header-height; height: @header-height;
background: @white; background: @header-bg;
box-shadow: @shadow-base; border-bottom: 1px solid @header-border;
&__inner { &__inner {
.flex(row, space-between);
height: 100%;
}
&__logo {
.flex(row, flex-start); .flex(row, flex-start);
gap: @spacing-sm; height: 100%;
// 设计稿 1920 基准:导航首项左 275px,操作区右边界 1800px(右侧留白 120px)。
&-icon { // 视口窄于 1920 时按留白收缩,避免两端顶死。
width: 32px; padding-left: clamp(@spacing-base, calc((100% - 1370px) / 2), 275px);
height: 32px; padding-right: clamp(@spacing-base, calc((100% - 1680px) / 2), 120px);
}
&-text {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
} }
&__nav { &__nav {
.flex(row, center); .flex(row, center);
gap: @spacing-lg; gap: clamp(@spacing-lg, 4vw, 56px);
height: 100%;
&-item { &-item {
font-size: @font-size-base; position: relative;
color: @text-color; .flex(row, center);
height: 100%;
font-size: @font-size-md;
font-weight: 500;
color: @heading-color;
transition: color 0.2s; transition: color 0.2s;
&:hover, &:hover,
&.router-link-active { &--active {
color: @primary-color; color: @home-tab-active;
}
&--active::after {
content: '';
position: absolute;
left: 50%;
bottom: 12px;
width: 24px;
height: 2px;
border-radius: 1px;
background: @home-tab-active;
transform: translateX(-50%);
} }
} }
} }
&__actions { &__actions {
.flex(row, flex-end); .flex(row, flex-end, center);
gap: @spacing-base; gap: @spacing-base;
margin-left: auto;
flex-shrink: 0;
}
&__action {
.flex(row, center);
gap: @spacing-xs;
min-width: 96px;
height: 36px;
padding: 0 @spacing-base;
border-radius: @radius-round;
background: @primary-color;
color: @white;
font-size: @font-size-sm;
font-weight: 500;
transition: opacity 0.2s;
&:hover {
opacity: 0.88;
}
&-icon {
width: 16px;
height: 16px;
}
} }
&__bell { &__bell {
position: relative; position: relative;
.flex(row, center, center);
width: 36px;
height: 36px;
flex-shrink: 0;
&-icon { &-icon {
font-size: @font-size-lg; font-size: 20px;
line-height: 1;
} }
} }
&__badge { &__badge {
position: absolute; position: absolute;
top: -6px; top: -2px;
right: -8px; right: -2px;
min-width: 16px; min-width: 16px;
height: 16px; height: 16px;
padding: 0 4px; padding: 0 4px;
border-radius: @radius-round; border-radius: @radius-round;
background: @danger-color; background: @danger-color;
color: @white; color: @white;
font-size: @font-size-sm; font-size: 12px;
line-height: 16px; line-height: 16px;
text-align: center; text-align: center;
box-sizing: border-box;
} }
&__login { &__login {
font-size: @font-size-base; font-size: @font-size-base;
font-weight: 500;
} }
} }
</style> </style>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { formatBusinessType } from '@/utils/format' import { formatBusinessType } from '@/utils/format'
import type { Case } from '@/types/business/cases' import type { Case } from '@/types/business/cases'
@@ -6,36 +7,58 @@ interface Props {
item: Case item: Case
} }
defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ view: [id: string] }>() const emit = defineEmits<{
view: [id: string]
favorite: [id: string]
}>()
function onView(id: string): void { const meta = computed<Array<{ label: string; value: string }>>(() => [
emit('view', id) { label: '客户类型', value: props.item.clientType || '--' },
} { label: '所属行业', value: props.item.industry || '--' },
{ label: '规模', value: props.item.scale || '--' },
{ label: '项目周期', value: props.item.duration || '--' },
{ label: '服务团队', value: props.item.teamSize ? `${props.item.teamSize}人` : '--' },
{ label: '完成时间', value: props.item.finishDate ? props.item.finishDate.replace(/-/g, '/') : '--' },
])
</script> </script>
<template> <template>
<article class="case-card" @click="onView(item.id)"> <article class="case-card" @click="emit('view', item.id)">
<div class="case-card__cover"> <div class="case-card__cover">
<!-- TODO(蓝湖): 替换案例封面切图,接入 v-lazy --> <!-- TODO(蓝湖): 替换案例封面切图 -->
<img v-if="item.cover" v-lazy="item.cover" class="case-card__img" :alt="item.title"> <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" /> <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> <span class="case-card__tag" :class="`case-card__tag--${item.category}`">
{{ formatBusinessType(item.category) }}
</span>
<button
class="case-card__fav"
:class="{ 'is-active': item.isFavorite }"
:aria-label="item.isFavorite ? '取消收藏' : '收藏案例'"
@click.stop="emit('favorite', item.id)"
>
<svg class="case-card__fav-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 20.5s-7.2-4.6-8.4-9A5.1 5.1 0 0 1 12 5.6a5.1 5.1 0 0 1 8.4 5.9c-1.2 4.4-8.4 9-8.4 9Z" />
</svg>
</button>
</div> </div>
<div class="case-card__body"> <div class="case-card__body">
<h3 class="case-card__title"> <h3 class="case-card__title">
{{ item.title }} {{ item.title }}
</h3> </h3>
<ul class="case-card__meta"> <ul class="case-card__meta">
<li>完成时间:{{ item.finishDate }}</li> <li v-for="row in meta" :key="row.label">
<li>服务团队:{{ item.teamSize }}人</li> <span class="case-card__label">{{ row.label }}:</span>{{ row.value }}
<li>规模:{{ item.scale }}</li> </li>
<li>所属行业:{{ item.industry }}</li>
<li>项目周期:{{ item.duration }}</li>
<li>客户类型:{{ item.clientType }}</li>
</ul> </ul>
<span class="case-card__more">查看详情 ></span>
</div> </div>
</article> </article>
</template> </template>
@@ -43,24 +66,22 @@ function onView(id: string): void {
<style lang="less" scoped> <style lang="less" scoped>
.case-card { .case-card {
overflow: hidden; overflow: hidden;
border-radius: @radius-lg; height: 267px;
box-sizing: border-box;
border: 1px solid @cases-card-border;
border-radius: @radius-base;
background: @white; background: @white;
box-shadow: @shadow-base;
cursor: pointer; cursor: pointer;
transition: box-shadow 0.2s, transform 0.2s; transition: box-shadow 0.2s, transform 0.2s;
&:hover { &:hover {
box-shadow: @shadow-lg; box-shadow: @shadow-lg;
transform: translateY(-2px); transform: translateY(-2px);
.case-card__more {
opacity: 1;
}
} }
&__cover { &__cover {
position: relative; position: relative;
height: 160px; height: 137px;
overflow: hidden; overflow: hidden;
} }
@@ -70,56 +91,115 @@ function onView(id: string): void {
object-fit: cover; object-fit: cover;
&--placeholder { &--placeholder {
background: linear-gradient(135deg, @primary-light, @bg-color); background: @primary-strong;
} }
} }
&__tag { &__tag {
position: absolute; position: absolute;
top: @spacing-sm; top: 12px;
left: @spacing-sm; left: 12px;
padding: 2px @spacing-sm; height: 28px;
border-radius: @radius-round; padding: 0 @spacing-sm;
background: rgba(0, 0, 0, 0.55); border-radius: @radius-sm;
background: @primary-strong;
color: @white; color: @white;
font-size: @font-size-sm; font-size: @font-size-sm;
font-weight: 500;
line-height: 28px;
&--hardware {
background: @tag-hardware;
} }
&__more { &--iot {
background: @tag-iot;
}
&--manufacturing {
background: @tag-manufacturing;
}
&--other {
background: @tag-other;
}
}
&__fav {
position: absolute; position: absolute;
right: @spacing-sm; top: 10px;
bottom: @spacing-sm; right: 10px;
padding: 2px @spacing-sm; .flex(row, center);
width: 28px;
height: 28px;
border-radius: @radius-round; border-radius: @radius-round;
background: @primary-color; background: rgba(255, 255, 255, 0.85);
color: @white; transition: background 0.2s;
font-size: @font-size-sm;
opacity: 0; &:hover {
transition: opacity 0.2s; background: @white;
}
&-icon {
width: 15px;
height: 15px;
fill: none;
stroke: @text-color-secondary;
stroke-width: 1.8;
transition: all 0.2s;
}
&.is-active {
background: @white;
.case-card__fav-icon {
fill: @danger-color;
stroke: @danger-color;
}
}
} }
&__body { &__body {
padding: @spacing-base; position: relative;
padding: 18px 20px 0;
height: 129px;
box-sizing: border-box;
} }
&__title { &__title {
margin-bottom: @spacing-sm; margin: 0;
font-size: @font-size-lg; font-size: 16px;
font-weight: 600; font-weight: 500;
color: @heading-color; line-height: 21.5px;
.multi-ellipsis(1); color: @cases-text;
.ellipsis();
} }
&__meta { &__meta {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px @spacing-sm; gap: 21.5px 12px;
font-size: @font-size-sm; margin-top: 10px;
color: @text-color-secondary; font-size: 10px;
line-height: 14px;
color: @cases-text;
li { li {
.ellipsis(); overflow-wrap: anywhere;
} }
} }
&__label {
color: inherit;
}
&__more {
position: absolute;
right: 20px;
bottom: 14px;
color: @primary-strong;
font-size: 12px;
font-weight: 400;
}
} }
</style> </style>

View File

@@ -0,0 +1,592 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElDatePicker } from 'element-plus'
import type { FilterChip } from '@/components'
import { formatDate } from '@/utils/format'
import type { BusinessIndustryOption, CaseQuery, CaseTypeOption, ScaleLimit } from '@/types/business/cases'
interface Props {
query: CaseQuery
typeOptions: CaseTypeOption[]
industryOptions: BusinessIndustryOption[]
scaleOptions: ScaleLimit[]
caseTotal: number
previewTotal: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:query': [query: CaseQuery]
search: []
reset: []
}>()
type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom'
const timeShortcuts: Array<{ label: string; value: Exclude<TimeShortcut, 'custom'>; months: number }> = [
{ label: '全部', value: 'all', months: 0 },
{ label: '近3个月', value: '3m', months: 3 },
{ label: '近半年', value: '6m', months: 6 },
{ label: '近一年', value: '1y', months: 12 },
]
function scaleKey(item: ScaleLimit): string {
return `${item.lowerLimit ?? ''}-${item.upperLimit ?? ''}`
}
function scaleLabel(item: ScaleLimit): string {
return item.label ?? `${item.lowerLimit ?? 0}-${item.upperLimit ?? '不限'}万`
}
function rangeOf(months: number): [string, string] {
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - months)
return [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')]
}
const activeShortcut = computed<TimeShortcut>(() => {
const range = props.query.dateRange
if (!range) return 'all'
const hit = timeShortcuts.find(
(item) => item.value !== 'all' && rangeOf(item.months).join() === range.join(),
)
return hit ? hit.value : 'custom'
})
const dateRange = computed<[string, string] | null>({
get: () => props.query.dateRange,
set: (value) => patch({ dateRange: value }),
})
const chips = computed<FilterChip[]>(() => {
const result: FilterChip[] = []
for (const value of props.query.categories) {
const option = props.typeOptions.find((item) => item.value === value)
result.push({ key: `category:${value}`, label: option?.label ?? value })
}
for (const name of props.query.industries) {
result.push({ key: `industry:${name}`, label: name })
}
for (const scale of props.query.scales) {
result.push({ key: `scale:${scaleKey(scale)}`, label: scaleLabel(scale) })
}
if (props.query.dateRange) {
const label = timeShortcuts.find((item) => item.value === activeShortcut.value)?.label
result.push({
key: 'time',
label: label ?? `${props.query.dateRange[0]}-${props.query.dateRange[1]}`,
})
}
return result
})
function patch(partial: Partial<CaseQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
function toggleIn<T>(list: T[], value: T): T[] {
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]
}
function toggleScale(item: ScaleLimit): void {
const key = scaleKey(item)
const exists = props.query.scales.some((scale) => scaleKey(scale) === key)
patch({
scales: exists
? props.query.scales.filter((scale) => scaleKey(scale) !== key)
: [...props.query.scales, { ...item }],
})
}
function setShortcut(value: Exclude<TimeShortcut, 'custom'>): void {
if (value === 'all') {
patch({ dateRange: null })
return
}
const shortcut = timeShortcuts.find((item) => item.value === value)
if (shortcut) patch({ dateRange: rangeOf(shortcut.months) })
}
function onRemoveChip(key: string): void {
if (key === 'time') {
patch({ dateRange: null })
return
}
if (key.startsWith('category:')) {
const value = key.slice('category:'.length)
patch({ categories: props.query.categories.filter((item) => item !== value) })
return
}
if (key.startsWith('industry:')) {
const name = key.slice('industry:'.length)
patch({ industries: props.query.industries.filter((item) => item !== name) })
return
}
if (key.startsWith('scale:')) {
const target = key.slice('scale:'.length)
patch({ scales: props.query.scales.filter((item) => scaleKey(item) !== target) })
}
}
function clearChips(): void {
patch({ categories: [], industries: [], scales: [], dateRange: null })
}
</script>
<template>
<aside class="case-filter">
<div class="case-filter__head">
<h2 class="case-filter__title">
筛选条件
</h2>
<button class="case-filter__link" @click="emit('reset')">
重置
</button>
</div>
<section class="case-filter__group">
<h3 class="case-filter__group-title">
业务类型
</h3>
<ul class="case-filter__rows">
<li>
<button
class="case-filter__row"
:class="{ 'is-active': query.categories.length === 0 }"
@click="patch({ categories: [] })"
>
<span class="case-filter__box" />
<span class="case-filter__row-label">全部</span>
<span class="case-filter__row-count">{{ caseTotal }}</span>
</button>
</li>
<li v-for="option in typeOptions" :key="option.value">
<button
class="case-filter__row"
:class="{ 'is-active': query.categories.includes(option.value) }"
@click="patch({ categories: toggleIn(query.categories, option.value) })"
>
<span class="case-filter__box" />
<span class="case-filter__row-label">{{ option.label }}</span>
<span class="case-filter__row-count">{{ option.count }}</span>
</button>
</li>
</ul>
</section>
<section class="case-filter__group">
<h3 class="case-filter__group-title">
所属行业
</h3>
<div class="case-filter__pills">
<button
class="case-filter__pill"
:class="{ 'is-light': query.industries.length === 0 }"
@click="patch({ industries: [] })"
>
全部
</button>
<button
v-for="option in industryOptions"
:key="option.name ?? ''"
class="case-filter__pill"
:class="{ 'is-active': query.industries.includes(option.name ?? '') }"
@click="patch({ industries: toggleIn(query.industries, option.name ?? '') })"
>
{{ option.name }}
</button>
</div>
</section>
<section class="case-filter__group">
<h3 class="case-filter__group-title">
项目规模
</h3>
<div class="case-filter__pills">
<button
class="case-filter__pill"
:class="{ 'is-light': query.scales.length === 0 }"
@click="patch({ scales: [] })"
>
全部
</button>
<button
v-for="option in scaleOptions"
:key="scaleKey(option)"
class="case-filter__pill"
:class="{ 'is-active': query.scales.some((item) => scaleKey(item) === scaleKey(option)) }"
@click="toggleScale(option)"
>
{{ scaleLabel(option) }}
</button>
</div>
</section>
<section class="case-filter__group">
<h3 class="case-filter__group-title">
完成时间
</h3>
<div class="case-filter__pills">
<button
v-for="shortcut in timeShortcuts"
:key="shortcut.value"
class="case-filter__pill"
:class="{
'is-light': shortcut.value === 'all' && activeShortcut === 'all',
'is-active': shortcut.value !== 'all' && activeShortcut === shortcut.value,
}"
@click="setShortcut(shortcut.value)"
>
{{ shortcut.label }}
</button>
<div class="case-filter__date-wrap">
<ElDatePicker
v-model="dateRange"
class="case-filter__date"
type="daterange"
range-separator="—"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD"
/>
</div>
</div>
</section>
<section class="case-filter__selected">
<div class="case-filter__selected-head">
<span class="case-filter__selected-title">已选条件({{ chips.length }}个)</span>
<button class="case-filter__link" @click="clearChips">
清空
</button>
</div>
<div v-if="chips.length > 0" class="case-filter__chips">
<button
v-for="chip in chips"
:key="chip.key"
class="case-filter__chip"
:title="`移除 ${chip.label}`"
@click="onRemoveChip(chip.key)"
>
{{ chip.label }} ×
</button>
</div>
<p v-else class="case-filter__selected-empty">
暂无已选条件
</p>
</section>
<div class="case-filter__actions">
<button class="case-filter__reset" @click="emit('reset')">
重置
</button>
<button class="case-filter__confirm" @click="emit('search')">
确定筛选({{ previewTotal }}个结果)
</button>
</div>
</aside>
</template>
<style lang="less" scoped>
.case-filter {
flex-shrink: 0;
width: @cases-filter-width;
padding: @spacing-lg 0 @spacing-xl;
border-radius: @radius-base;
background: @white;
&__head,
&__group,
&__actions {
padding: 0 @spacing-lg;
}
&__head {
.flex(row, space-between, center);
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
line-height: 1.4;
color: @cases-filter-title;
}
&__link {
color: @primary-strong;
font-size: @font-size-sm;
&:hover {
color: @primary-hover;
}
}
&__group {
margin-top: @spacing-lg;
}
&__head + &__group {
margin-top: @spacing-base;
}
&__group-title {
font-size: @font-size-md;
font-weight: 500;
line-height: 1.4;
color: @cases-text;
}
&__rows {
margin-top: @spacing-sm;
display: flex;
flex-direction: column;
gap: 2px;
}
&__row {
.flex(row, flex-start, center);
gap: @spacing-sm;
width: 100%;
height: 40px;
padding: 0 @spacing-sm;
border-radius: @radius-sm;
color: @cases-text;
font-size: @font-size-base;
text-align: left;
transition: background 0.2s;
&:hover {
background: @bg-color-light;
}
&.is-active {
background: @filter-selected-bg;
color: @primary-strong;
font-weight: 600;
}
}
&__box {
position: relative;
flex-shrink: 0;
width: 14px;
height: 14px;
border: 1px solid @cases-input-border;
border-radius: 2px;
.case-filter__row.is-active & {
border-color: @primary-strong;
background: @primary-strong;
&::after {
content: '';
position: absolute;
top: 1px;
left: 3px;
width: 4px;
height: 7px;
border: solid @white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
}
&__row-label {
flex: 1;
.ellipsis();
}
&__row-count {
color: @cases-muted;
font-size: @font-size-sm;
font-weight: 500;
}
.case-filter__row.is-active &__row-count {
color: inherit;
font-weight: inherit;
}
&__pills {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: @spacing-sm;
margin-top: @spacing-sm;
}
&__pill {
height: 36px;
padding: 0 @spacing-sm;
border-radius: @radius-base;
background: @pill-bg;
color: @cases-text;
font-size: @font-size-sm;
.ellipsis();
transition: all 0.2s;
&:hover {
color: @primary-strong;
}
&.is-active,
&.is-light {
background: @filter-selected-bg;
color: @primary-strong;
font-weight: 500;
}
}
// ElDatePicker 有两层元素:外层 div.case-filter__date 与内部 .el-range-editor,
// 真正控制盒子尺寸的是内层。所以尺寸/背景/圆角都要用 :deep() 打到内层。
&__date-wrap {
grid-column: 1 / -1;
width: 100%;
:deep(.case-filter__date),
:deep(.case-filter__date .el-range-editor) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
height: 36px;
padding: 0 @spacing-sm;
border-radius: @radius-base;
background: @input-bg;
box-sizing: border-box;
box-shadow: none;
justify-content: center;
}
}
&__date {
justify-content: center;
min-width: 0;
height: 36px;
padding: 0 @spacing-sm;
border-radius: @radius-base;
background: @input-bg;
box-sizing: border-box;
box-shadow: none;
&:hover,
&.is-active,
&.is-focus {
box-shadow: none;
}
:deep(.el-range__icon),
:deep(.el-range__close-icon) {
display: none;
}
:deep(.el-range-input) {
flex: 0 1 auto;
width: 88px;
background: transparent;
color: @cases-text;
font-size: @font-size-sm;
&::placeholder {
color: @cases-muted;
}
}
:deep(.el-range-separator) {
flex: 0 0 auto;
margin: 0 @spacing-xs;
color: @cases-muted;
font-size: @font-size-sm;
line-height: 36px;
}
&.is-focus {
background: @white;
box-shadow: 0 0 0 1px @cases-accent inset;
}
}
&__selected {
margin: @spacing-lg @spacing-lg 0;
padding: @spacing-base;
border-radius: @radius-base;
background: @chips-panel-bg;
}
&__selected-head {
.flex(row, space-between, center);
}
&__selected-title {
color: @cases-text;
font-size: @font-size-base;
font-weight: 500;
}
&__selected-empty {
margin-top: @spacing-sm;
color: @cases-muted;
font-size: @font-size-sm;
}
&__chips {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
margin-top: @spacing-sm;
}
&__chip {
height: 28px;
padding: 0 @spacing-sm;
border-radius: @radius-round;
background: @white;
color: @cases-accent;
font-size: @font-size-sm;
font-weight: 500;
transition: color 0.2s;
&:hover {
color: @primary-hover;
}
}
&__actions {
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-top: @spacing-lg;
}
&__reset {
flex: 0 0 88px;
height: 32px;
border-radius: @radius-round;
background: @cases-reset-bg;
color: @text-color;
font-size: @font-size-sm;
font-weight: 400;
transition: color 0.2s;
&:hover {
color: @primary-color;
}
}
&__confirm {
flex: 1;
height: 32px;
border-radius: @radius-round;
background: @cases-confirm;
color: @white;
font-size: @font-size-sm;
font-weight: 400;
transition: background 0.2s;
&:hover {
background: @cases-accent;
}
}
}
</style>

View File

@@ -4,23 +4,29 @@ import type { Case } from '@/types/business/cases'
interface Props { interface Props {
cases: Case[] cases: Case[]
total: number
} }
defineProps<Props>() defineProps<Props>()
const emit = defineEmits<{ view: [id: string] }>() const emit = defineEmits<{
view: [id: string]
favorite: [id: string]
}>()
</script> </script>
<template> <template>
<div class="cases-grid"> <div class="cases-grid">
<template v-if="cases.length > 0"> <div v-if="cases.length > 0" class="cases-grid__list">
<CaseCard <CaseCard
v-for="item in cases" v-for="item in cases"
:key="item.id" :key="item.id"
:item="item" :item="item"
@view="emit('view', $event)" @view="emit('view', $event)"
@favorite="emit('favorite', $event)"
/> />
</template> </div>
<p v-else class="cases-grid__empty text-secondary"> <p v-else class="cases-grid__empty text-secondary">
暂无符合条件的案例,请调整筛选条件 暂无符合条件的案例,请调整筛选条件
</p> </p>
@@ -29,15 +35,16 @@ const emit = defineEmits<{ view: [id: string] }>()
<style lang="less" scoped> <style lang="less" scoped>
.cases-grid { .cases-grid {
&__list {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: @spacing-base; gap: 23px 28px;
}
&__empty { &__empty {
grid-column: 1 / -1;
padding: @spacing-xxl 0; padding: @spacing-xxl 0;
text-align: center; text-align: center;
font-size: @font-size-base; font-size: 16px;
} }
} }
</style> </style>

View File

@@ -3,11 +3,12 @@ import { computed } from 'vue'
interface Props { interface Props {
keyword: string keyword: string
/** 是否只看收藏 */
favoriteOnly: boolean favoriteOnly: boolean
/** 是否显示「收藏列表」切换按钮(案例库显示,收藏页本身不显示) */
showFavoriteToggle?: boolean
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), { showFavoriteToggle: true })
const emit = defineEmits<{ const emit = defineEmits<{
'update:keyword': [keyword: string] 'update:keyword': [keyword: string]
@@ -45,6 +46,7 @@ const keyword = computed<string>({
</button> </button>
<button <button
v-if="showFavoriteToggle"
class="case-search__fav" class="case-search__fav"
:class="{ 'is-active': favoriteOnly }" :class="{ 'is-active': favoriteOnly }"
@click="emit('toggleFavoriteOnly')" @click="emit('toggleFavoriteOnly')"
@@ -60,57 +62,65 @@ const keyword = computed<string>({
<style lang="less" scoped> <style lang="less" scoped>
.case-search { .case-search {
.flex(row, flex-start, center); .flex(row, flex-start, center);
gap: @spacing-sm; gap: @spacing-base;
padding: @spacing-base @spacing-lg; width: 100%;
min-height: 64px;
box-sizing: border-box;
padding: 0 @spacing-lg;
border-radius: @radius-base; border-radius: @radius-base;
background: @white; background: @white;
&__field { &__field {
position: relative; position: relative;
flex: 1; flex: 0 1 360px;
max-width: 520px; min-width: 0;
} }
&__icon { &__icon {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 12px; right: @spacing-base;
width: 16px; left: auto;
height: 16px; width: 18px;
height: 18px;
transform: translateY(-50%); transform: translateY(-50%);
fill: none; fill: none;
stroke: @text-color-secondary; stroke: @cases-icon;
stroke-width: 1.8; stroke-width: 1.8;
} }
&__input { &__input {
width: 100%; width: 100%;
height: 36px; height: 36px;
padding: 0 @spacing-base 0 36px; padding: 0 40px 0 @spacing-base;
border: 1px solid transparent; border: 1px solid @cases-input-border;
border-radius: @radius-base; border-radius: @radius-round;
background: @input-bg; background: @input-bg;
color: @text-color; color: @cases-text;
font-size: @font-size-base; font-size: @font-size-base;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s, background-color 0.2s;
&::placeholder { &::placeholder {
color: @text-color-secondary; color: @text-color-disabled;
} }
&:focus { &:focus {
border-color: @primary-color; border-color: @cases-accent;
background: @white;
} }
} }
&__btn { &__btn {
flex-shrink: 0;
min-width: 72px;
height: 36px; height: 36px;
padding: 0 @spacing-lg; padding: 0 @spacing-lg;
border-radius: @radius-base; border-radius: @radius-round;
background: @pill-bg; background: @pill-bg;
color: @text-color; color: @text-color;
font-size: @font-size-base; font-size: @font-size-base;
font-weight: 400;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
@@ -118,7 +128,7 @@ const keyword = computed<string>({
} }
&--primary { &--primary {
background: @primary-color; background: @cases-accent;
color: @white; color: @white;
&:hover { &:hover {
@@ -147,8 +157,8 @@ const keyword = computed<string>({
} }
&-icon { &-icon {
width: 16px; width: 18px;
height: 16px; height: 18px;
fill: none; fill: none;
stroke: currentColor; stroke: currentColor;
stroke-width: 1.8; stroke-width: 1.8;

View File

@@ -63,67 +63,82 @@ function onCancel(): void {
inset: 0; inset: 0;
z-index: 1000; z-index: 1000;
.flex(row, center); .flex(row, center);
background: rgba(0, 0, 0, 0.45); background: transparent;
&__panel { &__panel {
width: 400px; width: 660px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
padding: @spacing-lg; padding: @spacing-base;
border-radius: @radius-lg; border-radius: @radius-lg;
background: @white; background: @white;
box-shadow: @shadow-lg; box-shadow: @shadow-lg;
} }
&__title { &__title {
position: relative;
margin-bottom: @spacing-base; margin-bottom: @spacing-base;
font-size: @font-size-lg; padding-left: 12px;
font-weight: 600; font-size: 12px;
font-weight: 700;
color: @heading-color; color: @heading-color;
line-height: 20px;
&::before {
content: '';
position: absolute;
left: 0;
top: 3px;
width: 3px;
height: 14px;
border-radius: 2px;
background: @primary-strong;
}
} }
&__body { &__body {
margin-bottom: @spacing-lg; margin-bottom: @spacing-lg;
font-size: @font-size-base; font-size: 11px;
color: @text-color; color: @text-color;
line-height: 1.6; line-height: 1.7;
} }
&__footer { &__footer {
.flex(row, flex-end); .flex(row, flex-start);
gap: @spacing-sm; gap: @spacing-sm;
} }
&__btn { &__btn {
height: 34px; flex: 1;
height: 36px;
padding: 0 @spacing-base; padding: 0 @spacing-base;
border: 1px solid @border-color-dark; border: none;
border-radius: @radius-base; border-radius: @profile-radius;
background: @white; background: @profile-quiet-btn;
color: @text-color; color: @text-color;
font-size: @font-size-base; font-size: 12px;
font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
border-color: @primary-color; border: none;
color: @primary-color; background: darken(@profile-quiet-btn, 4%);
color: @text-color;
} }
&--confirm { &--confirm {
border-color: @primary-color; background: @primary-strong;
background: @primary-color;
color: @white; color: @white;
&:hover { &:hover {
background: @primary-hover; background: darken(@primary-strong, 6%);
color: @white; color: @white;
} }
&.is-danger { &.is-danger {
border-color: @danger-color;
background: @danger-color; background: @danger-color;
&:hover { &:hover {
background: fade(@danger-color, 85%); background: darken(@danger-color, 6%);
} }
} }
} }

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElTooltip } from 'element-plus'
interface Props {
text: string
placement?: 'top' | 'bottom' | 'left' | 'right'
}
const props = withDefaults(defineProps<Props>(), {
placement: 'top',
})
const root = ref<HTMLElement | null>(null)
const overflow = ref(false)
function check(): void {
const el = root.value
if (!el) return
overflow.value = el.scrollWidth > el.clientWidth + 1
}
let ro: ResizeObserver | null = null
onMounted(() => {
check()
if (root.value && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => check())
ro.observe(root.value)
}
})
onBeforeUnmount(() => {
ro?.disconnect()
ro = null
})
watch(
() => props.text,
() => check(),
)
</script>
<template>
<span ref="root" class="ellipsis-text">
<ElTooltip :content="text" :disabled="!overflow" :placement="placement" effect="dark">
<span class="ellipsis-text__inner">{{ text }}</span>
</ElTooltip>
</span>
</template>
<style lang="less" scoped>
.ellipsis-text {
display: block;
width: 100%;
margin-top: 6px;
font-size: @font-size-base;
font-weight: 600;
color: @profile-ink;
line-height: 20px;
.ellipsis();
&__inner {
display: inline-block;
max-width: 100%;
vertical-align: bottom;
}
}
</style>

View File

@@ -1,13 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FileInfo } from '@/types/api' import type { FileInfo } from '@/types/api'
import { fileLabelOf, fileToneOf } from '@/utils/fileMeta'
import { formatFileSize } from '@/utils/format' import { formatFileSize } from '@/utils/format'
interface Props { interface Props {
files: FileInfo[] files: FileInfo[]
showActions?: boolean showActions?: boolean
/** 左侧文件类型徽标(设计稿 #28 附件行有,#29/#27 同款) */
showBadge?: boolean
} }
withDefaults(defineProps<Props>(), { showActions: true }) withDefaults(defineProps<Props>(), { showActions: true, showBadge: false })
const emit = defineEmits<{ const emit = defineEmits<{
preview: [file: FileInfo] preview: [file: FileInfo]
@@ -19,6 +22,13 @@ const emit = defineEmits<{
<template> <template>
<ul v-if="files.length > 0" class="file-list"> <ul v-if="files.length > 0" class="file-list">
<li v-for="(file, index) in files" :key="`${file.name}-${index}`" class="file-list__item"> <li v-for="(file, index) in files" :key="`${file.name}-${index}`" class="file-list__item">
<span
v-if="showBadge"
class="file-list__badge"
:style="{ color: fileToneOf(file), backgroundColor: `${fileToneOf(file)}1a` }"
>
{{ fileLabelOf(file) }}
</span>
<div class="file-list__info"> <div class="file-list__info">
<span class="file-list__name" :title="file.name">{{ file.name }}</span> <span class="file-list__name" :title="file.name">{{ file.name }}</span>
<span class="file-list__meta text-secondary"> <span class="file-list__meta text-secondary">
@@ -48,10 +58,22 @@ const emit = defineEmits<{
&__item { &__item {
.flex(row, space-between); .flex(row, space-between);
gap: @spacing-base; gap: @spacing-base;
padding: @spacing-sm @spacing-base; padding: @spacing-base;
border: 1px solid @border-color; border: none;
border-radius: @radius-base; border-radius: @radius-sm;
background: @bg-color-light; background: @demand-upload-bg;
}
// 设计稿 #29/#27/#28 附件行:徽标 33.5×33.5。
&__badge {
.flex(row, center, center);
flex-shrink: 0;
width: 33.5px;
height: 33.5px;
border-radius: @radius-sm;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.2px;
} }
&__info { &__info {

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { AppButton } from '@/components'
import { useOssStore } from '@/stores/modules/oss'
import { PREVIEW_TEXT_MAX_BYTES, formatBytes } from '@/utils/filePreview'
const ossStore = useOssStore()
const { visible, file, kind, objectUrl, textContent, loading, error, truncated, totalBytes } = storeToRefs(ossStore)
function onClose(): void {
ossStore.closePreview()
}
function onDownload(): void {
const target = file.value
if (!target?.url) return
const link = document.createElement('a')
link.href = target.url
link.download = target.name
link.target = '_blank'
link.rel = 'noopener'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="preview-mask" @click.self="onClose">
<div class="preview-dialog">
<header class="preview-dialog__head">
<span class="preview-dialog__name" :title="file?.name">{{ file?.name }}</span>
<button class="preview-dialog__close" type="button" aria-label="关闭" @click="onClose">
×
</button>
</header>
<div class="preview-dialog__body">
<div v-if="loading" class="preview-dialog__center">
预览加载中…
</div>
<div v-else-if="error" class="preview-dialog__center">
<p class="preview-dialog__msg">{{ error }}</p>
<AppButton v-if="file?.url" type="primary" @click="onDownload">
下载文件
</AppButton>
</div>
<div v-else-if="kind === 'unsupported'" class="preview-dialog__center">
<p class="preview-dialog__msg">
当前格式({{ (file?.name ?? '').split('.').pop() }})不支持在线预览,请下载后查看。
</p>
<AppButton v-if="file?.url" type="primary" @click="onDownload">
下载文件
</AppButton>
</div>
<img
v-else-if="kind === 'image' && objectUrl"
:src="objectUrl"
class="preview-dialog__image"
alt="预览"
>
<iframe
v-else-if="kind === 'pdf' && objectUrl"
:src="objectUrl"
class="preview-dialog__frame"
title="预览"
/>
<video
v-else-if="kind === 'video' && objectUrl"
:src="objectUrl"
class="preview-dialog__media"
controls
/>
<audio
v-else-if="kind === 'audio' && objectUrl"
:src="objectUrl"
class="preview-dialog__media"
controls
/>
<div v-else-if="kind === 'text'" class="preview-dialog__text-wrap">
<p v-if="truncated" class="preview-dialog__hint">
<span>文件较大({{ formatBytes(totalBytes ?? 0) }}),仅预览前 {{ formatBytes(PREVIEW_TEXT_MAX_BYTES) }},完整内容请下载。</span>
<AppButton v-if="file?.url" type="primary" @click="onDownload">下载文件</AppButton>
</p>
<pre class="preview-dialog__text">{{ textContent }}</pre>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.preview-mask {
position: fixed;
inset: 0;
z-index: 2000;
.flex(row, center);
background: rgba(0, 0, 0, 0.55);
padding: @spacing-lg;
overflow: auto;
}
.preview-dialog {
width: min(960px, 100%);
max-height: calc(100vh - @spacing-xxl);
.flex(column, flex-start, stretch);
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
overflow: hidden;
&__head {
.flex(row, space-between, center);
flex-shrink: 0;
gap: @spacing-sm;
padding: @spacing-base @spacing-lg;
border-bottom: 1px solid @border-color;
}
&__name {
min-width: 0;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__close {
flex-shrink: 0;
width: 28px;
height: 28px;
border: none;
border-radius: @radius-base;
background: transparent;
color: @text-color-secondary;
font-size: 20px;
line-height: 1;
cursor: pointer;
transition: background 0.2s, color 0.2s;
&:hover {
background: @bg-color-light;
color: @text-color;
}
}
&__body {
flex: 1;
min-height: 0;
.flex(column, flex-start, stretch);
padding: @spacing-base;
overflow: auto;
}
&__center {
.flex(column, center, center);
gap: @spacing-base;
width: 100%;
min-height: 240px;
}
&__msg {
font-size: @font-size-base;
color: @text-color-secondary;
text-align: center;
}
&__image {
max-width: 100%;
max-height: 70vh;
margin: auto;
object-fit: contain;
border-radius: @radius-base;
}
&__frame {
flex: 1;
width: 100%;
min-height: 60vh;
border: none;
}
&__media {
width: 100%;
max-height: 70vh;
margin: auto;
}
&__text-wrap {
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
}
&__hint {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: @spacing-sm;
flex-shrink: 0;
margin: 0 0 @spacing-sm;
padding: @spacing-sm @spacing-base;
border-radius: @radius-base;
background: #fff7e6;
border: 1px solid #ffd591;
color: #ad6800;
font-size: @font-size-sm;
line-height: 1.6;
}
&__text {
width: 100%;
margin: 0;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
color: @text-color;
font-size: @font-size-sm;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 70vh;
overflow: auto;
}
}
</style>

View File

@@ -71,49 +71,54 @@ const emit = defineEmits<{
<style lang="less" scoped> <style lang="less" scoped>
.filter-panel { .filter-panel {
padding: @spacing-base @spacing-lg; padding: @spacing-lg;
border-radius: @radius-lg; border-radius: @radius-base;
background: @white; background: @white;
box-shadow: @shadow-base;
&__bar { &__bar {
.flex(row, flex-start); .flex(row, flex-start, center);
gap: @spacing-sm; gap: @spacing-base;
} }
&__input { &__input {
width: 240px; flex: 1;
height: 34px; max-width: 360px;
margin-right: 0;
height: 36px;
padding: 0 @spacing-base; padding: 0 @spacing-base;
border: 1px solid @border-color; border: 1px solid @border-color;
border-radius: @radius-base; border-radius: @radius-round;
background: @bg-color-light; background: @input-bg;
color: @text-color;
font-size: @font-size-base; font-size: @font-size-base;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s, background-color 0.2s;
&::placeholder {
color: @text-color-disabled;
}
&:focus { &:focus {
border-color: @primary-color; border-color: @primary-color;
background: @white;
} }
} }
&__btn { &__btn {
height: 34px; min-width: 72px;
padding: 0 @spacing-base; height: 36px;
border: 1px solid @border-color-dark; padding: 0 @spacing-lg;
border-radius: @radius-base; border-radius: @radius-round;
background: @white; background: @pill-bg;
color: @text-color; color: @text-color;
font-size: @font-size-base; font-size: @font-size-base;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
border-color: @primary-color;
color: @primary-color; color: @primary-color;
} }
&--primary { &--primary {
border-color: @primary-color;
background: @primary-color; background: @primary-color;
color: @white; color: @white;
@@ -129,27 +134,34 @@ const emit = defineEmits<{
flex-wrap: wrap; flex-wrap: wrap;
gap: @spacing-sm; gap: @spacing-sm;
margin-top: @spacing-base; margin-top: @spacing-base;
font-size: @font-size-sm; padding: @spacing-base;
border-radius: @radius-base;
background: @chips-panel-bg;
font-size: @font-size-base;
}
&__chips-label {
color: @text-color;
font-weight: 500;
} }
&__chip { &__chip {
height: 26px; height: 28px;
padding: 0 @spacing-sm; padding: 0 @spacing-sm;
border: 1px solid @primary-light;
border-radius: @radius-round; border-radius: @radius-round;
background: @primary-light; background: @white;
color: @primary-color; color: @primary-color;
font-size: @font-size-sm; font-size: @font-size-sm;
font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
border-color: @primary-color; color: @primary-hover;
} }
&--clear { &--clear {
border-color: @border-color; background: transparent;
background: @white; color: @primary-strong;
color: @text-color-secondary;
} }
} }

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
interface Props {
title: string
slogan?: string
}
defineProps<Props>()
</script>
<template>
<header class="page-head">
<div class="container">
<h1 class="page-head__title">
{{ title }}
</h1>
<p v-if="slogan" class="page-head__slogan">
{{ slogan }}
</p>
</div>
</header>
</template>
<style lang="less" scoped>
.page-head {
background: @white;
padding: @spacing-lg 0;
border-bottom: 1px solid @border-color;
&__title {
padding-top: 0;
font-size: @font-size-title;
font-weight: 600;
line-height: 1.3;
color: @heading-color;
}
&__slogan {
text-align: left;
padding-bottom: 0;
margin-top: @spacing-sm;
font-size: @font-size-base;
font-weight: 400;
line-height: 1.5;
color: @text-color-secondary;
}
}
</style>

View File

@@ -13,7 +13,6 @@ const emit = defineEmits<{ pageChange: [page: number] }>()
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize))) const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
/** 页码序列:≤7 全部展示,否则首尾 + 当前页窗口(… 省略) */
const pages = computed<Array<number | '...'>>(() => { const pages = computed<Array<number | '...'>>(() => {
const n = pageCount.value const n = pageCount.value
const { page } = props const { page } = props
@@ -72,25 +71,26 @@ function go(page: number): void {
&__btn { &__btn {
.flex(row, center); .flex(row, center);
min-width: 32px; min-width: 28px;
height: 32px; height: 28px;
padding: 0 @spacing-xs; padding: 0 6px;
border: 1px solid @border-color; border: 1px solid @border-color;
border-radius: @radius-sm; border-radius: @radius-sm;
background: @white; background: @white;
color: @text-color; color: @text-color;
font-size: @font-size-base; font-size: @font-size-sm;
transition: all 0.2s; transition: all 0.2s;
&:hover:not(:disabled):not(.is-ellipsis) { &:hover:not(:disabled):not(.is-ellipsis) {
border-color: @primary-color; border-color: @primary-strong;
color: @primary-color; color: @primary-strong;
} }
&.is-active { &.is-active {
border-color: @primary-color; border-color: @primary-strong;
background: @primary-color; background: @primary-light;
color: @white; color: @primary-strong;
font-weight: 500;
} }
&.is-ellipsis { &.is-ellipsis {

View File

@@ -4,9 +4,11 @@ import { getPasswordLevel } from '@/utils/validate'
interface Props { interface Props {
password: string password: string
/** 是否显示右侧「密码强度:X」文字(登录/注册页显示,个人中心设计稿不显示) */
showLabel?: boolean
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), { showLabel: true })
const level = computed(() => getPasswordLevel(props.password)) const level = computed(() => getPasswordLevel(props.password))
@@ -19,17 +21,17 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
</script> </script>
<template> <template>
<div v-if="password" class="pwd-strength"> <div class="pwd-strength">
<div class="pwd-strength__bars"> <div class="pwd-strength__bars">
<span <span
v-for="i in 3" v-for="i in 3"
:key="i" :key="i"
class="pwd-strength__bar" class="pwd-strength__bar"
:class="{ 'is-on': i <= levelIndex, [`is-${level}`]: i <= levelIndex }" :class="{ 'is-on': password && i <= levelIndex, [`is-${level}`]: password && i <= levelIndex }"
/> />
</div> </div>
<span class="pwd-strength__label" :class="`is-${level}`">密码强度:{{ levelText }}</span> <span v-if="showLabel" class="pwd-strength__label" :class="`is-${level}`">{{ password ? `密码强度:${levelText}` : '' }}</span>
<p v-if="level === 'weak'" class="pwd-strength__tip"> <p v-if="password && level === 'weak'" class="pwd-strength__tip">
密码等级过低,请重新输入(建议字母+数字+符号组合) 密码等级过低,请重新输入(建议字母+数字+符号组合)
</p> </p>
</div> </div>
@@ -37,19 +39,25 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
<style lang="less" scoped> <style lang="less" scoped>
.pwd-strength { .pwd-strength {
.flex(column, flex-start); display: grid;
gap: @spacing-xs; grid-template-columns: 1fr auto;
grid-template-rows: 15px auto;
column-gap: @spacing-sm;
row-gap: 3px;
align-items: center;
min-height: 33px;
box-sizing: border-box;
&__bars { &__bars {
.flex(row, center); .flex(row, center);
gap: 4px; gap: 8px;
} }
&__bar { &__bar {
width: 56px; flex: 1;
height: 4px; height: 5px;
border-radius: @radius-round; border-radius: 2px;
background: @border-color; background: @auth-track;
&.is-on.is-weak { &.is-on.is-weak {
background: @danger-color; background: @danger-color;
@@ -65,23 +73,17 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
} }
&__label { &__label {
font-size: @font-size-sm; justify-self: end;
font-size: 11px;
&.is-weak { line-height: 15px;
color: @danger-color; color: @text-color-disabled;
}
&.is-medium {
color: @warning-color;
}
&.is-strong {
color: @success-color;
}
} }
&__tip { &__tip {
font-size: @font-size-sm; grid-column: 1 / -1;
margin: 0;
font-size: 11px;
line-height: 15px;
color: @danger-color; color: @danger-color;
} }
} }

View File

@@ -3,7 +3,6 @@ import { computed, onMounted, ref } from 'vue'
import { useCountDown } from '@/composables/useCountDown' import { useCountDown } from '@/composables/useCountDown'
interface Props { interface Props {
/** 二维码有效期(秒),默认 4:32 */
expiresIn?: number expiresIn?: number
size?: number size?: number
} }
@@ -57,7 +56,7 @@ onMounted(() => {
</div> </div>
<p class="qr-panel__status text-secondary"> <p class="qr-panel__status text-secondary">
<template v-if="!expired"> <template v-if="!expired">
二维码将在 <strong class="qr-panel__count">{{ countdownText }}</strong> 后失效(过期可点击二维码刷新) <span class="qr-panel__countdown">二维码将在 <strong class="qr-panel__count">{{ countdownText }}</strong> 后失效</span>(过期可点击二维码刷新)
</template> </template>
<template v-else> <template v-else>
二维码已过期,请点击刷新 二维码已过期,请点击刷新
@@ -69,23 +68,19 @@ onMounted(() => {
<style lang="less" scoped> <style lang="less" scoped>
.qr-panel { .qr-panel {
.flex(column, center); .flex(column, center);
gap: @spacing-sm; gap: 17px;
&__box { &__box {
.flex(column, center); .flex(column, center);
gap: @spacing-xs; gap: @spacing-xs;
border: 1px solid @border-color; border: none;
border-radius: @radius-base; border-radius: @radius-sm;
background: @white; background: @contract-qr-bg;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s; transition: background 0.2s;
&:hover {
border-color: @primary-color;
}
&.is-expired { &.is-expired {
background: @bg-color; background: @bg-color-light;
color: @text-color-secondary; color: @text-color-secondary;
} }
} }
@@ -98,12 +93,27 @@ onMounted(() => {
&__tip { &__tip {
font-size: @font-size-sm; font-size: @font-size-sm;
color: @text-color-secondary; color: @text-color;
text-align: center; text-align: center;
} }
&__status { &__status {
font-size: @font-size-sm; display: flex;
flex-direction: column;
align-items: center;
gap: @spacing-sm;
font-size: 11px;
color: @text-color-disabled;
}
&__countdown {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: @radius-round;
background: fade(@danger-color, 8%);
color: @danger-color;
line-height: 1.4;
} }
&__count { &__count {

View File

@@ -21,12 +21,8 @@ function onGetCode(): void {
class="sms-code__input" class="sms-code__input"
type="text" type="text"
placeholder="请输入验证码" placeholder="请输入验证码"
> />
<button <button class="sms-code__btn" :disabled="isCounting" @click="onGetCode">
class="sms-code__btn"
:disabled="isCounting"
@click="onGetCode"
>
{{ isCounting ? `${seconds}秒后重新获取` : '获取验证码' }} {{ isCounting ? `${seconds}秒后重新获取` : '获取验证码' }}
</button> </button>
</div> </div>
@@ -36,42 +32,57 @@ function onGetCode(): void {
.sms-code { .sms-code {
.flex(row, stretch); .flex(row, stretch);
gap: @spacing-sm; gap: @spacing-sm;
width: 100%;
&__input { &__input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 40px; height: 44px;
padding: 0 @spacing-base; padding: 0 @spacing-base;
border: 1px solid @border-color; border: 1px solid @border-color;
border-radius: @radius-base; border-radius: @radius-base;
background: @bg-color-light; background: @bg-color-light;
font-size: @font-size-base; font-size: @font-size-base;
color: @heading-color;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
&:focus { &:focus {
border-color: @primary-color; border-color: @primary-strong;
background: @white;
}
&::placeholder {
color: @text-color-secondary;
} }
} }
&__btn { &__btn {
flex-shrink: 0; flex-shrink: 0;
width: 132px; min-width: 120px;
height: 40px; height: 44px;
border: 1px solid @primary-color; padding: 0 @spacing-base;
border: 1px solid @primary-strong;
border-radius: @radius-base; border-radius: @radius-base;
background: @white; background: @white;
color: @primary-color; color: @primary-strong;
font-size: @font-size-sm; font-size: @font-size-sm;
font-weight: 500;
white-space: nowrap;
text-align: center;
cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: @primary-light; background: @primary-strong;
color: @white;
} }
&:disabled { &:disabled {
border-color: @border-color-dark; border-color: @border-color;
color: @text-color-disabled; background: @bg-color-light;
color: @text-color-secondary;
cursor: not-allowed; cursor: not-allowed;
} }
} }

View File

@@ -16,10 +16,10 @@ withDefaults(defineProps<Props>(), { variant: 'info' })
<style lang="less" scoped> <style lang="less" scoped>
.status-tag { .status-tag {
display: inline-block; display: inline-block;
padding: 2px @spacing-sm; padding: 3px 10px;
border-radius: @radius-sm; border-radius: @radius-sm;
font-size: @font-size-sm; font-size: 11px;
line-height: 1.5; line-height: 16px;
white-space: nowrap; white-space: nowrap;
&--primary { &--primary {
@@ -28,13 +28,13 @@ withDefaults(defineProps<Props>(), { variant: 'info' })
} }
&--success { &--success {
background: fade(@success-color, 12%); background: @tag-success-bg;
color: @success-color; color: @success-color;
} }
&--warning { &--warning {
background: fade(@warning-color, 12%); background: @tag-warning-bg;
color: @warning-color; color: @tag-warning-text;
} }
&--danger { &--danger {
@@ -43,7 +43,7 @@ withDefaults(defineProps<Props>(), { variant: 'info' })
} }
&--info { &--info {
background: @bg-color; background: @tag-info-bg;
color: @text-color-secondary; color: @text-color-secondary;
} }
} }

View File

@@ -30,57 +30,48 @@ defineProps<Props>()
<style lang="less" scoped> <style lang="less" scoped>
.step-form { .step-form {
padding: @spacing-lg @spacing-xl; padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg; border-radius: @radius-base;
background: @white; background: @white;
box-shadow: @shadow-base; box-shadow: none;
&__header { &__header {
.flex(row, center); .flex(column, flex-start, baseline);
gap: @spacing-sm; gap: @spacing-base;
margin-bottom: @spacing-lg; margin-bottom: @spacing-lg;
padding-bottom: @spacing-base; padding-bottom: 0;
border-bottom: 1px solid @border-color; border-bottom: none;
} }
&__step { &__step {
.flex(row, center); .flex(row, baseline);
gap: @spacing-xs; 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 { &__dot {
.flex(row, center); flex-shrink: 0;
width: 24px; width: 46px;
height: 24px; border: none;
border: 1px solid @border-color-dark; border-radius: 0;
border-radius: @radius-round; background: transparent;
font-size: @font-size-sm; font-size: 37px;
color: @text-color-secondary; font-weight: 500;
line-height: 1;
color: @demand-accent;
text-align: left;
} }
&__label { &__label {
font-size: @font-size-base; font-size: @font-size-title;
color: @text-color; font-weight: 500;
color: @demand-heading;
} }
&__line { &__line {
flex: 1; display: none;
height: 1px; }
background: @border-color;
&__body {
margin-left: 46px;
} }
} }
</style> </style>

View File

@@ -66,17 +66,17 @@ function onDrop(event: DragEvent): void {
.upload-area { .upload-area {
.flex(column, center); .flex(column, center);
gap: @spacing-xs; gap: @spacing-xs;
padding: @spacing-lg; min-height: 69px;
border: 1px dashed @border-color-dark; padding: @spacing-base;
border-radius: @radius-base; border: none;
background: @bg-color-light; border-radius: @radius-sm;
background: @demand-upload-bg;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s, background 0.2s; transition: background 0.2s;
&:hover, &:hover,
&.is-dragging { &.is-dragging {
border-color: @primary-color; background: darken(@demand-upload-bg, 5%);
background: @primary-light;
} }
&.is-disabled { &.is-disabled {
@@ -85,8 +85,8 @@ function onDrop(event: DragEvent): void {
} }
&__icon { &__icon {
font-size: @font-size-xl; font-size: @font-size-lg;
color: @primary-color; color: @text-color-disabled;
} }
&__hint { &__hint {

View File

@@ -1,6 +1,7 @@
export { default as AppHeader } from './AppHeader.vue' export { default as AppHeader } from './AppHeader.vue'
export { default as AppFooter } from './AppFooter.vue' export { default as AppFooter } from './AppFooter.vue'
export { default as AppButton } from './AppButton.vue' export { default as AppButton } from './AppButton.vue'
export { default as PageHead } from './PageHead.vue'
export { default as CaseCard } from './CaseCard.vue' export { default as CaseCard } from './CaseCard.vue'
export { default as FilterPanel } from './FilterPanel.vue' export { default as FilterPanel } from './FilterPanel.vue'
export type { FilterChip } from './FilterPanel.vue' export type { FilterChip } from './FilterPanel.vue'
@@ -8,8 +9,13 @@ export { default as PaginationBar } from './PaginationBar.vue'
export { default as StatusTag } from './StatusTag.vue' export { default as StatusTag } from './StatusTag.vue'
export { default as FileList } from './FileList.vue' export { default as FileList } from './FileList.vue'
export { default as UploadArea } from './UploadArea.vue' export { default as UploadArea } from './UploadArea.vue'
export { default as FilePreviewDialog } from './FilePreviewDialog.vue'
export { default as StepForm } from './StepForm.vue' export { default as StepForm } from './StepForm.vue'
export { default as SmsCodeInput } from './SmsCodeInput.vue' export { default as SmsCodeInput } from './SmsCodeInput.vue'
export { default as PasswordStrength } from './PasswordStrength.vue' export { default as PasswordStrength } from './PasswordStrength.vue'
export { default as ConfirmDialog } from './ConfirmDialog.vue' export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as QrCodePanel } from './QrCodePanel.vue' export { default as QrCodePanel } from './QrCodePanel.vue'
export { default as CasesFilter } from './CasesFilter.vue'
export { default as CasesSearchBar } from './CasesSearchBar.vue'
export { default as CasesGrid } from './CasesGrid.vue'
export { default as EllipsisText } from './EllipsisText.vue'

View File

@@ -1,6 +1,5 @@
import { computed, onScopeDispose, ref } from 'vue' import { computed, onScopeDispose, ref } from 'vue'
/** 倒计时(验证码 60s、二维码失效 4:32 等通用) */
export function useCountDown(defaultSeconds = 60) { export function useCountDown(defaultSeconds = 60) {
const seconds = ref(0) const seconds = ref(0)
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null

View File

@@ -1,6 +1,5 @@
import { onMounted, onUnmounted, ref, type Ref } from 'vue' import { onMounted, onUnmounted, ref, type Ref } from 'vue'
/** 元素可见性(配合图片懒加载/曝光统计) */
export function useIntersection( export function useIntersection(
target: Ref<Element | null | undefined>, target: Ref<Element | null | undefined>,
options?: IntersectionObserverInit, options?: IntersectionObserverInit,

View File

@@ -1,6 +1,5 @@
import { ref, type Ref } from 'vue' import { ref, type Ref } from 'vue'
/** 请求 loading 状态管理 */
export function useLoading(initial = false): { export function useLoading(initial = false): {
loading: Ref<boolean> loading: Ref<boolean>
withLoading: <T>(fn: () => Promise<T>) => Promise<T> withLoading: <T>(fn: () => Promise<T>) => Promise<T>

View File

@@ -1,6 +1,5 @@
import { computed, onMounted, onUnmounted, ref, type ComputedRef, type Ref } from 'vue' import { computed, onMounted, onUnmounted, ref, type ComputedRef, type Ref } from 'vue'
/** 滚动监听/吸顶/回到顶部 */
export function useScroll(threshold = 200): { export function useScroll(threshold = 200): {
scrollY: Ref<number> scrollY: Ref<number>
isAtTop: ComputedRef<boolean> isAtTop: ComputedRef<boolean>

View File

@@ -13,7 +13,6 @@ function normalize(value: DebounceValue): DebounceBinding {
return typeof value === 'function' ? { handler: value } : value return typeof value === 'function' ? { handler: value } : value
} }
/** v-debounce 防抖点击:v-debounce="fn" 或 v-debounce="{ handler: fn, delay: 500 }" */
const debounce: Directive<Element, DebounceValue> = { const debounce: Directive<Element, DebounceValue> = {
mounted(el, binding) { mounted(el, binding) {
const { handler, delay = 300 } = normalize(binding.value) const { handler, delay = 300 } = normalize(binding.value)

View File

@@ -3,7 +3,6 @@ import lazy from './lazy'
import debounce from './debounce' import debounce from './debounce'
import throttle from './throttle' import throttle from './throttle'
/** main.ts 调用:统一注册自定义指令 */
export function setupDirectives(app: App): void { export function setupDirectives(app: App): void {
app app
.directive('lazy', lazy) .directive('lazy', lazy)

View File

@@ -2,7 +2,6 @@ import type { Directive } from 'vue'
const observerMap = new WeakMap<Element, IntersectionObserver>() const observerMap = new WeakMap<Element, IntersectionObserver>()
/** v-lazy 图片懒加载:v-lazy="图片地址" */
const lazy: Directive<Element, string> = { const lazy: Directive<Element, string> = {
mounted(el, binding) { mounted(el, binding) {
if (!(el instanceof HTMLImageElement)) return if (!(el instanceof HTMLImageElement)) return

View File

@@ -13,7 +13,6 @@ function normalize(value: ThrottleValue): ThrottleBinding {
return typeof value === 'function' ? { handler: value } : value return typeof value === 'function' ? { handler: value } : value
} }
/** v-throttle 节流点击:v-throttle="fn" 或 v-throttle="{ handler: fn, interval: 500 }" */
const throttle: Directive<Element, ThrottleValue> = { const throttle: Directive<Element, ThrottleValue> = {
mounted(el, binding) { mounted(el, binding) {
const { handler, interval = 500 } = normalize(binding.value) const { handler, interval = 500 } = normalize(binding.value)

1
src/env.d.ts vendored
View File

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

View File

@@ -1,58 +0,0 @@
<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>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { AppFooter, AppHeader } from '@/components' import { useRoute } from 'vue-router'
import FloatingActions from './components/FloatingActions.vue' import { computed } from 'vue'
import { AppFooter, AppHeader, FilePreviewDialog } from '@/components'
const route = useRoute()
const showFooter = computed(() => !route.meta.noFooter)
</script> </script>
<template> <template>
@@ -9,15 +13,18 @@ import FloatingActions from './components/FloatingActions.vue'
<main class="layout__main"> <main class="layout__main">
<router-view /> <router-view />
</main> </main>
<AppFooter /> <AppFooter v-if="showFooter" />
<FloatingActions /> <FilePreviewDialog />
</div> </div>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.layout { .layout {
.layout__main { .flex(column, flex-start, stretch);
min-height: calc(100vh - @header-height - @footer-height); min-height: 100vh;
&__main {
flex: 1;
} }
} }
</style> </style>

View File

@@ -1,9 +1,15 @@
import type { Router } from 'vue-router' import type { Router } from 'vue-router'
import { storage } from '@/utils/storage' import { storage } from '@/utils/storage'
/** 路由守卫:登录态校验 + document.title */
export function setupGuard(router: Router): void { export function setupGuard(router: Router): void {
router.beforeEach((to) => { router.beforeEach((to) => {
if (
import.meta.env.DEV
&& new URLSearchParams(window.location.search).has('visual')
) {
return true
}
if (to.meta.requiresAuth && !storage.get<string>('token')) { if (to.meta.requiresAuth && !storage.get<string>('token')) {
return { path: '/login', query: { redirect: to.fullPath } } return { path: '/login', query: { redirect: to.fullPath } }
} }

View File

@@ -1,40 +1,34 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
/**
* 公共布局(AppHeader + router-view + AppFooter + FloatingActions)下的路由
* 完整规划见 docs/page-architecture.md 第 2 节
* M3–M10 视图由子代理并行实现中,交付后本表即为终态
*/
const layoutRoutes: RouteRecordRaw = { const layoutRoutes: RouteRecordRaw = {
path: '/', path: '/',
component: () => import('@/layout/index.vue'), component: () => import('@/layout/index.vue'),
children: [ children: [
// ── 公开 ── { path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页', footer: true } },
{ path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页' } },
{ path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), 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', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), 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: 'favorites', name: 'Favorites', component: () => import('@/views/profile/favorites.vue'), meta: { title: '收藏列表', requiresAuth: true } },
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true, noFooter: true } },
// ── 登录区 ──
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } }, { 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', 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: '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', name: 'Contract', component: () => import('@/views/contract'), meta: { title: '合同中心', requiresAuth: true, noFooter: true } },
{ path: 'contract/:id', name: 'ContractDetail', component: () => import('@/views/contract/detail.vue'), 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: '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', 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', 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', 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', 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/bills/:id/pay/result', name: 'PayResult', component: () => import('@/views/finance/pay-result.vue'), meta: { title: '支付成功', requiresAuth: true } },
{ path: 'finance/checkout', name: 'Checkout', component: () => import('@/views/finance/checkout.vue'), meta: { title: '确认支付', requiresAuth: true } },
{ path: 'finance/invoices', name: 'Invoices', component: () => import('@/views/finance/invoices.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: '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', 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: '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: 'service', name: 'Service', component: () => import('@/views/service'), meta: { title: '在线客服', requiresAuth: true } },
// ── 个人中心(左菜单嵌套)──
{ {
path: 'profile', path: 'profile',
component: () => import('@/views/profile'), component: () => import('@/views/profile'),
@@ -42,8 +36,11 @@ const layoutRoutes: RouteRecordRaw = {
children: [ children: [
{ path: '', redirect: '/profile/security' }, { path: '', redirect: '/profile/security' },
{ path: 'security', name: 'ProfileSecurity', component: () => import('@/views/profile/security.vue'), meta: { title: '账号安全', requiresAuth: true } }, { path: 'security', name: 'ProfileSecurity', component: () => import('@/views/profile/security.vue'), meta: { title: '账号安全', requiresAuth: true } },
{ path: 'security/change-password', name: 'ProfileChangePassword', component: () => import('@/views/profile/change-password.vue'), meta: { title: '修改密码', requiresAuth: true } },
{ path: 'security/bind-phone', name: 'ProfileBindPhone', component: () => import('@/views/profile/bind-phone.vue'), meta: { title: '绑定手机号', requiresAuth: true } },
{ path: 'security/bind-email', name: 'ProfileBindEmail', component: () => import('@/views/profile/bind-email.vue'), meta: { title: '绑定邮箱', requiresAuth: true } },
{ path: 'security/delete-account', name: 'ProfileDeleteAccount', component: () => import('@/views/profile/delete-account.vue'), meta: { title: '注销账号', requiresAuth: true } },
{ path: 'address', name: 'ProfileAddress', component: () => import('@/views/profile/address.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: '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: '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: 'help', name: 'ProfileHelp', component: () => import('@/views/profile/help.vue'), meta: { title: '帮助中心', requiresAuth: true } },
@@ -54,7 +51,6 @@ const layoutRoutes: RouteRecordRaw = {
], ],
} }
/** 无全局布局的路由(登录注册独立品牌页 + 404) */
const plainRoutes: RouteRecordRaw[] = [ const plainRoutes: RouteRecordRaw[] = [
{ path: '/login', name: 'Login', component: () => import('@/views/login'), meta: { title: '登录' } }, { path: '/login', name: 'Login', component: () => import('@/views/login'), meta: { title: '登录' } },
{ path: '/register', name: 'Register', component: () => import('@/views/login/register.vue'), meta: { title: '注册' } }, { path: '/register', name: 'Register', component: () => import('@/views/login/register.vue'), meta: { title: '注册' } },

View File

@@ -1,50 +1,147 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import type { BusinessType, PageResult } from '@/types/api' import type {
import type { Case, CaseQuery } from '@/types/business/cases' BusinessIndustryOption,
import { getCaseCounts, getCaseDetail, getCaseList, toggleFavorite as toggleFavoriteApi } from '@/api/cases' Case,
CaseQuery,
CaseTypeOption,
ProjectCaseStatisticsVo,
ProjectCaseUserBo,
ScaleLimit,
} from '@/types/business/cases'
import { collectCase, getCaseCategories, getCaseDetail, getCaseList } from '@/api/cases'
import { toCase } from '@/utils/caseVo'
import type { CaseTypeNameMap } from '@/utils/caseVo'
const PAGE_SIZE = 9
const PROBE_DELAY = 300
function createDefaultQuery(): CaseQuery { function createDefaultQuery(): CaseQuery {
return { return {
category: 'all', categories: [],
industries: [], industries: [],
scales: [], scales: [],
dateRange: null, dateRange: null,
favoriteOnly: false,
keyword: '', keyword: '',
page: 1, page: 1,
pageSize: 8, pageSize: PAGE_SIZE,
} }
} }
/** 案例库数据(列表 / 筛选 / 分页 / 收藏) */ function withTime(date: string, time: string): string {
return date.length === 10 ? `${date} ${time}` : date
}
function trimmed(raw: string | null | undefined): string {
return (raw ?? '').trim()
}
function toTypeOptions(stats: ProjectCaseStatisticsVo | null): CaseTypeOption[] {
return (stats?.projectCaseCounts ?? [])
.map((item) => ({
value: String(item.id ?? '').trim(),
label: trimmed(item.businessType),
count: item.caseCount ?? 0,
}))
.filter((item) => item.value.length > 0 && item.label.length > 0)
}
function toTypeNameMap(stats: ProjectCaseStatisticsVo | null): CaseTypeNameMap {
const map: CaseTypeNameMap = {}
for (const item of stats?.projectCaseCounts ?? []) {
const id = String(item.id ?? '').trim()
const name = trimmed(item.businessType)
if (id.length > 0 && name.length > 0) map[id] = name
}
return map
}
export const useCasesStore = defineStore('cases', () => { export const useCasesStore = defineStore('cases', () => {
const query = ref<CaseQuery>(createDefaultQuery()) const query = ref<CaseQuery>(createDefaultQuery())
const list = ref<Case[]>([]) const list = ref<Case[]>([])
const total = ref(0) const total = ref(0)
const counts = ref<Partial<Record<BusinessType, number>>>({}) const previewTotal = ref(0)
const typeOptions = ref<CaseTypeOption[]>([])
const typeNameById = ref<CaseTypeNameMap>({})
const industryOptions = ref<BusinessIndustryOption[]>([])
const scaleOptions = ref<ScaleLimit[]>([])
const caseTotal = ref(0)
const currentCase = ref<Case | null>(null) const currentCase = ref<Case | null>(null)
async function fetchCases(): Promise<void> { let probeTimer: ReturnType<typeof setTimeout> | null = null
const result: PageResult<Case> = await getCaseList(query.value)
list.value = result.list function buildQuery(input: CaseQuery): {
total.value = result.total bo: ProjectCaseUserBo
pageNum: number
pageSize: number
} {
const bo: ProjectCaseUserBo = {}
if (input.categories.length > 0) {
bo.businessTypes = [...input.categories]
}
if (input.industries.length > 0) {
bo.businessIndustries = [...input.industries]
}
if (input.scales.length > 0) {
bo.scales = input.scales.map((item) => ({ ...item }))
}
if (input.dateRange) {
bo.beginTime = withTime(input.dateRange[0], '00:00:00')
bo.endTime = withTime(input.dateRange[1], '23:59:59')
}
if (input.favoriteOnly) bo.isFavorite = 1
const keyword = input.keyword.trim()
if (keyword) bo.key = keyword
return { bo, pageNum: input.page, pageSize: input.pageSize }
} }
async function fetchCounts(): Promise<void> { async function fetchCategories(): Promise<void> {
counts.value = await getCaseCounts() const result = await getCaseCategories()
const stats = result.projectCaseStatisticsVo
typeOptions.value = toTypeOptions(stats)
typeNameById.value = toTypeNameMap(stats)
caseTotal.value = stats?.total ?? 0
industryOptions.value = (result.businessIndustry ?? []).filter((item) => trimmed(item.name).length > 0)
scaleOptions.value = result.upperAndLowerLimits ?? []
}
async function ensureCategories(): Promise<void> {
if (typeOptions.value.length === 0) await fetchCategories()
}
async function fetchCases(): Promise<void> {
const { bo, pageNum, pageSize } = buildQuery(query.value)
const result = await getCaseList(bo, pageNum, pageSize)
list.value = (result.projectCasePage?.rows ?? []).map((vo) => toCase(vo, typeNameById.value))
total.value = result.projectCasePage?.total ?? 0
previewTotal.value = total.value
}
async function probeTotal(): Promise<void> {
const { bo } = buildQuery(query.value)
const result = await getCaseList(bo, 1, 1)
previewTotal.value = result.projectCasePage?.total ?? 0
}
function scheduleProbe(): void {
if (probeTimer) clearTimeout(probeTimer)
probeTimer = setTimeout(() => void probeTotal(), PROBE_DELAY)
} }
function setQuery(next: CaseQuery): void { function setQuery(next: CaseQuery): void {
query.value = next query.value = next
scheduleProbe()
} }
/** 确定筛选(重置页码后取数) */
async function search(): Promise<void> { async function search(): Promise<void> {
query.value = { ...query.value, page: 1 } query.value = { ...query.value, page: 1 }
await fetchCases() await fetchCases()
} }
/** 重置全部条件 */
async function resetQuery(): Promise<void> { async function resetQuery(): Promise<void> {
query.value = createDefaultQuery() query.value = createDefaultQuery()
await fetchCases() await fetchCases()
@@ -55,29 +152,53 @@ export const useCasesStore = defineStore('cases', () => {
await fetchCases() await fetchCases()
} }
async function toggleFavoriteOnly(): Promise<void> {
query.value = { ...query.value, favoriteOnly: !query.value.favoriteOnly, page: 1 }
await fetchCases()
}
async function fetchCaseDetail(id: string): Promise<void> { async function fetchCaseDetail(id: string): Promise<void> {
currentCase.value = await getCaseDetail(id) const vo = await getCaseDetail(id)
currentCase.value = vo ? toCase(vo, typeNameById.value) : null
} }
async function toggleFavorite(id: string): Promise<void> { async function toggleFavorite(id: string): Promise<void> {
const updated = await toggleFavoriteApi(id) await collectCase(id)
const index = list.value.findIndex((item) => item.id === id) const index = list.value.findIndex((item) => item.id === id)
if (index >= 0) list.value[index] = updated if (index >= 0) {
if (currentCase.value?.id === id) currentCase.value = updated const target = list.value[index]
const delta = target.isFavorite ? -1 : 1
list.value[index] = { ...target, isFavorite: !target.isFavorite, favoriteCount: target.favoriteCount + delta }
}
if (currentCase.value?.id === id) {
const delta = currentCase.value.isFavorite ? -1 : 1
currentCase.value = {
...currentCase.value,
isFavorite: !currentCase.value.isFavorite,
favoriteCount: currentCase.value.favoriteCount + delta,
}
}
} }
return { return {
query, query,
list, list,
total, total,
counts, previewTotal,
typeOptions,
typeNameById,
industryOptions,
scaleOptions,
caseTotal,
currentCase, currentCase,
fetchCases, fetchCases,
fetchCounts, fetchCategories,
ensureCategories,
setQuery, setQuery,
search, search,
resetQuery, resetQuery,
changePage, changePage,
toggleFavoriteOnly,
fetchCaseDetail, fetchCaseDetail,
toggleFavorite, toggleFavorite,
} }

View File

@@ -17,7 +17,6 @@ function createDefaultQuery(): ContractQuery {
} }
} }
/** 合同中心数据(列表 / 筛选 / 分页 / 详情 / 签署二维码) */
export const useContractStore = defineStore('contract', () => { export const useContractStore = defineStore('contract', () => {
const query = ref<ContractQuery>(createDefaultQuery()) const query = ref<ContractQuery>(createDefaultQuery())
const list = ref<Contract[]>([]) const list = ref<Contract[]>([])
@@ -39,13 +38,11 @@ export const useContractStore = defineStore('contract', () => {
query.value = next query.value = next
} }
/** 确定筛选(重置页码后取数) */
async function search(): Promise<void> { async function search(): Promise<void> {
query.value = { ...query.value, page: 1 } query.value = { ...query.value, page: 1 }
await fetchList() await fetchList()
} }
/** 重置全部条件 */
async function resetQuery(): Promise<void> { async function resetQuery(): Promise<void> {
query.value = createDefaultQuery() query.value = createDefaultQuery()
await fetchList() await fetchList()
@@ -56,7 +53,6 @@ export const useContractStore = defineStore('contract', () => {
await fetchList() await fetchList()
} }
/** 获取/刷新签署二维码(refresh=true 时走刷新接口) */
async function fetchSignQr(id: string, refresh = false): Promise<void> { async function fetchSignQr(id: string, refresh = false): Promise<void> {
const result = refresh ? await refreshSignQr(id) : await getSignQr(id) const result = refresh ? await refreshSignQr(id) : await getSignQr(id)
signExpires.value = result.expiresInSeconds signExpires.value = result.expiresInSeconds

View File

@@ -1,60 +1,211 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { Demand } from '@/types/business/demand' import type {
import type { FileInfo } from '@/types/api' BizTypeOption,
Demand,
DemandAttachment,
DemandDraftVo,
DictOption,
} from '@/types/business/demand'
import {
getBizTypeDict,
getBudgetRangeDict,
getDraftDemand,
getOssUrls,
getSubmissionBizTypes,
resetDraft as resetDraftApi,
saveDraft as saveDraftApi,
submitDemand,
uploadDemandFile,
} from '@/api/demand'
import { getUserProfile } from '@/api/user'
import { toAssetUrl } from '@/utils/asset'
import { toBizTypeOption, toBizTypeOptionFromDict } from '@/utils/bizTypeMeta'
import { isManualBudget, resolveDraftBudget } from '@/utils/budgetMeta'
import { formatDate } from '@/utils/format' import { formatDate } from '@/utils/format'
import { getDraftDemand, saveDraft as saveDraftApi, submitDemand } from '@/api/demand'
function createDefaultForm(): Demand { function createDefaultForm(): Demand {
return { return {
businessType: 'internet', id: null,
bizType: '',
projectName: '', projectName: '',
attachments: [], requirementDesc: '',
coreDescription: '',
expectedDuration: '',
budgetRange: '', budgetRange: '',
userName: '王永志', budgetManual: false,
userPhone: '1866****9618', developmentCycle: '',
remark: '', supplementaryRemarks: '',
status: 'draft', userName: '',
userPhone: '',
attachmentInfo: [],
}
}
function sortDict(list: DictOption[] | null): DictOption[] {
return [...(list ?? [])].sort((a, b) => (a.dictSort ?? 0) - (b.dictSort ?? 0))
}
function mergeDraft(base: Demand, draft: DemandDraftVo, budgetDict: DictOption[]): Demand {
const budget = resolveDraftBudget(draft.budgetRange, budgetDict)
return {
id: draft.id ?? null,
bizType: draft.bizType || base.bizType,
projectName: draft.projectName || base.projectName,
requirementDesc: draft.requirementDesc || base.requirementDesc,
budgetRange: budget.value,
budgetManual: budget.manual,
developmentCycle: draft.developmentCycle ?? base.developmentCycle,
supplementaryRemarks: draft.supplementaryRemarks || base.supplementaryRemarks,
userName: draft.userName || base.userName,
userPhone: draft.userPhone || base.userPhone,
attachmentInfo: draft.attachmentInfo ?? [],
}
}
async function loadOssUrlMap(raws: (string | null)[]): Promise<Record<string, string>> {
const ids = [...new Set(
raws.map((item) => (item ?? '').trim()).filter((item) => item && !toAssetUrl(item)),
)]
if (ids.length === 0) return {}
try {
const list = await getOssUrls(ids)
return Object.fromEntries(list.map((item) => [String(item.ossId), item.url]))
}
catch {
return {}
} }
} }
/** 需求提报数据(M3:表单 / 步骤 / 提交与草稿) */
export const useDemandStore = defineStore('demand', () => { export const useDemandStore = defineStore('demand', () => {
const form = ref<Demand>(createDefaultForm()) const form = ref<Demand>(createDefaultForm())
const step = ref(1) const bizTypeOptions = ref<BizTypeOption[]>([])
const budgetDict = ref<DictOption[]>([])
const loading = ref(false) const loading = ref(false)
const saving = ref(false)
const submitting = ref(false)
const uploading = ref(false)
function setStep(next: number): void { const budgetOptions = computed<DictOption[]>(() => {
step.value = next const list = budgetDict.value
return [
...list.filter((item) => !isManualBudget(item.dictLabel)),
...list.filter((item) => isManualBudget(item.dictLabel)),
]
})
async function loadBizTypeOptions(): Promise<BizTypeOption[]> {
try {
const submissions = await getSubmissionBizTypes()
if (submissions.length > 0) {
const ossUrlById = await loadOssUrlMap(submissions.map((item) => item.logoUrl))
const options = submissions
.map((item) => toBizTypeOption(item, ossUrlById))
.filter((item): item is BizTypeOption => item !== null)
if (options.length > 0) return options
}
}
catch {
}
const dict = sortDict(await getBizTypeDict())
return dict.map((item) => toBizTypeOptionFromDict(item.dictValue, item.dictLabel))
} }
function patchForm(partial: Partial<Demand>): void { function patchForm(partial: Partial<Demand>): void {
form.value = { ...form.value, ...partial } form.value = { ...form.value, ...partial }
} }
/** File → FileInfo(mock:url 为空,uploadTime 取当前时间) */ async function init(): Promise<void> {
function addAttachment(file: File): void { loading.value = true
const info: FileInfo = { try {
name: file.name, const [bizTypes, budgetDictRes, profile, draft] = await Promise.all([
size: file.size, loadBizTypeOptions(),
uploadTime: formatDate(new Date()), getBudgetRangeDict(),
url: '', getUserProfile().catch(() => null),
getDraftDemand(),
])
bizTypeOptions.value = bizTypes
budgetDict.value = sortDict(budgetDictRes)
const base = createDefaultForm()
if (profile) {
base.userName = profile.nickName || profile.userName || ''
base.userPhone = profile.phoneNumber || ''
}
form.value = draft ? mergeDraft(base, draft, budgetDict.value) : base
}
catch (error) {
ElMessage.error(error instanceof Error ? error.message : '需求提报数据加载失败')
}
finally {
loading.value = false
} }
form.value.attachments = [...form.value.attachments, info]
} }
function removeAttachment(target: FileInfo): void { async function addAttachment(file: File): Promise<void> {
form.value.attachments = form.value.attachments.filter( if (uploading.value) return
(item) => !(item.name === target.name && item.uploadTime === target.uploadTime), uploading.value = true
try {
const oss = await uploadDemandFile(file)
const suffix = (oss.fileSuffix || file.name.split('.').pop() || '').replace(/^\./, '')
const item: DemandAttachment = {
ossId: oss.ossId,
originalName: oss.originalName || file.name,
url: oss.url,
fileSize: file.size,
type: suffix,
createTime: formatDate(new Date()),
}
form.value.attachmentInfo = [...form.value.attachmentInfo, item]
ElMessage.success(`「${item.originalName}」上传成功`)
}
catch (error) {
ElMessage.error(error instanceof Error ? error.message : '附件上传失败')
}
finally {
uploading.value = false
}
}
function removeAttachment(target: DemandAttachment): void {
form.value.attachmentInfo = form.value.attachmentInfo.filter(
(item) => !(item.ossId === target.ossId && item.id === target.id),
) )
} }
/** 提交需求:必填校验通过后调 api,成功返回 true(页面可据此跳转) */ async function saveDraft(): Promise<void> {
saving.value = true
try {
await saveDraftApi(form.value)
ElMessage.success('草稿已保存')
}
catch (error) {
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
}
finally {
saving.value = false
}
}
async function reset(): Promise<void> {
saving.value = true
try {
await resetDraftApi()
const base = createDefaultForm()
base.userName = form.value.userName
base.userPhone = form.value.userPhone
form.value = base
ElMessage.success('已重置')
}
catch (error) {
ElMessage.error(error instanceof Error ? error.message : '重置失败')
}
finally {
saving.value = false
}
}
async function submit(): Promise<boolean> { async function submit(): Promise<boolean> {
if (!form.value.businessType) { if (!form.value.bizType) {
ElMessage.warning('请选择业务类型') ElMessage.warning('请选择业务类型')
return false return false
} }
@@ -62,15 +213,22 @@ export const useDemandStore = defineStore('demand', () => {
ElMessage.warning('请填写项目名称') ElMessage.warning('请填写项目名称')
return false return false
} }
if (!form.value.coreDescription.trim()) { if (!form.value.requirementDesc.trim()) {
ElMessage.warning('请填写核心需求描述') ElMessage.warning('请填写核心需求描述')
return false return false
} }
loading.value = true if (form.value.budgetManual && !form.value.budgetRange.trim()) {
ElMessage.warning('请填写预算金额,或改选一个预设档位')
return false
}
submitting.value = true
try { try {
await submitDemand(form.value) await submitDemand(form.value)
form.value.status = 'submitted' const base = createDefaultForm()
ElMessage.success('提交后我们的专业顾问将在一个工作日内与您联系') base.userName = form.value.userName
base.userPhone = form.value.userPhone
form.value = base
ElMessage.success('提交成功,专业顾问将在一个工作日内与您联系')
return true return true
} }
catch (error) { catch (error) {
@@ -78,49 +236,24 @@ export const useDemandStore = defineStore('demand', () => {
return false return false
} }
finally { finally {
loading.value = false submitting.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 { return {
form, form,
step, bizTypeOptions,
budgetOptions,
loading, loading,
setStep, saving,
submitting,
uploading,
init,
patchForm, patchForm,
addAttachment, addAttachment,
removeAttachment, removeAttachment,
submit,
saveDraft, saveDraft,
loadDraft,
reset, reset,
submit,
} }
}) })

View File

@@ -10,20 +10,25 @@ import type {
InvoiceTitle, InvoiceTitle,
InvoiceTitlePayload, InvoiceTitlePayload,
PayMethod, PayMethod,
PayOrderResponse,
Payment, Payment,
PaymentResult, PaymentResult,
} from '@/types/business/finance' } from '@/types/business/finance'
import { import {
applyInvoice as applyInvoiceApi, applyInvoice as applyInvoiceApi,
createAlipayPageOrder as createAlipayPageOrderApi,
createInvoiceTitle, createInvoiceTitle,
createPayment as createPaymentApi, createPayment as createPaymentApi,
createWechatNativeOrder as createWechatNativeOrderApi,
deleteInvoiceTitle, deleteInvoiceTitle,
exportReport as exportReportApi, exportReport as exportReportApi,
getBillDetail, getBillDetail,
getBillList, getBillList,
getCheckoutBills,
getFinanceStats, getFinanceStats,
getInvoiceList, getInvoiceList,
getInvoiceTitles, getInvoiceTitles,
getPlanPayResult,
getPayResult, getPayResult,
setDefaultTitle as setDefaultTitleApi, setDefaultTitle as setDefaultTitleApi,
switchPaymentMethod as switchPaymentMethodApi, switchPaymentMethod as switchPaymentMethodApi,
@@ -38,35 +43,31 @@ function createDefaultInvoiceQuery(): InvoiceQuery {
return { keyword: '', status: 'all', page: 1, pageSize: 6 } return { keyword: '', status: 'all', page: 1, pageSize: 6 }
} }
/** 财务中心数据(统计/账单/支付/发票/抬头) */
export const useFinanceStore = defineStore('finance', () => { export const useFinanceStore = defineStore('finance', () => {
// —— 统计
const stats = ref<FinanceStats | null>(null) const stats = ref<FinanceStats | null>(null)
// —— 账单列表
const list = ref<Bill[]>([]) const list = ref<Bill[]>([])
const total = ref(0) const total = ref(0)
const query = ref<BillQuery>(createDefaultBillQuery()) const query = ref<BillQuery>(createDefaultBillQuery())
const currentBill = ref<Bill | null>(null) const currentBill = ref<Bill | null>(null)
// —— 支付
const payment = ref<Payment | null>(null) const payment = ref<Payment | null>(null)
const result = ref<PaymentResult | null>(null) const result = ref<PaymentResult | null>(null)
// —— 发票 const checkoutBills = ref<Bill[]>([])
const checkoutLoading = ref(false)
const payOrder = ref<PayOrderResponse | null>(null)
const invoices = ref<Invoice[]>([]) const invoices = ref<Invoice[]>([])
const invoiceTotal = ref(0) const invoiceTotal = ref(0)
const invoiceQuery = ref<InvoiceQuery>(createDefaultInvoiceQuery()) const invoiceQuery = ref<InvoiceQuery>(createDefaultInvoiceQuery())
// —— 发票抬头
const titles = ref<InvoiceTitle[]>([]) const titles = ref<InvoiceTitle[]>([])
// —— 统计
async function fetchStats(period?: string): Promise<void> { async function fetchStats(period?: string): Promise<void> {
stats.value = await getFinanceStats(period) stats.value = await getFinanceStats(period)
} }
// —— 账单
async function fetchList(): Promise<void> { async function fetchList(): Promise<void> {
const pageData: PageResult<Bill> = await getBillList(query.value) const pageData: PageResult<Bill> = await getBillList(query.value)
list.value = pageData.list list.value = pageData.list
@@ -77,13 +78,11 @@ export const useFinanceStore = defineStore('finance', () => {
query.value = next query.value = next
} }
/** 确定筛选(重置页码后取数) */
async function search(): Promise<void> { async function search(): Promise<void> {
query.value = { ...query.value, page: 1 } query.value = { ...query.value, page: 1 }
await fetchList() await fetchList()
} }
/** 重置全部条件 */
async function resetQuery(): Promise<void> { async function resetQuery(): Promise<void> {
query.value = createDefaultBillQuery() query.value = createDefaultBillQuery()
await fetchList() await fetchList()
@@ -98,21 +97,41 @@ export const useFinanceStore = defineStore('finance', () => {
currentBill.value = await getBillDetail(id) currentBill.value = await getBillDetail(id)
} }
// —— 支付
async function createPayment(billId: string, method: PayMethod): Promise<void> { async function createPayment(billId: string, method: PayMethod): Promise<void> {
payment.value = await createPaymentApi(billId, method) payment.value = await createPaymentApi(billId, method)
} }
/** 切换支付方式(刷新二维码) */
async function switchPaymentMethod(billId: string, method: PayMethod): Promise<void> { async function switchPaymentMethod(billId: string, method: PayMethod): Promise<void> {
payment.value = await switchPaymentMethodApi(billId, method) payment.value = await switchPaymentMethodApi(billId, method)
} }
async function fetchResult(billId: string): Promise<void> { async function fetchResult(billId: string, requirementId = ''): Promise<void> {
result.value = await getPayResult(billId) result.value = requirementId
? await getPlanPayResult(requirementId, billId)
: await getPayResult(billId)
}
async function loadCheckout(requirementId: string, ids: string[] = []): Promise<void> {
checkoutLoading.value = true
try {
checkoutBills.value = await getCheckoutBills(requirementId, ids)
} finally {
checkoutLoading.value = false
}
}
async function payByWechatNative(orderNo: string, amount: number, billIds: string[]): Promise<void> {
payOrder.value = await createWechatNativeOrderApi(orderNo, amount, billIds)
}
async function payByAlipayPage(orderNo: string, amount: number, billIds: string[]): Promise<void> {
payOrder.value = await createAlipayPageOrderApi(orderNo, amount, billIds)
}
function clearPayOrder(): void {
payOrder.value = null
} }
// —— 发票
async function fetchInvoices(): Promise<void> { async function fetchInvoices(): Promise<void> {
const pageData: PageResult<Invoice> = await getInvoiceList(invoiceQuery.value) const pageData: PageResult<Invoice> = await getInvoiceList(invoiceQuery.value)
invoices.value = pageData.list invoices.value = pageData.list
@@ -123,7 +142,6 @@ export const useFinanceStore = defineStore('finance', () => {
invoiceQuery.value = next invoiceQuery.value = next
} }
/** 发票确定筛选(重置页码后取数) */
async function searchInvoices(): Promise<void> { async function searchInvoices(): Promise<void> {
invoiceQuery.value = { ...invoiceQuery.value, page: 1 } invoiceQuery.value = { ...invoiceQuery.value, page: 1 }
await fetchInvoices() await fetchInvoices()
@@ -134,14 +152,12 @@ export const useFinanceStore = defineStore('finance', () => {
await fetchInvoices() await fetchInvoices()
} }
/** 申请开票(状态 → 开票中) */
async function applyInvoice(id: string): Promise<void> { async function applyInvoice(id: string): Promise<void> {
const updated = await applyInvoiceApi(id) const updated = await applyInvoiceApi(id)
const index = invoices.value.findIndex((item) => item.id === id) const index = invoices.value.findIndex((item) => item.id === id)
if (index >= 0) invoices.value[index] = updated if (index >= 0) invoices.value[index] = updated
} }
// —— 发票抬头
async function fetchTitles(): Promise<void> { async function fetchTitles(): Promise<void> {
titles.value = await getInvoiceTitles() titles.value = await getInvoiceTitles()
} }
@@ -165,7 +181,6 @@ export const useFinanceStore = defineStore('finance', () => {
titles.value = await setDefaultTitleApi(id) titles.value = await setDefaultTitleApi(id)
} }
// —— 报表导出
async function exportReport(): Promise<void> { async function exportReport(): Promise<void> {
await exportReportApi() await exportReportApi()
} }
@@ -178,6 +193,9 @@ export const useFinanceStore = defineStore('finance', () => {
currentBill, currentBill,
payment, payment,
result, result,
checkoutBills,
checkoutLoading,
payOrder,
invoices, invoices,
invoiceTotal, invoiceTotal,
invoiceQuery, invoiceQuery,
@@ -192,6 +210,10 @@ export const useFinanceStore = defineStore('finance', () => {
createPayment, createPayment,
switchPaymentMethod, switchPaymentMethod,
fetchResult, fetchResult,
loadCheckout,
payByWechatNative,
payByAlipayPage,
clearPayOrder,
fetchInvoices, fetchInvoices,
setInvoiceQuery, setInvoiceQuery,
searchInvoices, searchInvoices,

View File

@@ -1,46 +1,224 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { computed, ref } from 'vue'
import type { BusinessType } from '@/types/api' import type { BusinessType } from '@/types/api'
import type { Case } from '@/types/business/cases' import type { Case } from '@/types/business/cases'
import type { BusinessLine, HomeStats } from '@/types/business/home' import type {
import { getBusinessDetail, getBusinessLines, getFeaturedCases, getHomeStats } from '@/api/home' Banner,
BannerVo,
BusinessLine,
HomeStats,
ProjectStatusCount,
ProjectSubmissionFlat,
ProjectSubmissionInfo,
ProjectSubmissionVo,
ServiceContentBlock,
ServiceType,
} from '@/types/business/home'
import type { PortalEntryGroup, PortalEntryVo } from '@/types/business/home'
import {
getCarouselList,
getPortalEntryList,
getProjectStatusCount,
getServiceTypeDetail,
getServiceTypeList,
switchPortalEntryGroup as switchPortalEntryGroupApi,
} from '@/api/home'
import { getFeaturedCases as getFeaturedCasesApi } from '@/api/cases'
import { toCase } from '@/utils/caseVo'
import { formatBusinessType, toBusinessType } from '@/utils/format'
import { useCasesStore } from '@/stores/modules/cases'
function stripHtml(html: string): string {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function toBanners(list: BannerVo[]): Banner[] {
return list
.filter((item) => item.enabled !== false && Boolean(item.imageUrl))
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
.map((item) => ({
id: item.id ?? 0,
imageUrl: item.imageUrl ?? '',
}))
}
function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
const serviceTypes: ServiceType[] = (vo.serviceTypes ?? []).map((item) => ({
label: item.serviceName ?? '',
desc: '',
icon: item.iconUrl ?? '',
}))
const serviceContent = (vo.backgrounds ?? [])
.map((item) => stripHtml(item.content ?? ''))
.filter((text) => text.length > 0)
.join('\n\n')
const contents: ServiceContentBlock[] = (vo.backgrounds ?? [])
.map((item) => ({ title: '', content: item.content ?? '' }))
.filter((block) => block.content.trim().length > 0)
const subtitle = vo.subtitle ?? ''
return {
id: toBusinessType(vo.businessType),
name: vo.title ?? '',
desc: subtitle,
slogan: subtitle,
icon: vo.logoUrl ?? '',
cover: vo.coverUrl ?? '',
serviceContent,
contents,
serviceTypes,
video: '',
}
}
function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
const ps = info.projectSubmission ?? ({} as ProjectSubmissionFlat)
const serviceTypes: ServiceType[] = (info.projectServiceType ?? []).map((item) => ({
label: item.serviceName ?? '',
desc: '',
icon: item.iconUrl ?? '',
}))
const serviceContent = (info.projectSubmissionCatalog ?? [])
.map((item) => stripHtml(item.content ?? ''))
.filter((text) => text.length > 0)
.join('\n\n')
const contents: ServiceContentBlock[] = (info.projectSubmissionCatalog ?? [])
.map((item) => ({ title: item.title ?? '', content: item.content ?? '' }))
.filter((block) => block.content.trim().length > 0)
const subtitle = ps.subtitle ?? ''
return {
id: toBusinessType(ps.businessType),
name: ps.title ?? '',
desc: subtitle,
slogan: subtitle,
icon: ps.logoUrl ?? '',
cover: ps.coverUrl ?? '',
serviceContent,
contents,
serviceTypes,
video: '',
}
}
const EMPTY_STATUS: ProjectStatusCount = {
projectCount: 0,
ongoingProjectCount: 0,
pendingProjectCount: 0,
}
/** 首页数据(数据层:取数 + 状态,页面只调本 store) */
export const useHomeStore = defineStore('home', () => { export const useHomeStore = defineStore('home', () => {
const casesStore = useCasesStore()
const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 }) const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 })
const banners = ref<Banner[]>([])
const businessLines = ref<BusinessLine[]>([]) const businessLines = ref<BusinessLine[]>([])
const featuredCases = ref<Case[]>([]) const featuredCases = ref<Case[]>([])
const currentBusiness = ref<BusinessLine | null>(null) const currentBusiness = ref<BusinessLine | null>(null)
/** 首页初始化:看板 + 业务线 + 案例精选 */ const submissions = ref<ProjectSubmissionVo[]>([])
const portalEntries = ref<PortalEntryVo[]>([])
const portalEntriesLoaded = ref(false)
const quickEntries = computed(() =>
portalEntries.value.filter((item) => item.groupType === '1'),
)
const otherEntries = computed(() =>
portalEntries.value.filter((item) => item.groupType !== '1'),
)
async function fetchPortalEntries(): Promise<void> {
try {
const list = await getPortalEntryList()
portalEntries.value = list ?? []
portalEntriesLoaded.value = true
} catch {
portalEntries.value = []
portalEntriesLoaded.value = false
}
}
async function switchEntryGroup(id: number, type: PortalEntryGroup): Promise<void> {
await switchPortalEntryGroupApi(id, type)
await fetchPortalEntries()
}
async function ensureSubmissions(): Promise<void> {
if (submissions.value.length === 0) {
submissions.value = await getServiceTypeList()
}
}
async function fetchHomeData(): Promise<void> { async function fetchHomeData(): Promise<void> {
const [homeStats, lines, cases] = await Promise.all([ const [statusCount, submissionList, cases, carouselList] = await Promise.all([
getHomeStats(), getProjectStatusCount(),
getBusinessLines(), getServiceTypeList(),
getFeaturedCases(), getFeaturedCasesApi(),
getCarouselList().catch(() => [] as BannerVo[]),
casesStore.ensureCategories(),
]) ])
stats.value = homeStats const status = statusCount ?? EMPTY_STATUS
businessLines.value = lines stats.value = {
featuredCases.value = cases pending: status.pendingProjectCount ?? 0,
inProgress: status.ongoingProjectCount ?? 0,
total: status.projectCount ?? 0,
}
submissions.value = submissionList
businessLines.value = submissionList.map(toBusinessLine)
featuredCases.value = cases.map((vo) => toCase(vo, casesStore.typeNameById))
banners.value = toBanners(carouselList ?? [])
await fetchPortalEntries()
} }
/** 按业务类型切换案例精选 */
async function fetchFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<void> { async function fetchFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<void> {
featuredCases.value = await getFeaturedCases(category) if (category === 'all') {
featuredCases.value = (await getFeaturedCasesApi()).map((vo) => toCase(vo, casesStore.typeNameById))
return
}
await casesStore.ensureCategories()
const name = formatBusinessType(category)
const typeId = Object.entries(casesStore.typeNameById).find(([, value]) => value === name)?.[0]
if (!typeId) {
featuredCases.value = []
return
}
featuredCases.value = (await getFeaturedCasesApi(typeId)).map((vo) => toCase(vo, casesStore.typeNameById))
} }
/** 业务详情 */
async function fetchBusinessDetail(type: BusinessType): Promise<void> { async function fetchBusinessDetail(type: BusinessType): Promise<void> {
currentBusiness.value = await getBusinessDetail(type) await ensureSubmissions()
const target = submissions.value.find((item) => toBusinessType(item.businessType) === type)
if (!target || target.id == null) {
currentBusiness.value = null
return
}
currentBusiness.value = toBusinessLineFromInfo(await getServiceTypeDetail(target.id))
} }
return { return {
stats, stats,
banners,
businessLines, businessLines,
featuredCases, featuredCases,
currentBusiness, currentBusiness,
portalEntries,
portalEntriesLoaded,
quickEntries,
otherEntries,
fetchHomeData, fetchHomeData,
fetchFeaturedCases, fetchFeaturedCases,
fetchBusinessDetail, fetchBusinessDetail,
fetchPortalEntries,
switchEntryGroup,
} }
}) })

View File

@@ -12,7 +12,6 @@ import {
markRead as markReadApi, markRead as markReadApi,
} from '@/api/message' } from '@/api/message'
/** tab 计数结构 */
export interface MessageTabCounts { export interface MessageTabCounts {
all: number all: number
在线客服: number 在线客服: number
@@ -29,7 +28,6 @@ function createDefaultQuery(): MessageQuery {
} }
} }
/** 消息中心数据(列表 / 筛选 / 分页 / 已读) */
export const useMessageStore = defineStore('message', () => { export const useMessageStore = defineStore('message', () => {
const query = ref<MessageQuery>(createDefaultQuery()) const query = ref<MessageQuery>(createDefaultQuery())
const list = ref<Message[]>([]) const list = ref<Message[]>([])
@@ -59,13 +57,11 @@ export const useMessageStore = defineStore('message', () => {
query.value = next query.value = next
} }
/** 确定搜索(重置页码后取数) */
async function search(): Promise<void> { async function search(): Promise<void> {
query.value = { ...query.value, page: 1 } query.value = { ...query.value, page: 1 }
await fetchList() await fetchList()
} }
/** 重置全部条件 */
async function resetQuery(): Promise<void> { async function resetQuery(): Promise<void> {
query.value = createDefaultQuery() query.value = createDefaultQuery()
await fetchList() await fetchList()
@@ -76,7 +72,6 @@ export const useMessageStore = defineStore('message', () => {
await fetchList() await fetchList()
} }
/** 全部标记已读 */
async function markAllRead(): Promise<void> { async function markAllRead(): Promise<void> {
await markAllReadApi() await markAllReadApi()
ElMessage.success('已全部标记为已读') ElMessage.success('已全部标记为已读')

100
src/stores/modules/oss.ts Normal file
View File

@@ -0,0 +1,100 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchOssPreviewBlob } from '@/api/oss'
import { resolvePreviewKind, PREVIEW_TEXT_MAX_BYTES, type PreviewKind } from '@/utils/filePreview'
import type { FileInfo } from '@/types/api'
export const useOssStore = defineStore('oss', () => {
const visible = ref(false)
const file = ref<FileInfo | null>(null)
const kind = ref<PreviewKind>('unsupported')
const objectUrl = ref<string | null>(null)
const textContent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
const truncated = ref(false)
const totalBytes = ref(0)
function revokeObjectUrl(): void {
if (objectUrl.value && objectUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(objectUrl.value)
}
objectUrl.value = null
}
async function openPreview(target: FileInfo): Promise<void> {
closePreview()
file.value = target
visible.value = true
loading.value = true
error.value = ''
textContent.value = null
truncated.value = false
totalBytes.value = 0
const previewKind = resolvePreviewKind(target.name)
kind.value = previewKind
if (previewKind === 'unsupported') {
loading.value = false
return
}
if (target.ossId === undefined || target.ossId === null) {
if (previewKind === 'image' && target.url) {
objectUrl.value = target.url
loading.value = false
return
}
error.value = '该文件暂不支持在线预览,请使用下载'
loading.value = false
return
}
try {
const blob = await fetchOssPreviewBlob(target.ossId)
if (previewKind === 'text') {
const total = blob.size
if (total > PREVIEW_TEXT_MAX_BYTES) {
textContent.value = await blob.slice(0, PREVIEW_TEXT_MAX_BYTES).text()
truncated.value = true
totalBytes.value = total
} else {
textContent.value = await blob.text()
}
} else {
objectUrl.value = URL.createObjectURL(blob)
}
loading.value = false
} catch {
error.value = '预览加载失败,请使用下载'
loading.value = false
}
}
function closePreview(): void {
revokeObjectUrl()
visible.value = false
file.value = null
kind.value = 'unsupported'
textContent.value = null
truncated.value = false
totalBytes.value = 0
loading.value = false
error.value = ''
}
return {
visible,
file,
kind,
objectUrl,
textContent,
loading,
error,
truncated,
totalBytes,
openPreview,
closePreview,
}
})

View File

@@ -51,9 +51,7 @@ function createDefaultFavoritesQuery(): FavoritesQuery {
return { page: 1, pageSize: 8, keyword: '', category: 'all' } return { page: 1, pageSize: 8, keyword: '', category: 'all' }
} }
/** 个人中心数据(资料/安全/地址/收藏/资质/反馈/帮助) */
export const useProfileStore = defineStore('profile', () => { export const useProfileStore = defineStore('profile', () => {
// ── 账号资料 ──
const profile = ref<Profile | null>(null) const profile = ref<Profile | null>(null)
async function fetchProfile(): Promise<void> { async function fetchProfile(): Promise<void> {
@@ -65,7 +63,6 @@ export const useProfileStore = defineStore('profile', () => {
ElMessage.success('昵称修改成功') ElMessage.success('昵称修改成功')
} }
// ── 账号安全 ──
async function changePasswordAction(params: ChangePasswordParams): Promise<void> { async function changePasswordAction(params: ChangePasswordParams): Promise<void> {
await changePassword(params) await changePassword(params)
profile.value = await getProfile() profile.value = await getProfile()
@@ -97,7 +94,6 @@ export const useProfileStore = defineStore('profile', () => {
ElMessage.success('注销成功') ElMessage.success('注销成功')
} }
// ── 收货地址 ──
const addresses = ref<Address[]>([]) const addresses = ref<Address[]>([])
const addressTotal = ref(0) const addressTotal = ref(0)
const addressQuery = ref<ListQuery>(createDefaultAddressQuery()) const addressQuery = ref<ListQuery>(createDefaultAddressQuery())
@@ -138,7 +134,6 @@ export const useProfileStore = defineStore('profile', () => {
await fetchAddresses() await fetchAddresses()
} }
// ── 我的收藏 ──
const favorites = ref<Case[]>([]) const favorites = ref<Case[]>([])
const favoritesTotal = ref(0) const favoritesTotal = ref(0)
const favoritesCounts = ref<Partial<Record<BusinessType, number>>>({}) const favoritesCounts = ref<Partial<Record<BusinessType, number>>>({})
@@ -169,7 +164,6 @@ export const useProfileStore = defineStore('profile', () => {
await fetchFavorites() await fetchFavorites()
} }
// ── 资质管理 ──
const qualifications = ref<Qualification[]>([]) const qualifications = ref<Qualification[]>([])
const qualificationTotal = ref(0) const qualificationTotal = ref(0)
const qualificationQuery = ref<ListQuery>(createDefaultQualificationQuery()) const qualificationQuery = ref<ListQuery>(createDefaultQualificationQuery())
@@ -204,7 +198,6 @@ export const useProfileStore = defineStore('profile', () => {
await fetchQualifications() await fetchQualifications()
} }
// ── 意见反馈 / 帮助中心 ──
async function submitFeedbackAction(feedback: Feedback): Promise<void> { async function submitFeedbackAction(feedback: Feedback): Promise<void> {
await submitFeedbackApi(feedback) await submitFeedbackApi(feedback)
} }

View File

@@ -1,90 +1,202 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { PageResult } from '@/types/api' import type {
import type { AfterSalesPayload, Project, ProjectQuery } from '@/types/business/project' AfterSalesAttachment,
AfterSalesPayload,
Project,
ProjectQuery,
ProjectStatistics,
ProjectTypeCount,
RdProjectVo,
RequirementDetailBundle,
} from '@/types/business/project'
import { import {
cancelAfterSales as cancelAfterSalesApi, cancelAfterSales as cancelAfterSalesApi,
deleteProjectFile, getCompletedProjects,
getProjectDetail, getProjectDetailBundle,
getProjectDetailTab,
getProjectFilterConditions,
getProjectList, getProjectList,
submitAfterSales as submitAfterSalesApi, submitAfterSales as submitAfterSalesApi,
uploadProjectFile, uploadAfterSalesFile as uploadAfterSalesFileApi,
} from '@/api/project' } from '@/api/project'
import type { ProjectDetailTab } from '@/api/project'
import { toProjectDetail } from '@/utils/projectVo'
function createDefaultQuery(): ProjectQuery { function createDefaultQuery(): ProjectQuery {
return { return {
keyword: '', keyword: '',
businessType: 'all', businessTypes: [],
status: 'all', status: '',
timeShortcut: 'all', timeShortcut: 'all',
page: 1, startDate: '',
endDate: '',
pageNum: 1,
pageSize: 8, pageSize: 8,
} }
} }
/** 项目中心数据(列表 / 筛选 / 分页 / 详情 / 文件 / 售后) */ function buildTypeCounts(
statistics: ProjectStatistics | null | undefined,
total: number,
): ProjectTypeCount[] {
const counts = statistics?.businessTypeCounts ?? []
return [
{ value: 'all', label: '全部', count: statistics?.total ?? total },
...counts.map((item) => ({
value: String(item.businessTypeId),
label: item.businessTypeLabel,
count: item.count,
})),
]
}
export const useProjectStore = defineStore('project', () => { export const useProjectStore = defineStore('project', () => {
const query = ref<ProjectQuery>(createDefaultQuery()) const query = ref<ProjectQuery>(createDefaultQuery())
const list = ref<Project[]>([]) const list = ref<RdProjectVo[]>([])
const total = ref(0) const total = ref(0)
const current = ref<Project | null>(null) const current = ref<Project | null>(null)
const typeCounts = ref<ProjectTypeCount[]>([{ value: 'all', label: '全部', count: 0 }])
let filterConditionsLoaded = false
const completedProjects = ref<RdProjectVo[]>([])
const detailBundle = ref<RequirementDetailBundle | null>(null)
let detailSeq = 0
const tabSeq: Record<ProjectDetailTab, number> = {
progress: 0,
files: 0,
finance: 0,
afterSales: 0,
}
const loadingTab = ref<ProjectDetailTab | ''>('')
async function ensureFilterConditions(): Promise<void> {
if (filterConditionsLoaded) return
try {
typeCounts.value = buildTypeCounts(await getProjectFilterConditions(), total.value)
filterConditionsLoaded = true
}
catch (error) {
console.warn('[project] 业务类型筛选条件加载失败', error)
}
}
async function fetchList(): Promise<void> { async function fetchList(): Promise<void> {
const result: PageResult<Project> = await getProjectList(query.value) const result = await getProjectList(query.value)
list.value = result.list list.value = result?.rows ?? []
total.value = result.total total.value = result?.total ?? 0
}
async function fetchCompletedProjects(): Promise<void> {
try {
completedProjects.value = await getCompletedProjects()
}
catch (error) {
console.warn('[project] 已完成项目列表加载失败', error)
}
} }
async function fetchDetail(id: string): Promise<void> { async function fetchDetail(id: string): Promise<void> {
current.value = await getProjectDetail(id) const seq = ++detailSeq
loadingTab.value = ''
try {
const bundle = await getProjectDetailBundle(id)
if (seq !== detailSeq) return
if (!bundle) {
detailBundle.value = null
current.value = null
return
}
detailBundle.value = bundle
current.value = toProjectDetail(bundle)
}
catch (error) {
if (seq !== detailSeq) return
detailBundle.value = null
current.value = null
console.warn('[project] 项目详情加载失败', error)
}
}
async function refreshTab(tab: ProjectDetailTab): Promise<void> {
const requestId = current.value?.id
if (!requestId) return
const seq = ++tabSeq[tab]
loadingTab.value = tab
try {
const vo = await getProjectDetailTab(requestId, tab)
if (seq !== tabSeq[tab] || current.value?.id !== requestId) return
if (!vo || !detailBundle.value) return
const next: RequirementDetailBundle = { ...detailBundle.value }
switch (tab) {
case 'progress':
next.progress = vo as RequirementDetailBundle['progress']
break
case 'files':
next.files = vo as RequirementDetailBundle['files']
break
case 'finance':
next.finance = vo as RequirementDetailBundle['finance']
break
case 'afterSales':
next.afterSales = vo as RequirementDetailBundle['afterSales']
break
}
detailBundle.value = next
current.value = toProjectDetail(next)
}
catch (error) {
console.warn(`[project] 页签「${tab}」数据刷新失败`, error)
}
finally {
if (seq === tabSeq[tab]) loadingTab.value = ''
}
} }
function setQuery(next: ProjectQuery): void { function setQuery(next: ProjectQuery): void {
query.value = next query.value = next
} }
/** 确定筛选(重置页码后取数) */ async function load(): Promise<void> {
async function search(): Promise<void> { try {
query.value = { ...query.value, page: 1 }
await fetchList() await fetchList()
} }
catch (error) {
console.warn('[project] 项目列表加载失败', error)
}
}
async function search(): Promise<void> {
query.value = { ...query.value, pageNum: 1 }
await load()
}
/** 重置全部条件 */
async function resetQuery(): Promise<void> { async function resetQuery(): Promise<void> {
query.value = createDefaultQuery() query.value = createDefaultQuery()
await fetchList() await load()
} }
async function changePage(page: number): Promise<void> { async function changePage(pageNum: number): Promise<void> {
query.value = { ...query.value, page } query.value = { ...query.value, pageNum }
await fetchList() await load()
} }
/** 上传项目文件(追加到当前详情) */ async function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
async function uploadFile(file: File): Promise<void> { return uploadAfterSalesFileApi(file)
const info = await uploadProjectFile(file)
current.value?.files.push(info)
} }
/** 删除当前详情中的项目文件 */ async function submitAfterSales(id: string, payload: AfterSalesPayload): Promise<void> {
async function deleteFile(index: number): Promise<void> { await submitAfterSalesApi(id, payload)
await deleteProjectFile(index) ElMessage.success('售后申请已提交')
current.value?.files.splice(index, 1) if (current.value?.id === id) await refreshTab('afterSales')
} }
/** 提交售后工单 */ async function cancelAfterSales(id: string): Promise<void> {
async function submitAfterSales(payload: AfterSalesPayload): Promise<void> { await cancelAfterSalesApi(id)
if (!current.value) return ElMessage.success('售后申请已撤回')
current.value.afterSales = await submitAfterSalesApi(current.value.id, payload) if (current.value?.id === id) await refreshTab('afterSales')
ElMessage.success('已提交售后工单')
}
/** 取消售后工单 */
async function cancelAfterSales(): Promise<void> {
if (!current.value) return
current.value.afterSales = await cancelAfterSalesApi(current.value.id)
ElMessage.success('已取消售后工单')
} }
return { return {
@@ -92,14 +204,20 @@ export const useProjectStore = defineStore('project', () => {
list, list,
total, total,
current, current,
typeCounts,
ensureFilterConditions,
fetchList, fetchList,
completedProjects,
fetchCompletedProjects,
fetchDetail, fetchDetail,
refreshTab,
loadingTab,
setQuery, setQuery,
load,
search, search,
resetQuery, resetQuery,
changePage, changePage,
uploadFile, uploadAfterSalesFile,
deleteFile,
submitAfterSales, submitAfterSales,
cancelAfterSales, cancelAfterSales,
} }

View File

@@ -8,7 +8,6 @@ import {
uploadChatFile, uploadChatFile,
} from '@/api/service' } from '@/api/service'
/** 在线客服会话数据(消息列表 / 发送状态) */
export const useServiceStore = defineStore('service', () => { export const useServiceStore = defineStore('service', () => {
const messages = ref<ChatMessage[]>([]) const messages = ref<ChatMessage[]>([])
const sending = ref(false) const sending = ref(false)
@@ -17,7 +16,6 @@ export const useServiceStore = defineStore('service', () => {
messages.value = await getChatHistory() messages.value = await getChatHistory()
} }
/** 发送文本:先推入用户消息,600ms 后推入客服自动回复 */
async function send(content: string): Promise<void> { async function send(content: string): Promise<void> {
const trimmed = content.trim() const trimmed = content.trim()
if (!trimmed || sending.value) return if (!trimmed || sending.value) return
@@ -37,7 +35,6 @@ export const useServiceStore = defineStore('service', () => {
} }
} }
/** 发送文件卡片消息 */
async function sendFile(file: File): Promise<void> { async function sendFile(file: File): Promise<void> {
if (sending.value) return if (sending.value) return
sending.value = true sending.value = true

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
/** 站点公共数据(站点配置、全局 loading),非页面级 */
export const useSiteStore = defineStore('site', () => { export const useSiteStore = defineStore('site', () => {
const globalLoading = ref(false) const globalLoading = ref(false)

View File

@@ -4,21 +4,20 @@ import { ElMessage } from 'element-plus'
import type { import type {
LoginByAccountParams, LoginByAccountParams,
LoginBySmsParams, LoginBySmsParams,
LoginResult,
RegisterParams,
ResetPasswordParams, ResetPasswordParams,
User, User,
} from '@/types/business/user' } from '@/types/business/user'
import { import {
loginByAccount as loginByAccountApi,
loginBySms as loginBySmsApi, loginBySms as loginBySmsApi,
register as registerApi,
resetPassword as resetPasswordApi, resetPassword as resetPasswordApi,
sendSmsCode as sendSmsCodeApi, sendSmsCode as sendSmsCodeApi,
} from '@/api/auth' } from '@/api/auth'
import {
loginByAccountApi,
registerByAccountApi,
} from '@/api/auth'
import { storage } from '@/utils/storage' import { storage } from '@/utils/storage'
/** 用户登录态(M4:token 持久化 key 'token',路由守卫读取同一 key) */
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const token = ref<string | null>(storage.get<string>('token')) const token = ref<string | null>(storage.get<string>('token'))
const user = ref<User | null>(null) const user = ref<User | null>(null)
@@ -26,7 +25,6 @@ export const useUserStore = defineStore('user', () => {
const isLoggedIn = computed(() => Boolean(token.value)) const isLoggedIn = computed(() => Boolean(token.value))
/** 发送短信验证码 */
async function sendSmsCode(phone: string): Promise<void> { async function sendSmsCode(phone: string): Promise<void> {
try { try {
await sendSmsCodeApi(phone) await sendSmsCodeApi(phone)
@@ -38,19 +36,17 @@ export const useUserStore = defineStore('user', () => {
} }
} }
/** 登录成功统一落库(token 持久化) */ function applyLogin(payload: { token: string; user: User | null }): void {
function applyLogin(result: LoginResult): void { token.value = payload.token
token.value = result.token user.value = payload.user
user.value = result.user storage.set('token', payload.token)
storage.set('token', result.token)
} }
/** 账号密码登录 */
async function loginByAccount(params: LoginByAccountParams): Promise<boolean> { async function loginByAccount(params: LoginByAccountParams): Promise<boolean> {
loading.value = true loading.value = true
try { try {
const result = await loginByAccountApi(params) const result = await loginByAccountApi({ username: params.account, password: params.password })
applyLogin(result) applyLogin({ token: result.access_token, user: null })
ElMessage.success('登录成功') ElMessage.success('登录成功')
return true return true
} }
@@ -63,12 +59,11 @@ export const useUserStore = defineStore('user', () => {
} }
} }
/** 手机验证码登录 */
async function loginBySms(params: LoginBySmsParams): Promise<boolean> { async function loginBySms(params: LoginBySmsParams): Promise<boolean> {
loading.value = true loading.value = true
try { try {
const result = await loginBySmsApi(params) const result = await loginBySmsApi(params)
applyLogin(result) applyLogin({ token: result.token, user: result.user })
ElMessage.success('登录成功') ElMessage.success('登录成功')
return true return true
} }
@@ -81,11 +76,10 @@ export const useUserStore = defineStore('user', () => {
} }
} }
/** 注册 */ async function register(params: { username: string; password: string; confirmPassword: string }): Promise<boolean> {
async function register(params: RegisterParams): Promise<boolean> {
loading.value = true loading.value = true
try { try {
await registerApi(params) await registerByAccountApi({ username: params.username, password: params.password })
ElMessage.success('注册成功,请登录') ElMessage.success('注册成功,请登录')
return true return true
} }
@@ -98,7 +92,6 @@ export const useUserStore = defineStore('user', () => {
} }
} }
/** 重置密码 */
async function resetPassword(params: ResetPasswordParams): Promise<boolean> { async function resetPassword(params: ResetPasswordParams): Promise<boolean> {
loading.value = true loading.value = true
try { try {
@@ -115,7 +108,6 @@ export const useUserStore = defineStore('user', () => {
} }
} }
/** 退出登录(清除持久化 token) */
function logout(): void { function logout(): void {
token.value = null token.value = null
user.value = null user.value = null

View File

@@ -1,10 +1,133 @@
// 通用工具类
.container { .container {
width: 100%; width: 100%;
max-width: @container-width; max-width: @container-width;
margin: 0 auto; margin: 0 auto;
padding: 0 @spacing-base; padding: 0
clamp(@spacing-base, calc((100% - @page-content) / 2), @page-gutter);
}
.container--home {
padding: 0 @home-gutter;
// 1:1 还原基准为 1920。仅当视口明显窄于 1920 时才收窄留白。
@media (max-width: 1600px) {
padding: 0 @spacing-lg;
}
}
.auth-page {
position: relative;
display: flex;
min-height: 100vh;
background: @white;
// 设计稿 #36/#38/#39 的左侧品牌区为固定 784px 渐变块。
&__brand {
.flex(column, flex-start, flex-start);
flex: 0 0 @login-brand-width;
padding: 304px 0 0 257px;
background: linear-gradient(
160deg,
@auth-gradient-from 0%,
@auth-gradient-to 100%
);
color: @white;
text-align: left;
}
&__logo {
margin: 0;
font-size: 40px;
font-weight: 700;
line-height: 58px;
color: @white;
}
&__key {
margin: 26px 0 0;
font-size: 15px;
font-weight: 400;
letter-spacing: 0.5px;
color: @white;
}
&__slogan {
// logo(116) + key 上距 26 + key(20) = 162;设计稿 slogan 顶 715,
// 故与 key 底(446+20=466) 间距 249px。
margin: 249px 0 0;
font-size: 22px;
font-weight: 600;
line-height: 29px;
letter-spacing: 8px;
color: @white;
}
// 设计稿 #39:右上角「游客模式」12px,标注框 (1840,24) 64.2×16
// (64.2 含 Sketch 文本框内边距,实际文字 48px),右留白 16px。
&__guest {
position: absolute;
top: 24px;
right: 16px;
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: #1f2937;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: @primary-strong;
}
}
&__main {
.flex(column, center, center);
flex: 1;
min-width: 0;
// 底部多留白,整体视觉中心上移,「欢迎登录」不再偏下
padding: 24px 0 120px;
}
// 设计稿表单区统一 693px 宽。标题/副标题按自身文字宽度相对表单居中,
// 不额外内缩——设计稿标注的 714px 宽是 Sketch 文本框含内边距的值。
&__card {
.flex(column, flex-start, flex-start);
width: @login-form-width;
text-align: left;
}
&__title {
margin: 0;
font-size: 34px;
font-weight: 700;
line-height: 45px;
color: #1f2937;
}
// 三页副标题样式一致且模板类名统一为 auth-page__subtitle,
// 放在公共层,避免各页 scoped 选择器与模板类名不匹配。
&__subtitle {
margin: 12px 0 0;
font-size: 13px;
font-weight: 400;
line-height: 17px;
color: #6b7280;
}
&__title,
&__subtitle {
align-self: center;
text-align: center;
}
&__link {
color: @primary-strong;
&:hover {
color: @primary-hover;
}
}
} }
.link { .link {

View File

@@ -1,3 +1,8 @@
// 样式入口(main.ts 引入;variables/mixins 由 vite additionalData 自动注入)
@import './reset.less'; @import './reset.less';
@import './common.less'; @import './common.less';
// 视觉基准:蓝湖设计稿 1920px,1:1 还原,不做整体缩放。
// 如需适配窄屏,请在各页面内部用媒体查询处理,不要在根元素缩放。
html {
zoom: 1;
}

View File

@@ -1,4 +1,3 @@
// 混合宏(flex、文本省略、滚动条)
.flex(@direction: row; @justify: flex-start; @align: center) { .flex(@direction: row; @justify: flex-start; @align: center) {
display: flex; display: flex;
@@ -35,3 +34,46 @@
background: transparent; background: transparent;
} }
} }
.section-title() {
position: relative;
padding-left: 15px;
font-size: @font-size-lg;
font-weight: 600;
line-height: 20px;
color: @heading-color;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 5px;
height: 20px;
border-radius: @radius-round;
background: @project-accent;
transform: translateY(-50%);
}
}
.text-control() {
width: 100%;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @white;
font-size: @font-size-base;
font-family: inherit;
color: @heading-color;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}

View File

@@ -1,4 +1,3 @@
// 样式重置
*, *,
*::before, *::before,
@@ -14,8 +13,8 @@ body {
body { body {
color: @text-color; color: @text-color;
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, 'Helvetica Neue', Arial, sans-serif; font-family: 'PingFang SC', 'Source Han Sans SC', 'Microsoft YaHei', -apple-system, 'Helvetica Neue', Arial, sans-serif;
font-size: @font-size-base; font-size: 16px;
line-height: 1.5; line-height: 1.5;
background-color: @bg-color; background-color: @bg-color;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;

View File

@@ -1,56 +1,312 @@
// ★ less 变量唯一来源(颜色/间距/字号/圆角)
// 注:蓝湖设计稿到位前为原型近似值,到位后按标注精确校准
// 品牌色 @primary-color: #1f67ef;
@primary-color: #3370ff; @primary-strong: #024fff;
@primary-hover: #598bff; @primary-hover: #598bff;
@primary-light: #eaf1ff; @primary-light: #eaf1ff;
// 功能色 @success-color: #16a34a;
@success-color: #34c724;
@warning-color: #ff8f1f; @warning-color: #ff8f1f;
@danger-color: #f54a45; @danger-color: #ef4444;
@info-color: #8a94a6; @info-color: #8a94a6;
// 文字色 @heading-color: #121212;
@heading-color: #1f2733; @text-color: #333333;
@text-color: #525e71; @text-color-secondary: #666666;
@text-color-secondary: #8a94a6; @text-color-disabled: #999999;
@text-color-disabled: #b8c0cc;
// 背景 / 边框
@bg-color: #f5f6f8; @bg-color: #f5f6f8;
@bg-color-light: #fafbfc; @bg-color-light: #fafbfc;
@border-color: #e4e7ec; @border-color: #e4e7ec;
@border-color-dark: #cdd3dd; @border-color-dark: #cdd3dd;
@border-color-btn: #d8dbdf;
@white: #ffffff; @white: #ffffff;
@black: #000000;
@header-bg: #f4f5f9;
@header-border: #d8d8d8;
@footer-bg: #0c1321;
// 圆角
@radius-sm: 4px; @radius-sm: 4px;
@radius-base: 8px; @radius-base: 8px;
@radius-lg: 12px; @radius-lg: 12px;
@radius-round: 999px; @radius-round: 999px;
// 间距
@spacing-xs: 4px; @spacing-xs: 4px;
@spacing-sm: 8px; @spacing-sm: 8px;
@spacing-base: 16px; @spacing-base: 16px;
@spacing-lg: 24px; @spacing-lg: 24px;
@spacing-xl: 32px; @spacing-xl: 32px;
@spacing-xxl: 48px; @spacing-xxl: 48px;
/** 内容区底边到页脚的最小留白(老大指定 200px,不在 4/8 梯度内) */
@spacing-page-bottom: 200px;
// 字号 @font-size-xs: 10px;
@font-size-sm: 12px; @font-size-sm: 12px;
@font-size-base: 14px; @font-size-base: 14px;
@font-size-lg: 16px; @font-size-body: 16px;
@font-size-md: 16px;
@font-size-lg: 18px;
@font-size-xl: 20px; @font-size-xl: 20px;
@font-size-xxl: 28px; @font-size-title: 28px;
@font-size-xxl: 36px;
@font-size-hero: 48px;
@font-size-hero-lg: 60px;
// 布局 @banner-title-color: #12171a;
@container-width: 1200px; @btn-gradient-from: #a061fe;
@header-height: 64px; @btn-gradient-to: #045dff;
@footer-height: 240px; @btn-ghost-color: #2b5ff0;
@container-width: 1920px;
// 视觉基准:蓝湖设计稿 1920px,1:1 还原,根元素不做缩放(html { zoom: 1 })。
// @page-zoom 仅作为「设计稿 px 与实际渲染 px」的换算说明保留,实际值为 1。
@page-zoom: 1;
@page-zoom--sm: 1;
@page-content: 1780px;
@page-gutter: 70px;
@profile-content: 1600px;
@profile-gutter: 155px;
@home-content: 1680px;
@home-gutter: 120px;
@content-width: @page-content;
@container-width-inner: @page-content;
@header-height: 72px;
@header-nav-gap: 94px;
@footer-height: 280px;
@footer-inset: 70px;
// 登录/注册/找回密码三页共用的品牌区宽度。设计稿 #36/#38/#39 的左侧渐变
// artboard 均为 784×1080,是固定值而非百分比。
@login-brand-width: 784px;
// 表单区宽度。设计稿 el12/el14/el19 等标签层统一 693px 宽。
@login-form-width: 693px;
// 阴影
@shadow-base: 0 2px 8px rgba(31, 39, 51, 0.08); @shadow-base: 0 2px 8px rgba(31, 39, 51, 0.08);
@shadow-lg: 0 4px 16px rgba(31, 39, 51, 0.12); @shadow-lg: 0 4px 16px rgba(31, 39, 51, 0.12);
@filter-selected-bg: #e7eeff;
@pill-bg: #f5f6f8;
@pill-selected-bg: #075cff;
@chips-panel-bg: #eaf1ff;
@input-bg: #f5f6f8;
@tag-hardware: #4caf50;
@tag-iot: #5720ba;
@tag-manufacturing: #ff8f1f;
@tag-other: #8a94a6;
@cases-accent: #075cff;
@cases-confirm: #2563eb;
@cases-hero-title: #121212;
@cases-filter-title: #222222;
@cases-text: #333333;
@cases-muted: #666666;
@cases-input-border: #cdd3dd;
@cases-icon: #797979;
@cases-reset-bg: #f5f6f8;
@cases-card-border: #e4e7ec;
@cases-card-shadow: 0 2px 8px rgba(31, 39, 51, 0.06);
@cases-page-active-bg: #e0e9fa;
@cases-filter-width: 320px;
@demand-selected-bg: #024fff;
@demand-accent: #024fff;
@demand-secondary-bg: #075cff;
@project-table-head-bg: #f7f8fa;
@project-table-line: #e4e7ec;
@project-filter-width: 320px;
@project-pill-bg: #f5f6f8;
@project-input-bg: #f5f6f8;
@project-accent: #024fff;
@project-track: #edeff2;
@milestone-done: #22c55e;
@milestone-idle: #d6d9de;
@team-avatar-bg: #1f67ef;
@checkout-card: #ffffff;
@checkout-line: #e1e1e1;
@checkout-amount: #ff5500;
@checkout-method-active-bg: #d3f3df;
@checkout-wechat: #28c445;
@checkout-alipay: #1677ff;
@checkout-cta: #f59e0b;
@checkout-cta-hover: #e08d05;
@checkout-muted: #9b9b9b;
@checkout-radio-idle: #c8c8c8;
@finance-card-width: 285px;
@finance-card-radius: 10px;
@finance-meta-color: #6b7280;
@finance-row-bg: #f7f8fa;
@finance-paid: #16a34a;
@finance-paid-bg: #e7f7ec;
@finance-pending: #f59e0b;
@finance-pending-amount: #f59e0b;
@finance-idle: #b3bfc4;
@finance-cta-hover: #e08d05;
@home-ink: #121212;
@home-welcome: #2c2c2c;
@home-body: #333333;
@home-paragraph: #222222;
@home-breadcrumb: #666666;
@home-card-border: #ebebeb;
@home-section-border: #e2e2e2;
@home-case-border: #e0dede;
@home-business-border: #999999;
@home-ring: #c6c2c2;
@home-tab-active: #085dff;
@home-banner-dot: #8d96ae;
@home-banner-bg: #f3f4f8;
@home-quick-blue: #a5d0ff;
@home-quick-purple: #dbc9fe;
@home-quick-green: #c4f0be;
@home-quick-peach: #ffcbbe;
@home-quick-pink: #ffcadb;
@home-detail-hero-bg: linear-gradient(
115deg,
#e2ebf7 0%,
#cfe7fd 55%,
#b6dfff 100%
);
@home-detail-logo-bg: #d9e5ff;
@home-service-web: #ae7eff;
@home-service-mp: #00b240;
@home-service-app: #fd9a83;
@home-service-dev: #80e561;
@home-service-pc: #0081e3;
@demand-ink: #121212;
@demand-heading: #333333;
@demand-subtitle: #666666;
@demand-line: #e4e7ec;
@demand-upload-bg: #f5f6f8;
@demand-radio-idle: #c7c2c2;
@auth-gradient-from: #505be8;
@auth-gradient-to: #8b3fe8;
@auth-track: #edeff2;
@project-divider: #f0f1f3;
@project-breadcrumb: #666666;
@project-logo-bg: #f2f4f6;
@project-banner-bg: #fdeeee;
@project-banner-title: #d93025;
@project-apply: #e3464b;
@project-quiet-btn: #eef0f3;
@tag-success-bg: #e7f7ec;
@tag-warning-bg: #fef3e2;
@tag-warning-text: #d97706;
@tag-info-bg: #f3f4f6;
@milestone-line: #eeeff2;
@project-ink: #1f2937;
@project-label: #6b7280;
@project-value: #1f2937;
@project-muted: #9ca3af;
@project-cell: #4b5563;
@project-card: #ffffff;
@project-field-bg: #f3f4f6;
@project-input-border: #e4e7ec;
@project-head-bg: #f7f8fa;
@project-upload-tag: #ef4444;
@project-banner-soft: #fdeeee;
@milestone-node: 356px;
@milestone-dot: 26px;
@milestone-ring: #e5e7eb;
@milestone-idle-text: #9ca3af;
@team-size: 64px;
@team-gap: 35px;
@contract-accent: #075cff;
@contract-muted: #666666;
@contract-head-text: #666666;
@contract-cell-text: #333333;
@contract-line: #e4e7ec;
@contract-table-head-bg: #f7f8fa;
@contract-placeholder: #b3b9c4;
@contract-reset-bg: #f5f6f8;
@contract-qr-bg: #d6d6d6;
@finance-hero-gradient: linear-gradient(
115deg,
#024fff 0%,
#4b4ff3 55%,
#7e75ec 100%
);
@finance-chart-bar: #4a90c4;
@finance-chart-grid: #edeef2;
@finance-table-head-bg: #f7f8fa;
@finance-field-bg: #f5f6f8;
@finance-page-active: #d5e2fe;
@finance-summary-cta: #ffb754;
@finance-result-circle: #7ed321;
@finance-safe-bg: #d2eedc;
@message-icon-bg: #3b82f6;
@service-user-avatar: #8d99ae;
@profile-nav-active-bg: #e7eeff;
@profile-card-border: #f0f1f3;
@profile-line: #e5e7eb;
@profile-input-border: #e4e7ec;
@profile-radius: 6px;
@profile-page-bg: #f4f5f9;
@profile-ink: #1f2937;
@profile-hotline-bg: #d6e4fb;
@profile-icon-pwd: #c7d2fe;
@profile-icon-phone: #fdebeb;
@profile-icon-email: #fee2e2;
@profile-user-from: #5459e8;
@profile-user-to: #d2bdf3;
@profile-user-avatar: #8976ed;
@profile-page-active-bg: #d1e0fe;
@profile-quiet-btn: #eef0f3;
@profile-tag-ok-bg: #e0f0e0;
@profile-input-disabled-bg: #f3f4f6;
@profile-verify-banner-bg: #edf3fe;
@contract-ink: #121212;
@contract-page-bg: #f4f6f8;
@finance-pay-bg: #f7f8f9;
@finance-muted-text: #6b7280;
@finance-card-line: #f0f1f3;
@finance-entry-text: #4b5563;
@finance-axis-text: #9ca3af;
@finance-ink: #1f2937;
// 财务中心首页快捷入口图标(设计稿 #20,蓝湖 fallback 无切图,色值取自封面采样)
@finance-entry-bill-bg: #e9f1fe;
@finance-entry-bill-icon: #1f67ef;
@finance-entry-pending-bg: #fff4e5;
@finance-entry-invoice-bg: #f1ecfe;
@finance-entry-invoice-icon: #7c5ce0;
@finance-entry-export-bg: #e6f6ec;
@finance-entry-badge-bg: #f53f3f;
@finance-chart-switch-active: #024fff;
@finance-switch-active-bg: #e8f0ff;
@finance-switch-idle-bg: #f2f4f7;
// 消息中心(设计稿 #22)
@message-page-bg: #f4f6f8;
@message-tabs-text: #4b5563;
@message-tabs-badge: #f53f3f;
@message-toolbar-btn-border: #e5e7eb;
@message-pager-active-bg: #edf3fe;
@message-item-icon: #3b82f6;
// 在线客服(设计稿 #42)
@service-card-line: #f0f1f3;
@service-scroll-bg: #fafbfc;
@service-input-bg: #f7f8fa;
@service-bar-bg: #f8f8f9;
@service-header-name: #1f2937;
@service-avatar-text: #8d99ae;
@service-bubble-text: #1f2937;

7
src/types/api.d.ts vendored
View File

@@ -1,31 +1,26 @@
// 接口通用结构(PRD 4.8)
/** 业务类型(跨模块共享) */
export type BusinessType = 'internet' | 'hardware' | 'iot' | 'manufacturing' | 'other' export type BusinessType = 'internet' | 'hardware' | 'iot' | 'manufacturing' | 'other'
/** 统一响应结构 */
export interface ApiResponse<T> { export interface ApiResponse<T> {
code: number code: number
msg: string msg: string
data: T data: T
} }
/** 分页结果 */
export interface PageResult<T> { export interface PageResult<T> {
list: T[] list: T[]
total: number total: number
} }
/** 通用分页查询参数 */
export interface PageQuery { export interface PageQuery {
page: number page: number
pageSize: number pageSize: number
} }
/** 附件/文件信息 */
export interface FileInfo { export interface FileInfo {
name: string name: string
size: number size: number
uploadTime: string uploadTime: string
url: string url: string
ossId?: string | number
} }

Some files were not shown because too many files have changed in this diff Show More