Compare commits
44 Commits
4e1bb0f82a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7722e19df | ||
|
|
0b974bd36a | ||
|
|
a067fd8357 | ||
|
|
367380e44c | ||
|
|
77dbabb1f1 | ||
|
|
f917062d76 | ||
|
|
093cf9acd0 | ||
|
|
5fa940e9ba | ||
|
|
f552dd370c | ||
|
|
fe9492ab69 | ||
|
|
d0779fe37c | ||
|
|
b0e9fe6450 | ||
|
|
631d54f269 | ||
|
|
0f038e7818 | ||
|
|
53f23d4e69 | ||
|
|
6ecc649a1c | ||
|
|
6af8fd7b09 | ||
|
|
123d259f92 | ||
|
|
fd1eca95a6 | ||
|
|
a96f62d60b | ||
|
|
50636d0e06 | ||
|
|
e6e37d3b0e | ||
|
|
1187f20fb1 | ||
|
|
1bbbe50bd9 | ||
|
|
bb897ccc94 | ||
|
|
f73103a2a6 | ||
|
|
aa440d6bb5 | ||
|
|
a3b04485dd | ||
|
|
9f4a5c7cda | ||
|
|
2b59f68fd7 | ||
|
|
0d2389e49a | ||
|
|
b99b2a0a88 | ||
|
|
eda0bc2200 | ||
|
|
863fdafcc5 | ||
|
|
d856fb623a | ||
|
|
1601bdd82c | ||
|
|
9ab6253c81 | ||
|
|
ce8210fb3f | ||
|
|
6b88c3805f | ||
|
|
b014fffacf | ||
|
|
43c2dc0d8d | ||
|
|
f1e64ef864 | ||
|
|
6eb2bfcbfd | ||
|
|
7bc84bbe5e |
@@ -1,382 +1,335 @@
|
||||
---
|
||||
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。
|
||||
version: 2.0.0
|
||||
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: 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/` 下的现行目录结构(如某目录尚不存在,应在对应职责目录创建,而不是自创新顶层目录)。
|
||||
2. 读取目标文件及其同级文件,确认现有命名风格与组织方式(页面名、store 名、api 名必须一致)。
|
||||
3. 只有在目录树确认完毕后,才允许创建/修改文件。
|
||||
### 0.1 读取证据
|
||||
|
||||
## 核心原则
|
||||
| 要看什么 | 怎么看 |
|
||||
| ---------- | --------------------------------------------------------------------------- |
|
||||
| 依赖清单 | `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**。
|
||||
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>`。
|
||||
### 0.2 判定形态
|
||||
|
||||
## 数据流架构(强约束)
|
||||
```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
|
||||
页面组件 (views/home/index.vue)
|
||||
│ ① 页面只调用 store 的 action / 读取 store 的 state(模板渲染数据)
|
||||
页面组件 (views/news/index.vue 或 pages/news/index.vue)
|
||||
│ ① 只读 store state / 调 store action
|
||||
▼
|
||||
Pinia store (stores/modules/home.ts) —— 数据层
|
||||
│ ② store 的 action 内部调用 api 接口函数 → await → 写入 state / 返回结果
|
||||
状态层 (stores/modules/news.ts) —— 数据层:取数 + 状态 + 缓存 + 跨页共享
|
||||
│ ② action 内部调用 api 函数 → await → 写 state
|
||||
▼
|
||||
API (api/home.ts) —— 接口层
|
||||
│ ③ 经 utils/request.ts 发 HTTP 请求
|
||||
API (api/news.ts) —— 接口层:只发请求、只返回带类型的数据
|
||||
│ ③ request.ts(axios / uni.request 封装)
|
||||
▼
|
||||
后端服务器
|
||||
后端
|
||||
```
|
||||
|
||||
铁律:
|
||||
|
||||
- **页面禁止 `import` api 层文件**;获取数据必须调对应页面 store 的方法。
|
||||
- **api 层是纯接口层**:只发请求、返回数据(带类型),不做业务状态处理、不缓存。
|
||||
- **store 承担取数 + 数据状态 + 页面间共享 + 缓存**;与页面同名的 store 责任边界即该页面。
|
||||
- 调用链必须是 `页面 → store → api` 单向,禁止跳过 store 或反向依赖。
|
||||
- 页面**禁止** import api;取数必须走 store。
|
||||
- api 是**纯接口层**:不发业务判断、不缓存、不 toast。
|
||||
- store 承担取数、状态、缓存、跨页共享;本地缓存用 `storage.ts`,不直接写 `localStorage` / `uni.setStorageSync`。
|
||||
- 依赖方向单向:`页面 → store → api`,禁止反向与跨层直连。
|
||||
|
||||
## src/ 目录结构(强制划分)
|
||||
---
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # ★ 接口层:按页面命名,一页一文件(home.ts、news.ts、product.ts、about.ts)
|
||||
├── assets/ # 参与构建的静态资源(images/、icons/、fonts/)
|
||||
├── components/ # ★ 公共组件:仅跨页面复用的全站通用组件(禁含具体页面业务)
|
||||
│ ├── AppHeader.vue # 全站头部(导航)
|
||||
│ ├── AppFooter.vue # 全站页脚
|
||||
│ ├── AppButton.vue # 通用按钮
|
||||
│ ├── AppSwiper.vue # 通用轮播
|
||||
│ └── index.ts # 统一导出
|
||||
├── composables/ # 通用组合式逻辑(useScroll、useLoading、useCountDown、useIntersection)
|
||||
├── directives/ # 自定义指令(lazy 图片懒加载、debounce、throttle + index.ts 统一注册)
|
||||
├── layout/ # 全站布局骨架:index.vue = <AppHeader/> + <router-view/> + <AppFooter/>
|
||||
├── router/ # 路由:index.ts(入口)、routes.ts(静态路由表)、guard.ts(守卫,可选)
|
||||
├── stores/ # ★ 数据层(Pinia):按页面命名,一页一 store
|
||||
│ ├── index.ts # pinia 实例(main.ts 中 app.use(pinia))
|
||||
│ └── modules/
|
||||
│ ├── home.ts # useHomeStore:首页数据(banner、推荐、公告)
|
||||
│ ├── news.ts # useNewsStore:新闻列表/分页/详情
|
||||
│ ├── product.ts # useProductStore:产品列表/详情
|
||||
│ └── site.ts # useSiteStore:站点公共数据(站点配置、全局 loading),非页面级
|
||||
├── styles/ # ★ 全局样式(less)
|
||||
│ ├── index.less # 样式入口(main.ts 引入),组织以下文件
|
||||
│ ├── variables.less # ★ less 变量唯一来源(颜色/间距/字号/圆角),禁止组件内魔法色值
|
||||
│ ├── 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/ # 登录(仅存在会员体系时)
|
||||
## 各层职责速查
|
||||
|
||||
| 层 | 目录 | 一句话职责 | 禁止 |
|
||||
| ------------ | --------------------------------------------------------- | ---------------------------------------- | ------------------------------ |
|
||||
| 页面 | `views/<page>/index.vue`(uni:`pages/<page>/index.vue`) | 渲染 + 交互 + 编排 | 直接调 api、直接 axios/request |
|
||||
| 页面私有组件 | `views/<page>/cpns/`(uni 同名) | 仅本页用 | 被其他页 import |
|
||||
| 公共组件 | `components/` | ≥2 处复用 | 含单页业务逻辑 |
|
||||
| 状态层 | `stores/modules/<domain>.ts` | 取数 + 状态 + 缓存 | 拼 URL、写 UI 逻辑 |
|
||||
| 接口层 | `api/<domain>.ts` | 请求分发 + 类型 | 状态、弹窗提示 |
|
||||
| 请求层 | `utils/request.ts` | 唯一实例/封装:token、统一解包、错误提示 | 被页面直接 import axios |
|
||||
| 工具 | `utils/`(纯函数) / `composables/`(响应式) | — | 互相混放 |
|
||||
|
||||
> **页面私有组件目录名统一用 `cpns/`**(不再用 `components/`,避免与全局 `components/` 混淆)。
|
||||
|
||||
### 组件归属判定(唯一判据)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[新组件] --> B{被几个页面/分包使用?}
|
||||
B -->|1 个| C[放该页面 cpns/]
|
||||
B -->|≥2 个| D{业务无关还是业务相关?}
|
||||
D -->|业务无关<br/>按钮/弹窗/上传| E[全局 components/]
|
||||
D -->|业务相关| F[全局 components/<domain>/]
|
||||
F --> G[差异用 props 注入]
|
||||
```
|
||||
|
||||
**命名一致性(可分层互查)**:页面 `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 页面使用才放这里
|
||||
- 页面独有组件 `views/<page>/cpns/`:**严禁被其他页面 import**;语义化命名(Head.vue、Banner.vue)
|
||||
- 页面结构三件套:`index.vue`(页面) + `index.ts`(出口 barrel) + `cpns/`(独有组件)
|
||||
- 组件统一 `<script setup lang="ts">`;props 用 `defineProps<Props>()` + `withDefaults`;emits 用 `defineEmits<{ 事件名: [参数类型] }>()`
|
||||
- 组件事件 camelCase 命名;模板内不放复杂表达式;样式默认 `<style lang="less" scoped>`
|
||||
- 单文件超 400 行即拆分到 `cpns/`
|
||||
- 统一 `<script setup lang="ts">`。
|
||||
- props:`defineProps<Props>()` + `withDefaults`;emits:`defineEmits<{ change: [val: number] }>()`,事件名 camelCase。
|
||||
- 模板内不写复杂表达式(超过一次三元/逻辑运算即抽方法或 computed)。
|
||||
- 单文件 > 400 行 → 拆到同页面 `cpns/`。
|
||||
- uni-app 组件模板用 `view/text/image`。
|
||||
|
||||
### 样式规范(less)
|
||||
### 样式规范
|
||||
|
||||
- 全项目使用 **less**,禁止新增 `.scss/.css` 业务样式
|
||||
- 全局变量统一在 `styles/variables.less`;组件内禁止魔法色值
|
||||
- vite `css.preprocessorOptions.less` 配置 `additionalData` 自动注入 variables
|
||||
- 依赖:`pnpm add -D less`(vite 内置 less 编译支持)
|
||||
- **跟随项目既有方案**(less 或 scss),不混用两套预处理器。
|
||||
- 变量单一来源:Vue 项目 `src/styles/variables.*`;uni-app 项目 `uni.scss`。
|
||||
- 组件内禁止魔法色值/魔法间距。
|
||||
- Vue 项目:`<style scoped lang="less|scss">`;uni-app 页面样式**不加 `scoped`**(小程序不支持),靠类名隔离。
|
||||
|
||||
### 工程化硬性约定
|
||||
### 类型规范
|
||||
|
||||
- **导入一律 `@/` 别名**;禁止跨目录相对路径(`../` 超过一层即违规)
|
||||
- utils 与 composables 判据:**需要响应式状态或生命周期 → composables;否则 → utils**
|
||||
- 类型归位:实体类型 → `types/business/<page>.d.ts`(与 api 同名);接口结构 → `types/api.d.ts`
|
||||
- **禁止 `as any` / `@ts-ignore` / `@ts-expect-error`**
|
||||
- 实体类型 → `types/business/<domain>.d.ts`;接口通用结构 → `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>/ 等)
|
||||
- [ ] **页面是否直接 import 了 api 层?(违规——数据必须经 store)**
|
||||
- [ ] **store 名 / api 文件名 / 页面目录名 / 类型文件名 是否与页面一致(可分层互查)**
|
||||
- [ ] 导入是否使用 `@/` 别名
|
||||
- [ ] 组件归属是否正确:≥2 页面共用 → components/;单页面独有 → views/<page>/cpns/
|
||||
- [ ] 不在组件里直接 new axios 实例 / 直接调用原生 fetch
|
||||
- [ ] 样式是否使用 **less**(`<style lang="less" scoped>` / \*.less),颜色是否取自 variables.less
|
||||
- [ ] 类型是否齐全(无 `as any`、无 `@ts-ignore`)
|
||||
- [ ] 新页面是否有完整三件套(index.vue + index.ts + cpns/)并在 router 注册
|
||||
- [ ] 运行时 `pnpm build`(含 vue-tsc 类型检查)零错误
|
||||
- [ ] 文件位于其职责对应目录(接口在 api/、状态在 stores/、页面在 views|pages/)
|
||||
- [ ] **页面是否直接 import 了 api?**(违规)
|
||||
- [ ] api / store / 页面 / 类型 是否同业务域命名一致
|
||||
- [ ] 页面目录是否只有 `index.vue`(+ 同模块次级页面)+ `cpns/`,**没有多余 `index.ts`**
|
||||
- [ ] 私有组件是否在 `cpns/` 且无其他页面 import
|
||||
- [ ] 导入使用 `@/` 别名,无多级跨层相对路径
|
||||
- [ ] 未在组件内直接 `axios` / `uni.request` / `fetch`
|
||||
- [ ] 样式沿用项目既有预处理器,色值取自变量文件
|
||||
- [ ] uni-app:新页面已在 `pages.json` 注册;未把非首屏页放进主包;页面样式未加 `scoped`
|
||||
- [ ] 无 `as any` / `@ts-ignore`
|
||||
- [ ] 构建/类型检查零错误
|
||||
|
||||
> 本规范为项目强约束:**新增与修改的代码必须严格遵循**。发现不符合结构约定的代码,应指出问题并要求按规范调整,而不是放任通过。
|
||||
> 发现不符合规范的历史代码:**指出问题并给出改法**,除非本次需求正好涉及,否则不擅自批量重构。
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# TODO(api): 待接口文档提供后填写
|
||||
VITE_API_BASE_URL=
|
||||
# 开发期走 vite 代理:/api 前缀由 vite.config.ts 转发到后端 8080 并去掉前缀
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
5
.gitignore
vendored
@@ -11,12 +11,15 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
read_prototype.js
|
||||
RTK.md
|
||||
.playwright-mcp
|
||||
.omo
|
||||
.opencode
|
||||
.workbuddy
|
||||
specs2
|
||||
.shots
|
||||
docs
|
||||
.claude
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
31
.mock/afterSales.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.tsc-out.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
.tsc-out2.txt
Normal file
@@ -0,0 +1 @@
|
||||
EXIT=0
|
||||
412
docs/PRD.md
@@ -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」「报表导出」「发送邮箱」的交互细节待确认。
|
||||
170
docs/dev-plan.md
@@ -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] |
|
||||
| 蓝湖视觉补齐 | [!] |
|
||||
| 接口联调补齐 | [!] |
|
||||
@@ -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/ # 如需自定义字体
|
||||
```
|
||||
|
||||
> 切图来源:蓝湖到位前使用原型占位图;到位后按蓝湖图层语义化命名替换。
|
||||
2131
package-lock.json
generated
Normal file
@@ -17,6 +17,7 @@
|
||||
"axios": "^1.20.0",
|
||||
"element-plus": "^2.14.5",
|
||||
"pinia": "^4.0.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"vue": "^3.5.42",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
|
||||
8
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
pinia:
|
||||
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))
|
||||
qrcode-generator:
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
vue:
|
||||
specifier: ^3.5.42
|
||||
version: 3.5.42(typescript@6.0.3)
|
||||
@@ -642,6 +645,9 @@ packages:
|
||||
prr@1.0.1:
|
||||
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
|
||||
|
||||
qrcode-generator@2.0.4:
|
||||
resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==}
|
||||
|
||||
rolldown@1.2.8:
|
||||
resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1282,6 +1288,8 @@ snapshots:
|
||||
prr@1.0.1:
|
||||
optional: true
|
||||
|
||||
qrcode-generator@2.0.4: {}
|
||||
|
||||
rolldown@1.2.8:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.149.0
|
||||
|
||||
@@ -1,44 +1,51 @@
|
||||
import { request, CLIENT_ID } from '@/utils/request'
|
||||
import type { LoginResult as AuthLoginResult, RegisterResult } from '@/types/business/auth'
|
||||
import type {
|
||||
LoginByAccountParams,
|
||||
LoginBySmsParams,
|
||||
LoginResult,
|
||||
RegisterParams,
|
||||
ResetPasswordParams,
|
||||
User,
|
||||
} from '@/types/business/user'
|
||||
import { isPhone, isValidPassword } from '@/utils/validate'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||
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}$/
|
||||
|
||||
/** 发送短信验证码 */
|
||||
export function sendSmsCode(phone: string): Promise<void> {
|
||||
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 账号密码登录(mock:demo 账号可登录,其余提示先注册) */
|
||||
export function loginByAccount(params: LoginByAccountParams): Promise<LoginResult> {
|
||||
const { account, password } = params
|
||||
if (!account.trim() || !password) {
|
||||
return Promise.reject(new Error('请输入账号和密码'))
|
||||
}
|
||||
if (account === 'demo') {
|
||||
const user: User = {
|
||||
id: 'u1',
|
||||
nickname: '王永志',
|
||||
avatar: '',
|
||||
phone: '186****618',
|
||||
email: 'yongzh******4@163.com',
|
||||
passwordModifiedAt: '',
|
||||
}
|
||||
return Promise.resolve({ token: `mock-token-${Date.now()}`, user })
|
||||
}
|
||||
return Promise.reject(new Error('登录失败,请先注册账号'))
|
||||
}
|
||||
|
||||
/** 手机验证码登录(mock:验证码须为 6 位数字) */
|
||||
export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> {
|
||||
const { phone, code } = params
|
||||
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
|
||||
@@ -69,20 +76,6 @@ function validateAuthForm(
|
||||
return null
|
||||
}
|
||||
|
||||
/** 注册 */
|
||||
export function register(params: RegisterParams): Promise<void> {
|
||||
const message = validateAuthForm(
|
||||
params.phone,
|
||||
params.code,
|
||||
params.password,
|
||||
params.confirmPassword,
|
||||
params.agree,
|
||||
)
|
||||
if (message) return Promise.reject(new Error(message))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 重置密码 */
|
||||
export function resetPassword(params: ResetPasswordParams): Promise<void> {
|
||||
const message = validateAuthForm(
|
||||
params.phone,
|
||||
|
||||
252
src/api/cases.ts
@@ -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> 调用
|
||||
|
||||
/** 案例 mock(可变,收藏状态在前端内存中翻转演示) */
|
||||
const MOCK_CASES: Case[] = [
|
||||
{
|
||||
id: 'case-001',
|
||||
title: '自动化生产线升级改造项目',
|
||||
category: 'manufacturing',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 50,
|
||||
scale: '300万',
|
||||
industry: '自动化',
|
||||
duration: '150个工作日',
|
||||
clientType: '企业',
|
||||
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
||||
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
||||
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
||||
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-002',
|
||||
title: '智能工厂物联网整体解决方案',
|
||||
category: 'iot',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 30,
|
||||
scale: '500万',
|
||||
industry: '工厂服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
|
||||
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
|
||||
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
|
||||
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-003',
|
||||
title: '智能穿戴设备PCB设计与量产',
|
||||
category: 'hardware',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 50,
|
||||
scale: '10-50万',
|
||||
industry: '智能设备',
|
||||
duration: '30个工作日',
|
||||
clientType: '企业',
|
||||
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
|
||||
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
|
||||
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
|
||||
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-004',
|
||||
title: '智能园区综合管理平台开发',
|
||||
category: 'internet',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 20,
|
||||
scale: '200万',
|
||||
industry: '园区服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。',
|
||||
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
|
||||
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。',
|
||||
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-005',
|
||||
title: '电商中台系统定制开发',
|
||||
category: 'internet',
|
||||
cover: '',
|
||||
finishDate: '2026/7/20',
|
||||
teamSize: 15,
|
||||
scale: '150万',
|
||||
industry: '电商零售',
|
||||
duration: '120个工作日',
|
||||
clientType: '企业',
|
||||
background: '品牌方多渠道订单分散,库存与会员数据不通,需要统一电商中台。',
|
||||
painPoints: '1、多平台库存不同步;2、会员体系割裂;3、营销活动上线周期长。',
|
||||
requirements: '建设商品/库存/订单/会员中台,开放 API 对接主流电商平台。',
|
||||
deliverables: '1、中台系统与管理后台;2、开放 API 与对接文档;3、数据迁移方案;4、上线陪跑服务。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-006',
|
||||
title: '教培机构在线学习平台',
|
||||
category: 'internet',
|
||||
cover: '',
|
||||
finishDate: '2026/6/30',
|
||||
teamSize: 12,
|
||||
scale: '80万',
|
||||
industry: '教育培训',
|
||||
duration: '90个工作日',
|
||||
clientType: '企业',
|
||||
background: '教培机构需要线上授课、作业批改与学员管理一体化能力。',
|
||||
painPoints: '1、授课依赖第三方工具,数据不沉淀;2、续费转化缺少数据支撑。',
|
||||
requirements: '交付直播授课、题库作业、班级管理、数据分析完整平台(Web + 小程序)。',
|
||||
deliverables: '1、Web 学习平台;2、学员端小程序;3、题库与直播模块;4、运维部署手册。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-007',
|
||||
title: '医院智能随访系统开发',
|
||||
category: 'internet',
|
||||
cover: '',
|
||||
finishDate: '2026/5/18',
|
||||
teamSize: 18,
|
||||
scale: '250万',
|
||||
industry: '医疗健康',
|
||||
duration: '180个工作日',
|
||||
clientType: '企业',
|
||||
background: '三甲医院需要对出院患者进行结构化随访,提升复诊率与科研数据质量。',
|
||||
painPoints: '1、电话随访效率低;2、随访数据非结构化,科研难复用。',
|
||||
requirements: '建设随访计划引擎、患者端触达与随访数据仓,对接 HIS 系统。',
|
||||
deliverables: '1、随访管理平台;2、患者端小程序;3、HIS 对接中间件;4、随访报表与数据字典。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-008',
|
||||
title: '园区光伏能耗管理系统',
|
||||
category: 'iot',
|
||||
cover: '',
|
||||
finishDate: '2026/4/25',
|
||||
teamSize: 10,
|
||||
scale: '120万',
|
||||
industry: '能源环保',
|
||||
duration: '60个工作日',
|
||||
clientType: '企业',
|
||||
background: '园区部署屋顶光伏后需要实时监控发电与用电,实现削峰填谷。',
|
||||
painPoints: '1、发电数据分散在多品牌逆变器;2、能耗缺少分项计量。',
|
||||
requirements: '接入逆变器/电表数据,建设发电与能耗看板,输出节能策略。',
|
||||
deliverables: '1、能耗采集网关适配;2、监控看板系统;3、告警与报表;4、验收测试报告。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
]
|
||||
|
||||
const SCALE_BOUNDS: Record<ScaleRange, [number, number]> = {
|
||||
lt50w: [0, 50],
|
||||
'50to100w': [50, 100],
|
||||
'100to300w': [100, 300],
|
||||
'300to500w': [300, 500],
|
||||
gt500w: [500, Number.POSITIVE_INFINITY],
|
||||
}
|
||||
|
||||
/** 从展示值解析规模数值(万),如 '300万' → 300,'10-50万' → 10 */
|
||||
function parseScaleValue(scale: string): number {
|
||||
const value = Number.parseFloat(scale)
|
||||
return Number.isNaN(value) ? 0 : value
|
||||
}
|
||||
|
||||
function matchScale(item: Case, selected: ScaleRange[]): boolean {
|
||||
if (selected.length === 0) return true
|
||||
const value = parseScaleValue(item.scale)
|
||||
return selected.some((key) => {
|
||||
const [min, max] = SCALE_BOUNDS[key]
|
||||
return value >= min && value < max
|
||||
export function getCaseList(
|
||||
bo: ProjectCaseUserBo,
|
||||
pageNum: number,
|
||||
pageSize: number,
|
||||
): Promise<ProjectCaseAndCaseCountVo> {
|
||||
return request<ProjectCaseAndCaseCountVo>({
|
||||
url: '/web/projectCase/list',
|
||||
method: 'get',
|
||||
params: { ...bo, pageNum, pageSize },
|
||||
paramsSerializer: { serialize: serializeQuery },
|
||||
})
|
||||
}
|
||||
|
||||
function matchDate(item: Case, range: [string, string] | null): boolean {
|
||||
if (!range) return true
|
||||
const time = new Date(item.finishDate).getTime()
|
||||
const start = new Date(range[0]).getTime()
|
||||
const end = new Date(range[1]).getTime()
|
||||
return time >= start && time <= end
|
||||
}
|
||||
|
||||
function applyFilters(query: CaseQuery): Case[] {
|
||||
return MOCK_CASES.filter((item) => {
|
||||
if (query.category !== 'all' && item.category !== query.category) return false
|
||||
if (query.industries.length > 0 && !query.industries.includes(item.industry)) return false
|
||||
if (!matchScale(item, query.scales)) return false
|
||||
if (!matchDate(item, query.dateRange)) return false
|
||||
if (query.keyword && !item.title.includes(query.keyword)) return false
|
||||
return true
|
||||
export function getCaseDetail(id: string): Promise<ProjectCaseVo | null> {
|
||||
return request<ProjectCaseVo | null>({
|
||||
url: `/web/projectCase/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 案例列表(筛选 + 分页) */
|
||||
export function getCaseList(query: CaseQuery): Promise<PageResult<Case>> {
|
||||
const filtered = applyFilters(query)
|
||||
const start = (query.page - 1) * query.pageSize
|
||||
return Promise.resolve({
|
||||
list: filtered.slice(start, start + query.pageSize),
|
||||
total: filtered.length,
|
||||
export function collectCase(id: string): Promise<void> {
|
||||
return request<void>({
|
||||
url: '/web/projectCase/collect',
|
||||
method: 'post',
|
||||
data: { id },
|
||||
})
|
||||
}
|
||||
|
||||
/** 案例详情(不存在返回 null) */
|
||||
export function getCaseDetail(id: string): Promise<Case | null> {
|
||||
const found = MOCK_CASES.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found)
|
||||
export function getCaseCategories(): Promise<CaseSelectionModel> {
|
||||
return request<CaseSelectionModel>({
|
||||
url: '/web/projectCase/categories',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 收藏/取消收藏,返回最新案例数据 */
|
||||
export function toggleFavorite(id: string): Promise<Case> {
|
||||
const found = MOCK_CASES.find((item) => item.id === id)
|
||||
if (!found) return Promise.reject(new Error('案例不存在'))
|
||||
found.isFavorite = !found.isFavorite
|
||||
return Promise.resolve({ ...found })
|
||||
}
|
||||
|
||||
/** 业务类型计数(筛选面板角标) */
|
||||
export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> {
|
||||
const counts: Partial<Record<BusinessType, number>> = {}
|
||||
for (const item of MOCK_CASES) {
|
||||
counts[item.category] = (counts[item.category] ?? 0) + 1
|
||||
}
|
||||
return Promise.resolve(counts)
|
||||
export function getFeaturedCases(type?: string): Promise<ProjectCaseVo[]> {
|
||||
return request<ProjectCaseVo[]>({
|
||||
url: '/web/projectCase/mostViewed',
|
||||
method: 'get',
|
||||
params: type ? { type } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,15 +3,12 @@ import type { Contract, ContractQuery } from '@/types/business/contract'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
|
||||
/** 构造 mock 文件信息 */
|
||||
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
|
||||
return { name, size, uploadTime, url: '' }
|
||||
}
|
||||
|
||||
/** 详情 mock 所属合同 id */
|
||||
const DETAIL_ID = 'contract-001'
|
||||
|
||||
/** 合同列表 mock(3 条,覆盖全部签署状态) */
|
||||
const MOCK_CONTRACTS: Contract[] = [
|
||||
{
|
||||
id: 'contract-001',
|
||||
@@ -56,7 +53,6 @@ const MOCK_CONTRACTS: Contract[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 合同详情 mock */
|
||||
const CONTRACT_DETAIL: Contract = {
|
||||
id: DETAIL_ID,
|
||||
contractNo: 'C8K2M202408001',
|
||||
@@ -72,7 +68,6 @@ const CONTRACT_DETAIL: Contract = {
|
||||
files: [mockFile('项目需求规格说明书.pdf', 2516582, '2024-10-15')],
|
||||
}
|
||||
|
||||
/** 合同列表(keyword 筛选 + 分页) */
|
||||
export function getContractList(query: ContractQuery): Promise<PageResult<Contract>> {
|
||||
const filtered = MOCK_CONTRACTS.filter((item) => {
|
||||
if (
|
||||
@@ -92,19 +87,16 @@ export function getContractList(query: ContractQuery): Promise<PageResult<Contra
|
||||
})
|
||||
}
|
||||
|
||||
/** 合同详情(不存在返回 null) */
|
||||
export function getContractDetail(id: string): Promise<Contract | null> {
|
||||
if (id === DETAIL_ID) return Promise.resolve({ ...CONTRACT_DETAIL, files: [...CONTRACT_DETAIL.files] })
|
||||
const found = MOCK_CONTRACTS.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found ? { ...found, files: [...found.files] } : null)
|
||||
}
|
||||
|
||||
/** 获取签署二维码(返回有效期) */
|
||||
export function getSignQr(_id: string): Promise<{ expiresInSeconds: number }> {
|
||||
return Promise.resolve({ expiresInSeconds: 272 })
|
||||
}
|
||||
|
||||
/** 刷新签署二维码(返回有效期) */
|
||||
export function refreshSignQr(_id: string): Promise<{ expiresInSeconds: number }> {
|
||||
return Promise.resolve({ expiresInSeconds: 272 })
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
/** 提交需求(mock:返回提报单 id) */
|
||||
export function submitDemand(demand: Demand): Promise<{ id: string }> {
|
||||
if (!demand.businessType || !demand.projectName || !demand.coreDescription) {
|
||||
return Promise.reject(new Error('需求信息不完整,无法提交'))
|
||||
}
|
||||
return Promise.resolve({ id: `demand-${Date.now()}` })
|
||||
export function getSubmissionBizTypes(): Promise<SubmissionBizTypeVo[]> {
|
||||
return request<SubmissionBizTypeVo[]>({
|
||||
url: '/web/projectSubmission/list',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 读取本地草稿(无草稿返回 null) */
|
||||
export function getDraftDemand(): Promise<Demand | null> {
|
||||
return Promise.resolve(storage.sessionGet<Demand>(DRAFT_KEY))
|
||||
export function getOssUrls(ids: (string | number)[]): Promise<OssUrlVo[]> {
|
||||
return request<OssUrlVo[]>({
|
||||
url: `/web/resource/oss/listByIds/${ids.join(',')}`,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存草稿到本地 */
|
||||
export function saveDraft(demand: Demand): Promise<void> {
|
||||
storage.sessionSet(DRAFT_KEY, { ...demand, status: 'draft' })
|
||||
return Promise.resolve()
|
||||
return request<void>({
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,19 +2,21 @@ import type { PageResult } from '@/types/api'
|
||||
import type {
|
||||
Bill,
|
||||
BillQuery,
|
||||
BillVo,
|
||||
FinanceStats,
|
||||
Invoice,
|
||||
InvoiceQuery,
|
||||
InvoiceTitle,
|
||||
InvoiceTitlePayload,
|
||||
PayMethod,
|
||||
PayOrderResponse,
|
||||
Payment,
|
||||
PaymentResult,
|
||||
} 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[] = [
|
||||
{
|
||||
id: 'bill-001',
|
||||
@@ -74,7 +76,6 @@ const MOCK_BILLS: Bill[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 发票 mock(可变:申请开票后状态在前端内存中翻转演示) */
|
||||
const MOCK_INVOICES: Invoice[] = [
|
||||
{
|
||||
id: 'invoice-001',
|
||||
@@ -107,7 +108,6 @@ const MOCK_INVOICES: Invoice[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 发票抬头 mock(可变,CRUD 在前端内存中演示) */
|
||||
const MOCK_TITLES: InvoiceTitle[] = [
|
||||
{
|
||||
id: 'title-001',
|
||||
@@ -125,10 +125,8 @@ const MOCK_TITLES: InvoiceTitle[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 支付进行单(billId → 当前二维码) */
|
||||
const MOCK_PAYMENTS = new Map<string, Payment>()
|
||||
|
||||
/** 已产生支付结果的账单(billId → 结果),预置已支付账单 */
|
||||
const MOCK_RESULTS = new Map<string, PaymentResult>()
|
||||
MOCK_RESULTS.set('bill-001', {
|
||||
method: 'wechat',
|
||||
@@ -157,12 +155,20 @@ const DEFAULT_STATS: FinanceStats = {
|
||||
],
|
||||
}
|
||||
|
||||
/** 财务统计(period 为展示周期参数,mock 恒返回原型数据) */
|
||||
export function getFinanceStats(period?: string): Promise<FinanceStats> {
|
||||
void period
|
||||
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 {
|
||||
if (!keyword) return true
|
||||
return (
|
||||
@@ -173,26 +179,25 @@ function matchBillKeyword(item: Bill, keyword: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** 账单列表(筛选 + 分页) */
|
||||
export function getBillList(query: BillQuery): Promise<PageResult<Bill>> {
|
||||
const filtered = MOCK_BILLS.filter((item) => {
|
||||
export async function getBillList(query: BillQuery): Promise<PageResult<Bill>> {
|
||||
const all = await fetchOwnBills()
|
||||
const filtered = all.filter((item) => {
|
||||
if (query.status !== 'all' && item.status !== query.status) return false
|
||||
return matchBillKeyword(item, query.keyword)
|
||||
})
|
||||
const start = (query.page - 1) * query.pageSize
|
||||
return Promise.resolve({
|
||||
list: filtered.slice(start, start + query.pageSize).map((item) => ({ ...item })),
|
||||
total: filtered.length,
|
||||
})
|
||||
return { list: filtered.slice(start, start + query.pageSize), total: filtered.length }
|
||||
}
|
||||
|
||||
/** 账单详情(不存在返回 null) */
|
||||
export function getBillDetail(id: string): Promise<Bill | null> {
|
||||
const found = MOCK_BILLS.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found ? { ...found } : null)
|
||||
export async function getBillDetail(id: string): Promise<Bill | null> {
|
||||
const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get', params: { ids: id } })
|
||||
return toBills(rows)[0] ?? null
|
||||
}
|
||||
|
||||
/** 创建支付单(生成二维码支付信息) */
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ---------------------------------------------------------------------
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function createPayment(billId: string, method: PayMethod): Promise<Payment> {
|
||||
const bill = MOCK_BILLS.find((item) => item.id === billId)
|
||||
if (!bill) return Promise.reject(new Error('账单不存在'))
|
||||
@@ -206,13 +211,26 @@ export function createPayment(billId: string, method: PayMethod): Promise<Paymen
|
||||
return Promise.resolve({ ...payment })
|
||||
}
|
||||
|
||||
/** 切换支付方式(刷新二维码) */
|
||||
export function switchPaymentMethod(billId: string, method: PayMethod): Promise<Payment> {
|
||||
return createPayment(billId, method)
|
||||
}
|
||||
|
||||
/** 查询支付结果(无支付记录且未支付返回 null;演示数据 payTime/tradeNo 固定) */
|
||||
export function getPayResult(billId: string): Promise<PaymentResult | null> {
|
||||
export async 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)
|
||||
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)
|
||||
if (!payment || !bill) return Promise.resolve(null)
|
||||
|
||||
// 演示:存在支付单即视为完成支付(createPayment 由「完成支付」触发)
|
||||
const tradeNo = bill.tradeNo ?? 'T20260819153035678K2M001'
|
||||
if (bill.status !== '已支付') {
|
||||
bill.status = '已支付'
|
||||
@@ -247,7 +264,6 @@ function matchInvoiceKeyword(item: Invoice, keyword: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** 发票列表(筛选 + 分页) */
|
||||
export function getInvoiceList(query: InvoiceQuery): Promise<PageResult<Invoice>> {
|
||||
const filtered = MOCK_INVOICES.filter((item) => {
|
||||
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> {
|
||||
const found = MOCK_INVOICES.find((item) => item.id === id)
|
||||
if (!found) return Promise.reject(new Error('发票记录不存在'))
|
||||
@@ -268,12 +283,10 @@ export function applyInvoice(id: string): Promise<Invoice> {
|
||||
return Promise.resolve({ ...found })
|
||||
}
|
||||
|
||||
/** 发票抬头列表 */
|
||||
export function getInvoiceTitles(): Promise<InvoiceTitle[]> {
|
||||
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
|
||||
}
|
||||
|
||||
/** 新增发票抬头 */
|
||||
export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
|
||||
const created: InvoiceTitle = {
|
||||
id: `title-${Date.now()}`,
|
||||
@@ -284,7 +297,6 @@ export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<Invoic
|
||||
return Promise.resolve({ ...created })
|
||||
}
|
||||
|
||||
/** 编辑发票抬头 */
|
||||
export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
|
||||
const found = MOCK_TITLES.find((item) => item.id === id)
|
||||
if (!found) return Promise.reject(new Error('抬头不存在'))
|
||||
@@ -294,7 +306,6 @@ export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Pr
|
||||
return Promise.resolve({ ...found })
|
||||
}
|
||||
|
||||
/** 删除发票抬头 */
|
||||
export function deleteInvoiceTitle(id: string): Promise<void> {
|
||||
const index = MOCK_TITLES.findIndex((item) => item.id === id)
|
||||
if (index < 0) return Promise.reject(new Error('抬头不存在'))
|
||||
@@ -302,7 +313,6 @@ export function deleteInvoiceTitle(id: string): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 设为默认抬头(唯一默认) */
|
||||
export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> {
|
||||
for (const item of MOCK_TITLES) {
|
||||
item.isDefault = item.id === id
|
||||
@@ -310,7 +320,58 @@ export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> {
|
||||
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
|
||||
}
|
||||
|
||||
/** 导出报表(演示:稍后由页面提示成功) */
|
||||
export function exportReport(): Promise<void> {
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
237
src/api/home.ts
@@ -1,180 +1,73 @@
|
||||
import type { BusinessType } from '@/types/api'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
import type { BusinessLine, HomeStats, ServiceType } from '@/types/business/home'
|
||||
//
|
||||
import { request } from '@/utils/request'
|
||||
import type {
|
||||
AgreementFormVo,
|
||||
BannerVo,
|
||||
PortalEntryGroup,
|
||||
PortalEntryVo,
|
||||
ProjectStatusCount,
|
||||
ProjectSubmissionInfo,
|
||||
ProjectSubmissionVo,
|
||||
} from '@/types/business/home'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 返回替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||
|
||||
const SERVICE_TYPES: ServiceType[] = [
|
||||
{ label: 'Web应用', desc: '响应式 Web 与后台管理系统定制开发' },
|
||||
{ label: '小程序', desc: '微信/支付宝小程序开发与上架' },
|
||||
{ label: 'APP', desc: 'iOS/Android 移动应用定制开发' },
|
||||
{ label: '二次开发', desc: '基于现有系统的功能扩展与重构' },
|
||||
{ label: '桌面工具', desc: '跨平台桌面端工具与自动化软件' },
|
||||
]
|
||||
|
||||
const SERVICE_CONTENT = `一、产品咨询与规划服务
|
||||
1、业务诊断与梳理:深入调研客户现有业务流程,识别数字化痛点与改进机会。
|
||||
2、竞品分析与市场调研:分析行业标杆产品,提炼差异化竞争优势。
|
||||
3、产品定位与MVP规划:明确核心用户群体,规划最小可行性产品(MVP)功能集,制定迭代路线图。
|
||||
4、技术可行性评估:评估所选技术栈的成熟度、扩展性及与现有系统的兼容性。
|
||||
二、UI/UX 设计服务
|
||||
1、用户研究与体验地图:构建用户画像,梳理用户旅程,优化关键触点体验。
|
||||
2、交互原型设计:制作高保真可交互原型,直观展示产品逻辑与操作流程,支持早期验证。
|
||||
3、视觉UI设计:遵循最新设计趋势与品牌规范,输出图标、界面、动效等视觉元素。
|
||||
4、设计规范与组件库:建立统一的设计系统(Design System),确保多端体验一致性,提升后续迭代效率。
|
||||
三、技术开发服务
|
||||
5、前端开发:基于 React/Vue/Flutter 等框架,实现响应式Web、H5、小程序及跨平台移动端应用。
|
||||
6、后端开发:采用 Spring Cloud、Node.js、Go 等微服务架构,构建高并发、高可用的业务接口与逻辑。
|
||||
7、数据库设计:根据业务特性设计关系型(MySQL)与非关系型(Redis、MongoDB)数据库集群方案。
|
||||
8、DevOps 与云服务部署:搭建 CI/CD 自动化流水线,基于 Docker/K8s 进行容器化部署,保障系统弹性伸缩。
|
||||
四、质量保障(QA)服务
|
||||
1、功能测试:覆盖单元测试、集成测试、系统测试及UAT(用户验收测试)支持。
|
||||
2、性能测试:进行压力测试、负载测试,定位系统瓶颈,优化响应速度。
|
||||
3、安全测试:实施渗透测试、漏洞扫描,保障数据安全与隐私合规。
|
||||
4、兼容性测试:覆盖主流浏览器、操作系统及移动设备型号。
|
||||
五、运维与持续运营服务
|
||||
1、7x24小时监控:实时监控系统健康度、流量及异常日志。
|
||||
2、数据运营分析:接入埋点系统,输出用户行为分析报告,指导产品优化。
|
||||
3、迭代升级:基于用户反馈与业务增长需求,提供持续的功能迭代与版本更新。`
|
||||
|
||||
const BUSINESS_LINES: BusinessLine[] = [
|
||||
{
|
||||
id: 'internet',
|
||||
name: '互联网软件',
|
||||
desc: 'Web/小程序/APP/二次开发/桌面工具等',
|
||||
slogan: '专业的软件定制开发服务,从需求到上线全流程保障',
|
||||
icon: '',
|
||||
serviceContent: SERVICE_CONTENT,
|
||||
serviceTypes: SERVICE_TYPES,
|
||||
video: '',
|
||||
},
|
||||
{
|
||||
id: 'hardware',
|
||||
name: '硬件电路',
|
||||
desc: '原理图设计/PCB设计/电气效验等',
|
||||
slogan: '专业的硬件电路设计服务,从方案到量产全流程保障',
|
||||
icon: '',
|
||||
serviceContent: SERVICE_CONTENT,
|
||||
serviceTypes: SERVICE_TYPES,
|
||||
video: '',
|
||||
},
|
||||
{
|
||||
id: 'iot',
|
||||
name: '物联网全案',
|
||||
desc: '软硬件开发+云端一体化/定制化开发等',
|
||||
slogan: '专业的物联网软硬一体化服务,从感知到云端全流程保障',
|
||||
icon: '',
|
||||
serviceContent: SERVICE_CONTENT,
|
||||
serviceTypes: SERVICE_TYPES,
|
||||
video: '',
|
||||
},
|
||||
{
|
||||
id: 'manufacturing',
|
||||
name: '生产制造',
|
||||
desc: 'PCB制版/SMT/整机装配/定制生产等',
|
||||
slogan: '专业的研发智造生产服务,从制版到交付全流程保障',
|
||||
icon: '',
|
||||
serviceContent: SERVICE_CONTENT,
|
||||
serviceTypes: SERVICE_TYPES,
|
||||
video: '',
|
||||
},
|
||||
]
|
||||
|
||||
const FEATURED_CASES: Case[] = [
|
||||
{
|
||||
id: 'case-001',
|
||||
title: '自动化生产线升级改造项目',
|
||||
category: 'manufacturing',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 50,
|
||||
scale: '300万',
|
||||
industry: '自动化',
|
||||
duration: '150个工作日',
|
||||
clientType: '企业',
|
||||
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
||||
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
||||
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
||||
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-002',
|
||||
title: '智能工厂物联网整体解决方案',
|
||||
category: 'iot',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 30,
|
||||
scale: '500万',
|
||||
industry: '工厂服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
|
||||
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
|
||||
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
|
||||
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-003',
|
||||
title: '智能穿戴设备PCB设计与量产',
|
||||
category: 'hardware',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 50,
|
||||
scale: '10-50万',
|
||||
industry: '智能设备',
|
||||
duration: '30个工作日',
|
||||
clientType: '企业',
|
||||
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
|
||||
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
|
||||
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
|
||||
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
{
|
||||
id: 'case-004',
|
||||
title: '智能园区综合管理平台开发',
|
||||
category: 'internet',
|
||||
cover: '',
|
||||
finishDate: '2026/8/10',
|
||||
teamSize: 20,
|
||||
scale: '200万',
|
||||
industry: '园区服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '随着新型城镇化建设的加速和"数字中国"战略的深入推进,传统园区管理模式正面临严峻挑战,普遍存在"数据孤岛"现象。',
|
||||
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
|
||||
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒。',
|
||||
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
|
||||
video: '',
|
||||
isFavorite: false,
|
||||
},
|
||||
]
|
||||
|
||||
/** 首页数据看板 */
|
||||
export function getHomeStats(): Promise<HomeStats> {
|
||||
return Promise.resolve({ pending: 3, inProgress: 3, total: 6 })
|
||||
export function getProjectStatusCount(): Promise<ProjectStatusCount> {
|
||||
return request<ProjectStatusCount>({
|
||||
url: '/web/home-page/project-status-count',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 业务线列表(首页 4 卡片) */
|
||||
export function getBusinessLines(): Promise<BusinessLine[]> {
|
||||
return Promise.resolve(BUSINESS_LINES)
|
||||
export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
|
||||
return request<ProjectSubmissionVo[]>({
|
||||
url: '/web/home-page/service-type-list',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 业务详情(业务详情页) */
|
||||
export function getBusinessDetail(type: BusinessType): Promise<BusinessLine> {
|
||||
const found = BUSINESS_LINES.find((line) => line.id === type)
|
||||
return Promise.resolve(found ?? BUSINESS_LINES[0]!)
|
||||
export function getServiceTypeDetail(
|
||||
id: number | string,
|
||||
): Promise<ProjectSubmissionInfo> {
|
||||
return request<ProjectSubmissionInfo>({
|
||||
url: `/web/home-page/service-type-detail/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 首页案例精选(category=all 时返回全部) */
|
||||
export function getFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<Case[]> {
|
||||
const list = category === 'all'
|
||||
? FEATURED_CASES
|
||||
: FEATURED_CASES.filter((item) => item.category === category)
|
||||
return Promise.resolve(list)
|
||||
export function getCarouselList(): Promise<BannerVo[]> {
|
||||
return request<BannerVo[]>({
|
||||
url: '/web/home-page/carousel-list',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Message, MessageQuery } from '@/types/business/message'
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
|
||||
|
||||
/** 消息 mock(可变,已读状态在前端内存中翻转演示) */
|
||||
const MOCK_MESSAGES: Message[] = [
|
||||
{
|
||||
id: 'msg-001',
|
||||
@@ -107,7 +106,6 @@ function applyFilters(query: MessageQuery): Message[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** 消息列表(筛选 + 分页) */
|
||||
export function getMessageList(query: MessageQuery): Promise<PageResult<Message>> {
|
||||
const filtered = applyFilters(query)
|
||||
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> {
|
||||
const found = MOCK_MESSAGES.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found ? { ...found } : null)
|
||||
}
|
||||
|
||||
/** 全部标记已读 */
|
||||
export function markAllRead(): Promise<void> {
|
||||
for (const item of MOCK_MESSAGES) {
|
||||
item.isRead = true
|
||||
@@ -131,14 +127,12 @@ export function markAllRead(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 单条标记已读 */
|
||||
export function markRead(id: string): Promise<void> {
|
||||
const found = MOCK_MESSAGES.find((item) => item.id === id)
|
||||
if (found) found.isRead = true
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 各分类消息总数(tab 角标) */
|
||||
export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状态通知: number; 系统通知: number }> {
|
||||
const counts = { all: MOCK_MESSAGES.length, 在线客服: 0, 状态通知: 0, 系统通知: 0 }
|
||||
for (const item of MOCK_MESSAGES) {
|
||||
@@ -147,7 +141,6 @@ export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状
|
||||
return Promise.resolve(counts)
|
||||
}
|
||||
|
||||
/** 未读消息总数(头部角标) */
|
||||
export function getUnreadCount(): Promise<number> {
|
||||
return Promise.resolve(MOCK_MESSAGES.filter((item) => !item.isRead).length)
|
||||
}
|
||||
|
||||
8
src/api/oss.ts
Normal 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
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
|
||||
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
|
||||
|
||||
/** 账号资料 mock(可变,昵称/绑定等操作在前端内存中演示) */
|
||||
const MOCK_PROFILE: Profile = {
|
||||
nickname: '王永志',
|
||||
phone: '186****618',
|
||||
@@ -22,57 +21,48 @@ const MOCK_PROFILE: Profile = {
|
||||
passwordModifiedAt: '15天前',
|
||||
}
|
||||
|
||||
/** 获取账号资料 */
|
||||
export function getProfile(): Promise<Profile> {
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 修改昵称 */
|
||||
export function updateNickname(name: string): Promise<Profile> {
|
||||
MOCK_PROFILE.nickname = name
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 修改密码(验证码 + 新密码) */
|
||||
export function changePassword(params: ChangePasswordParams): Promise<void> {
|
||||
MOCK_PROFILE.passwordModifiedAt = '刚刚'
|
||||
void params
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 绑定/换绑手机号 */
|
||||
export function bindPhone(phone: string, code: string): Promise<Profile> {
|
||||
void code
|
||||
MOCK_PROFILE.phone = phone
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 绑定/换绑邮箱 */
|
||||
export function bindEmail(email: string, code: string): Promise<Profile> {
|
||||
void code
|
||||
MOCK_PROFILE.email = email
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 取消绑定邮箱 */
|
||||
export function unbindEmail(): Promise<Profile> {
|
||||
MOCK_PROFILE.email = ''
|
||||
return Promise.resolve({ ...MOCK_PROFILE })
|
||||
}
|
||||
|
||||
/** 注销前安全身份验证 */
|
||||
export function verifyIdentity(phone: string, code: string): Promise<void> {
|
||||
void phone
|
||||
void code
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 注销账号 */
|
||||
export function deleteAccount(): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 收货地址 mock(isDefault 混合) */
|
||||
const MOCK_ADDRESSES: Address[] = [
|
||||
{
|
||||
id: 'addr-1',
|
||||
@@ -116,12 +106,10 @@ function paginate<T>(list: T[], query: ListQuery): PageResult<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 收货地址列表(分页) */
|
||||
export function getAddresses(query: ListQuery): Promise<PageResult<Address>> {
|
||||
return Promise.resolve(paginate(MOCK_ADDRESSES, query))
|
||||
}
|
||||
|
||||
/** 新增收货地址 */
|
||||
export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
|
||||
const created: Address = {
|
||||
...input,
|
||||
@@ -132,7 +120,6 @@ export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise
|
||||
return Promise.resolve({ ...created })
|
||||
}
|
||||
|
||||
/** 编辑收货地址 */
|
||||
export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
|
||||
const found = MOCK_ADDRESSES.find((item) => item.id === id)
|
||||
if (!found) return Promise.reject(new Error('地址不存在'))
|
||||
@@ -140,14 +127,12 @@ export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault
|
||||
return Promise.resolve({ ...found })
|
||||
}
|
||||
|
||||
/** 删除收货地址 */
|
||||
export function deleteAddress(id: string): Promise<void> {
|
||||
const index = MOCK_ADDRESSES.findIndex((item) => item.id === id)
|
||||
if (index >= 0) MOCK_ADDRESSES.splice(index, 1)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 设为默认地址 */
|
||||
export function setDefaultAddress(id: string): Promise<Address[]> {
|
||||
for (const item of MOCK_ADDRESSES) {
|
||||
item.isDefault = item.id === id
|
||||
@@ -155,7 +140,6 @@ export function setDefaultAddress(id: string): Promise<Address[]> {
|
||||
return Promise.resolve(MOCK_ADDRESSES.map((item) => ({ ...item })))
|
||||
}
|
||||
|
||||
/** 我的收藏 mock(4 条已收藏案例,字段与案例库原型一致) */
|
||||
const MOCK_FAVORITES: Case[] = [
|
||||
{
|
||||
id: 'fav-1',
|
||||
@@ -168,12 +152,15 @@ const MOCK_FAVORITES: Case[] = [
|
||||
industry: '自动化',
|
||||
duration: '150个工作日',
|
||||
clientType: '企业',
|
||||
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
|
||||
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
|
||||
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
|
||||
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
|
||||
sections: [
|
||||
{ title: '项目背景', content: '<p>传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。</p>' },
|
||||
{ title: '客户痛点', content: '<p>1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。</p>' },
|
||||
{ title: '需求描述', content: '<p>建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。</p>' },
|
||||
{ title: '交付物', content: '<p>1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。</p>' },
|
||||
],
|
||||
video: '',
|
||||
isFavorite: true,
|
||||
favoriteCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'fav-2',
|
||||
@@ -186,12 +173,15 @@ const MOCK_FAVORITES: Case[] = [
|
||||
industry: '工厂服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
|
||||
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
|
||||
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
|
||||
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
|
||||
sections: [
|
||||
{ title: '项目背景', content: '<p>园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。</p>' },
|
||||
{ title: '客户痛点', content: '<p>1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。</p>' },
|
||||
{ title: '需求描述', content: '<p>构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。</p>' },
|
||||
{ title: '交付物', content: '<p>1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。</p>' },
|
||||
],
|
||||
video: '',
|
||||
isFavorite: true,
|
||||
favoriteCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'fav-3',
|
||||
@@ -204,12 +194,15 @@ const MOCK_FAVORITES: Case[] = [
|
||||
industry: '智能设备',
|
||||
duration: '30个工作日',
|
||||
clientType: '企业',
|
||||
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
|
||||
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
|
||||
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
|
||||
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
|
||||
sections: [
|
||||
{ title: '项目背景', content: '<p>客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。</p>' },
|
||||
{ title: '客户痛点', content: '<p>1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。</p>' },
|
||||
{ title: '需求描述', content: '<p>完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。</p>' },
|
||||
{ title: '交付物', content: '<p>1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。</p>' },
|
||||
],
|
||||
video: '',
|
||||
isFavorite: true,
|
||||
favoriteCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'fav-4',
|
||||
@@ -222,16 +215,18 @@ const MOCK_FAVORITES: Case[] = [
|
||||
industry: '园区服务',
|
||||
duration: '200个工作日',
|
||||
clientType: '企业',
|
||||
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。',
|
||||
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
|
||||
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。',
|
||||
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
|
||||
sections: [
|
||||
{ title: '项目背景', content: '<p>传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。</p>' },
|
||||
{ title: '客户痛点', content: '<p>1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。</p>' },
|
||||
{ title: '需求描述', content: '<p>打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。</p>' },
|
||||
{ title: '交付物', content: '<p>1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。</p>' },
|
||||
],
|
||||
video: '',
|
||||
isFavorite: true,
|
||||
favoriteCount: 0,
|
||||
},
|
||||
]
|
||||
|
||||
/** 我的收藏(关键字/分类筛选 + 分页) */
|
||||
export function getFavorites(query: FavoritesQuery): Promise<PageResult<Case>> {
|
||||
const filtered = MOCK_FAVORITES.filter((item) => {
|
||||
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>>> {
|
||||
const counts: Partial<Record<BusinessType, number>> = {
|
||||
internet: 1,
|
||||
@@ -256,7 +250,6 @@ export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>>
|
||||
return Promise.resolve(counts)
|
||||
}
|
||||
|
||||
/** 资质 mock(状态混合:已认证/审核中/未通过) */
|
||||
const MOCK_QUALIFICATIONS: Qualification[] = [
|
||||
{
|
||||
id: 'qual-1',
|
||||
@@ -312,12 +305,10 @@ const MOCK_QUALIFICATIONS: Qualification[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** 资质列表(分页) */
|
||||
export function getQualifications(query: ListQuery): Promise<PageResult<Qualification>> {
|
||||
return Promise.resolve(paginate(MOCK_QUALIFICATIONS, query))
|
||||
}
|
||||
|
||||
/** 新增资质(提交后进入审核中) */
|
||||
export function createQualification(input: QualificationInput): Promise<Qualification> {
|
||||
const created: Qualification = {
|
||||
...input,
|
||||
@@ -328,7 +319,6 @@ export function createQualification(input: QualificationInput): Promise<Qualific
|
||||
return Promise.resolve({ ...created })
|
||||
}
|
||||
|
||||
/** 编辑资质(未通过重新提交后回到审核中) */
|
||||
export function updateQualification(id: string, input: QualificationInput): Promise<Qualification> {
|
||||
const found = MOCK_QUALIFICATIONS.find((item) => item.id === id)
|
||||
if (!found) return Promise.reject(new Error('资质不存在'))
|
||||
@@ -343,74 +333,72 @@ export function updateQualification(id: string, input: QualificationInput): Prom
|
||||
return Promise.resolve({ ...found })
|
||||
}
|
||||
|
||||
/** 删除资质 */
|
||||
export function deleteQualification(id: string): Promise<void> {
|
||||
const index = MOCK_QUALIFICATIONS.findIndex((item) => item.id === id)
|
||||
if (index >= 0) MOCK_QUALIFICATIONS.splice(index, 1)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 提交意见反馈 */
|
||||
export function submitFeedback(feedback: Feedback): Promise<void> {
|
||||
void feedback
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 帮助中心 FAQ */
|
||||
/** TODO(api): 后端帮助中心接口就绪后改为 request 调用;暂用设计稿假数据 */
|
||||
export function getHelpFaqs(): Promise<HelpFaqGroup[]> {
|
||||
return Promise.resolve([
|
||||
{
|
||||
title: '1. 账户与登录',
|
||||
title: '账户与登录',
|
||||
items: [
|
||||
{
|
||||
q: '如何注册账号?',
|
||||
a: '点击页面右上角"注册",输入手机号并获取验证码,设置 6-20 位登录密码即可完成注册。',
|
||||
q: '如何注册账号?',
|
||||
a: '打开APP后点击“注册”,输入手机号获取验证码,设置登录密码即可完成注册。',
|
||||
},
|
||||
{
|
||||
q: '忘记密码怎么办?',
|
||||
a: '在登录页点击"忘记密码",通过绑定手机号验证码验证身份后即可重新设置密码。',
|
||||
q: '忘记密码怎么办?',
|
||||
a: '在登录页面点击“忘记密码”,输入注册手机号获取验证码,验证通过后即可重置新密码。',
|
||||
},
|
||||
{
|
||||
q: '一个账号可以多地登录吗?',
|
||||
a: '同一账号同时仅允许在 1 台设备上保持在线,超出将自动踢出最早登录的设备。',
|
||||
q: '一个账号可以多端登录吗?',
|
||||
a: '同一账号仅允许在 1 台设备上保持在线,超出将自动踢出最早登录的设备。',
|
||||
},
|
||||
{
|
||||
q: '如何修改绑定手机号?',
|
||||
a: '进入 个人中心 → 账号安全 → 绑定手机,验证原手机号后即可绑定新号码。',
|
||||
q: '如何修改绑定手机号?',
|
||||
a: '进入「我的 -> 设置 -> 账户安全 -> 更换手机号」,验证原手机号后即可绑定新号码。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '2. 功能使用指南',
|
||||
title: '功能使用指南',
|
||||
items: [
|
||||
{
|
||||
q: '如何提交需求?',
|
||||
a: '进入"发布需求"页面,填写需求描述、预算、周期等信息并提交,专业顾问将在一个工作日内与您联系。',
|
||||
q: '如何提交报修/工单?',
|
||||
a: '首页点击「报修」 -> 选择报修类型 -> 填写问题描述并上传图片 -> 提交。提交后可在「我的 -> 我的工单」中查看处理进度。',
|
||||
},
|
||||
{
|
||||
q: '如何查看我的消息?',
|
||||
a: '登录后点击右上角消息图标,或进入 个人中心 → 消息通知,即可查看系统通知与顾问消息。',
|
||||
q: '如何预约会议室?',
|
||||
a: '进入「服务 -> 会议室预订」,选择日期、时间段及会议室,确认后提交预约,预约成功将收到消息通知。',
|
||||
},
|
||||
{
|
||||
q: '如何申请售后?',
|
||||
a: '在"我的订单"中找到对应项目,点击"申请售后"填写问题描述并上传附件,平台将尽快为您处理。',
|
||||
q: '如何查看我的消息通知?',
|
||||
a: '首页右上角铃铛图标进入消息中心,支持按“全部 / 未读 / 系统通知 / 业务提醒”分类查看。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '3. 系统与权限',
|
||||
title: '系统与权限',
|
||||
items: [
|
||||
{
|
||||
q: '无权限怎么办?',
|
||||
a: '请确认当前账号角色是否具备该模块权限;如仍无法访问,请联系专属客服或平台管理员为您开通。',
|
||||
q: 'APP提示“无权限”怎么办?',
|
||||
a: '部分功能需要管理员授权后方可使用,请联系贵司系统管理员为您分配对应角色权限。',
|
||||
},
|
||||
{
|
||||
q: '收不到通知怎么办?',
|
||||
a: '请检查浏览器通知权限与消息设置是否开启;若仍未收到,请联系客服核实账号通知配置。',
|
||||
q: '为什么收不到推送通知?',
|
||||
a: '请检查:①手机系统设置中是否允许本APP发送通知;②APP内「我的 -> 设置 -> 消息推送」是否已开启。',
|
||||
},
|
||||
{
|
||||
q: '支持哪些浏览器?',
|
||||
a: '推荐使用 Chrome、Edge、Firefox、Safari 等主流浏览器的最新版本访问本平台。',
|
||||
q: 'APP支持哪些系统版本?',
|
||||
a: 'iOS 13.0 及以上版本,Android 8.0 及以上版本。建议使用最新系统版本以获得最佳体验。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,270 +1,177 @@
|
||||
import type { FileInfo, PageResult } from '@/types/api'
|
||||
import type {
|
||||
AfterSales,
|
||||
AfterSalesAttachment,
|
||||
AfterSalesPayload,
|
||||
Project,
|
||||
AfterSalesUploadVo,
|
||||
ProjectListResult,
|
||||
ProjectQuery,
|
||||
ProjectStatistics,
|
||||
RdProjectVo,
|
||||
RequirementDetailBundle,
|
||||
} from '@/types/business/project'
|
||||
import { request, serializeQuery } from '@/utils/request'
|
||||
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 {
|
||||
return { name, size, uploadTime, url: '' }
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /web/requirementPool/getFilterConditions
|
||||
// → 控制器 ProjectRequirementUserController#getFilterConditions
|
||||
// → 服务层 ProjectRequirementServiceImpl#getFilterConditions
|
||||
// 业务类型统计 businessTypeCounts: [{ businessTypeId, businessTypeLabel, count }]
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
/** 详情 mock 所属项目 id */
|
||||
const DETAIL_ID = 'project-004'
|
||||
|
||||
/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合) */
|
||||
const MOCK_PROJECTS: Project[] = [
|
||||
{
|
||||
id: 'project-001',
|
||||
projectNo: 'P8K2M202408001',
|
||||
projectName: '某大型银行数字化转型方案',
|
||||
businessType: 'internet',
|
||||
progress: 64,
|
||||
status: '待立项',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
},
|
||||
{
|
||||
id: 'project-002',
|
||||
projectNo: 'P8K2M202408002',
|
||||
projectName: '智慧城市大数据平台建设项目',
|
||||
businessType: 'iot',
|
||||
progress: 64,
|
||||
status: '待验收',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
},
|
||||
{
|
||||
id: 'project-003',
|
||||
projectNo: 'P8K2M202408003',
|
||||
projectName: '三甲医院指挥医疗信息化系统',
|
||||
businessType: 'internet',
|
||||
progress: 64,
|
||||
status: '已完成',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
},
|
||||
{
|
||||
id: DETAIL_ID,
|
||||
projectNo: '20260813RJ251545',
|
||||
projectName: '社区康养智能服务系统',
|
||||
businessType: 'internet',
|
||||
progress: 64,
|
||||
status: '进行中',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
},
|
||||
{
|
||||
id: 'project-005',
|
||||
projectNo: 'P8K2M202408005',
|
||||
projectName: '智能仓储物流分拣系统',
|
||||
businessType: 'iot',
|
||||
progress: 64,
|
||||
status: '售后中',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
afterSales: {
|
||||
status: '处理中',
|
||||
type: 'BUG问题',
|
||||
description: '分拣识别偶发误判,需要排查',
|
||||
remark: '',
|
||||
attachments: [],
|
||||
createTime: '2026-8-19 17:00',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'project-006',
|
||||
projectNo: 'P8K2M202408006',
|
||||
projectName: '新能源电站运维监控平台',
|
||||
businessType: 'internet',
|
||||
progress: 64,
|
||||
status: '已取消',
|
||||
submitTime: '2026-8-17 14:00',
|
||||
milestones: [],
|
||||
team: [],
|
||||
files: [],
|
||||
paymentPlans: [],
|
||||
invoiceRecords: [],
|
||||
},
|
||||
]
|
||||
|
||||
/** 项目详情 mock(社区康养智能服务系统) */
|
||||
const PROJECT_DETAIL: Project = {
|
||||
...MOCK_PROJECTS[3],
|
||||
progress: 65,
|
||||
startTime: '2026-8-13 11:04:12',
|
||||
endTime: '2026-10-30 18:00:00',
|
||||
milestones: [
|
||||
{ name: '需求确认', status: '已完成', time: '2026-8-13 11:04:12' },
|
||||
{ name: '原型设计', status: '已完成', time: '2026-8-13 11:04:12' },
|
||||
{ name: '开发实现', status: '进行中' },
|
||||
{ name: '测试验收', status: '待开始' },
|
||||
{ name: '项目交付', status: '待开始' },
|
||||
],
|
||||
team: [
|
||||
{ role: '项目经理', name: '张三', avatarChar: '张' },
|
||||
{ role: '项目经理', name: '张三', avatarChar: '张' },
|
||||
],
|
||||
files: [
|
||||
mockFile('项目需求规格说明书.pdf', 2516582, '2026-08-19 17:00'),
|
||||
mockFile('项目需求说明书.word', 1887437, '2026-08-19 17:00'),
|
||||
],
|
||||
paymentPlans: [
|
||||
{
|
||||
id: 'bill-001',
|
||||
planName: '第一期·预付款(比例33%)',
|
||||
billNo: '20260813ZZ251245',
|
||||
amount: 20100,
|
||||
status: '已支付',
|
||||
tradeNo: 'T20260819153035678K2M001',
|
||||
projectName: '社区康养智能服务系统',
|
||||
},
|
||||
{
|
||||
id: 'bill-002',
|
||||
planName: '第二期·付款(比例33%)',
|
||||
billNo: '20260813ZZ251245',
|
||||
amount: 20100,
|
||||
status: '待支付',
|
||||
projectName: '社区康养智能服务系统',
|
||||
},
|
||||
{
|
||||
id: 'bill-003',
|
||||
planName: '第三期·尾款(比例33%)',
|
||||
billNo: '20260813ZZ251245',
|
||||
amount: 20100,
|
||||
status: '未到期',
|
||||
projectName: '社区康养智能服务系统',
|
||||
},
|
||||
],
|
||||
invoiceRecords: [
|
||||
mockFile('增值税专用发票.pdf', 325120, '2026-08-19 17:00'),
|
||||
mockFile('电子回单.jpg', 186368, '2026-08-19 17:00'),
|
||||
],
|
||||
contractFile: mockFile('社区康养智能服务系统开发合同.pdf', 1039360, '2026-08-19 17:00'),
|
||||
afterSales: {
|
||||
status: '处理中',
|
||||
type: 'BUG问题',
|
||||
description: '具体请看清单',
|
||||
remark: '',
|
||||
attachments: [],
|
||||
createTime: '2026-8-19 17:00',
|
||||
},
|
||||
function toDateTime(date: Date, endOfDay: boolean): string {
|
||||
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
return `${ymd} ${endOfDay ? '23:59:59' : '00:00:00'}`
|
||||
}
|
||||
|
||||
/** 提交时间快捷区间匹配 */
|
||||
function matchTimeShortcut(submitTime: string, shortcut: ProjectQuery['timeShortcut']): boolean {
|
||||
if (shortcut === 'all') return true
|
||||
// mock 时间为 '2026-8-17 14:00',替换为 / 保证各环境可解析
|
||||
const time = new Date(submitTime.replaceAll('-', '/')).getTime()
|
||||
function toTimeRange(query: ProjectQuery): { beginTime?: string; endTime?: string } {
|
||||
if (query.startDate || query.endDate) {
|
||||
return {
|
||||
beginTime: query.startDate ? `${query.startDate} 00:00:00` : undefined,
|
||||
endTime: query.endDate ? `${query.endDate} 23:59:59` : undefined,
|
||||
}
|
||||
}
|
||||
if (query.timeShortcut === 'all') return {}
|
||||
|
||||
const start = new Date()
|
||||
if (shortcut === '3m') start.setMonth(start.getMonth() - 3)
|
||||
if (shortcut === '6m') start.setMonth(start.getMonth() - 6)
|
||||
if (shortcut === '1y') start.setFullYear(start.getFullYear() - 1)
|
||||
return time >= start.getTime()
|
||||
if (query.timeShortcut === '3m') start.setMonth(start.getMonth() - 3)
|
||||
else if (query.timeShortcut === '6m') start.setMonth(start.getMonth() - 6)
|
||||
else start.setFullYear(start.getFullYear() - 1)
|
||||
return { beginTime: toDateTime(start, false), endTime: toDateTime(new Date(), true) }
|
||||
}
|
||||
|
||||
/** 项目列表(筛选 + 分页,pageSize 固定 8) */
|
||||
export function getProjectList(query: ProjectQuery): Promise<PageResult<Project>> {
|
||||
const filtered = MOCK_PROJECTS.filter((item) => {
|
||||
if (
|
||||
query.keyword
|
||||
&& !item.projectName.includes(query.keyword)
|
||||
&& !item.projectNo.includes(query.keyword)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (query.businessType !== 'all' && item.businessType !== query.businessType) return false
|
||||
if (query.status !== 'all' && item.status !== query.status) return false
|
||||
if (!matchTimeShortcut(item.submitTime, query.timeShortcut)) return false
|
||||
return true
|
||||
})
|
||||
const pageSize = query.pageSize > 0 ? query.pageSize : 8
|
||||
const start = (query.page - 1) * pageSize
|
||||
return Promise.resolve({
|
||||
list: filtered.slice(start, start + pageSize),
|
||||
total: filtered.length,
|
||||
export function getProjectList(query: ProjectQuery): Promise<ProjectListResult> {
|
||||
return request<ProjectListResult>({
|
||||
url: '/web/requirementPool/list',
|
||||
method: 'get',
|
||||
paramsSerializer: { serialize: serializeQuery },
|
||||
params: {
|
||||
pageNum: query.pageNum,
|
||||
pageSize: query.pageSize,
|
||||
key: query.keyword || undefined,
|
||||
businessTypes: query.businessTypes.length > 0 ? [...query.businessTypes] : undefined,
|
||||
status: query.status || undefined,
|
||||
...toTimeRange(query),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 项目详情(不存在返回 null;仅详情项目返回完整数据) */
|
||||
export function getProjectDetail(id: string): Promise<Project | null> {
|
||||
if (id === DETAIL_ID) return Promise.resolve({ ...PROJECT_DETAIL })
|
||||
const found = MOCK_PROJECTS.find((item) => item.id === id) ?? null
|
||||
return Promise.resolve(found ? { ...found } : null)
|
||||
export function getProjectFilterConditions(): Promise<ProjectStatistics> {
|
||||
return request<ProjectStatistics>({
|
||||
url: '/web/requirementPool/getFilterConditions',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
/** 上传项目文件(追加进详情文件列表) */
|
||||
export function uploadProjectFile(file: File): Promise<FileInfo> {
|
||||
const info: FileInfo = {
|
||||
name: file.name,
|
||||
export function getCompletedProjects(): Promise<RdProjectVo[]> {
|
||||
return request<ProjectListResult>({
|
||||
url: '/web/requirementPool/list',
|
||||
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,
|
||||
url: vo.url,
|
||||
uploadTime: formatDate(new Date()),
|
||||
url: '',
|
||||
}
|
||||
PROJECT_DETAIL.files.push(info)
|
||||
return Promise.resolve({ ...info })
|
||||
}))
|
||||
}
|
||||
|
||||
/** 删除项目文件(按索引从详情文件列表移除) */
|
||||
export function deleteProjectFile(index: number): Promise<void> {
|
||||
if (index >= 0 && index < PROJECT_DETAIL.files.length) {
|
||||
PROJECT_DETAIL.files.splice(index, 1)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** 提交售后工单 */
|
||||
export function submitAfterSales(
|
||||
projectId: string,
|
||||
id: string,
|
||||
payload: AfterSalesPayload,
|
||||
): Promise<AfterSales> {
|
||||
const result: AfterSales = {
|
||||
status: '处理中',
|
||||
): Promise<void> {
|
||||
return request<void>({
|
||||
url: '/web/requirementPool/afterSales/request',
|
||||
method: 'post',
|
||||
data: {
|
||||
id,
|
||||
type: payload.type,
|
||||
description: payload.description,
|
||||
remark: payload.remark,
|
||||
attachments: [...payload.attachments],
|
||||
createTime: formatDate(new Date()),
|
||||
}
|
||||
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
|
||||
return Promise.resolve({ ...result, attachments: [...result.attachments] })
|
||||
reason: payload.reason,
|
||||
note: payload.note,
|
||||
attachmentOssIds: payload.attachmentOssIds,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 取消售后工单 */
|
||||
export function cancelAfterSales(projectId: string): Promise<AfterSales> {
|
||||
const result: AfterSales = {
|
||||
status: '无',
|
||||
type: '',
|
||||
description: '',
|
||||
remark: '',
|
||||
attachments: [],
|
||||
createTime: '',
|
||||
}
|
||||
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
|
||||
return Promise.resolve({ ...result })
|
||||
export function cancelAfterSales(id: string, reason = ''): Promise<void> {
|
||||
return request<void>({
|
||||
url: '/web/requirementPool/afterSales/withdraw',
|
||||
method: 'post',
|
||||
data: { id, reason },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ function nowTime(): string {
|
||||
return `今天 ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
/** 历史会话(欢迎语 + 用户提问 + 文件资料) */
|
||||
export function getChatHistory(): Promise<ChatMessage[]> {
|
||||
return Promise.resolve([
|
||||
{
|
||||
@@ -40,7 +39,6 @@ export function getChatHistory(): Promise<ChatMessage[]> {
|
||||
])
|
||||
}
|
||||
|
||||
/** 发送文本消息(echo 用户消息,携带当前时间) */
|
||||
export function sendChatMessage(content: string): Promise<ChatMessage> {
|
||||
return Promise.resolve({
|
||||
id: nextId('chat'),
|
||||
@@ -50,7 +48,6 @@ export function sendChatMessage(content: string): Promise<ChatMessage> {
|
||||
})
|
||||
}
|
||||
|
||||
/** 上传聊天文件(生成文件卡片消息) */
|
||||
export function uploadChatFile(file: File): Promise<ChatMessage> {
|
||||
return Promise.resolve({
|
||||
id: nextId('chat'),
|
||||
@@ -61,7 +58,6 @@ export function uploadChatFile(file: File): Promise<ChatMessage> {
|
||||
})
|
||||
}
|
||||
|
||||
/** 客服自动回复 */
|
||||
export function mockAutoReply(): Promise<ChatMessage> {
|
||||
return Promise.resolve({
|
||||
id: nextId('chat'),
|
||||
|
||||
10
src/api/user.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { request } from '@/utils/request'
|
||||
import type { UserProfileVo } from '@/types/business/user'
|
||||
|
||||
export async function getUserProfile(): Promise<UserProfileVo> {
|
||||
const data = await request<{ user: UserProfileVo }>({
|
||||
url: '/web/system/user/profile',
|
||||
method: 'get',
|
||||
})
|
||||
return data.user
|
||||
}
|
||||
BIN
src/assets/home/banner-illustration.png
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
src/assets/home/business-hardware.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/home/business-internet.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/assets/home/business-iot.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src/assets/home/business-manufacturing.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/home/chahua.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src/assets/home/entry-cases.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src/assets/home/entry-contract.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
src/assets/home/entry-demand.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src/assets/home/entry-finance.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src/assets/home/entry-service.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/icons/online-service.png
Normal file
|
After Width: | Height: | Size: 422 B |
BIN
src/assets/icons/quick-report.png
Normal file
|
After Width: | Height: | Size: 407 B |
@@ -22,28 +22,28 @@ const coreBusiness = [
|
||||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<div class="app-footer__inner container">
|
||||
<div class="app-footer__inner">
|
||||
<div class="app-footer__about">
|
||||
<div class="app-footer__brand">
|
||||
<!-- TODO(蓝湖): 替换 logo 切图 -->
|
||||
<img src="@/assets/vue.svg" class="app-footer__logo" alt="虫子智造 logo">
|
||||
<span class="app-footer__name">虫子智造</span>
|
||||
</div>
|
||||
<p class="app-footer__desc">
|
||||
研发智造一体化自营平台,打通研发‑仿真‑设计‑PCB‑SMT‑整机装配‑售后迭代全链路,为科技企业、硬件厂商、创客团队提供软硬一体化定制与量产落地服务。平台自营不开放第三方服务商入驻,对项目质量全权兜底。
|
||||
</p>
|
||||
<p class="app-footer__legal">
|
||||
©2026 虫子智造 研发智造一体化平台 版权所有|自营研发生产平台,所有项目源码、图纸受平台知识产权保护,禁止盗用、复制、二次分发
|
||||
</p>
|
||||
<p class="app-footer__legal">
|
||||
{{ siteStore.siteInfo.icp }} | {{ siteStore.siteInfo.police }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col">
|
||||
<h4 class="app-footer__title">
|
||||
快速导航
|
||||
</h4>
|
||||
<div class="app-footer__col app-footer__col--core">
|
||||
<h4 class="app-footer__title">核心业务</h4>
|
||||
<span
|
||||
v-for="item in coreBusiness"
|
||||
:key="item"
|
||||
class="app-footer__link"
|
||||
>{{ item }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col app-footer__col--nav">
|
||||
<h4 class="app-footer__title">快速导航</h4>
|
||||
<RouterLink
|
||||
v-for="link in quickLinks"
|
||||
:key="link.label"
|
||||
@@ -54,93 +54,135 @@ const coreBusiness = [
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col">
|
||||
<h4 class="app-footer__title">
|
||||
核心业务
|
||||
</h4>
|
||||
<span
|
||||
v-for="item in coreBusiness"
|
||||
:key="item"
|
||||
class="app-footer__link"
|
||||
>{{ item }}</span>
|
||||
<div class="app-footer__col app-footer__col--contact">
|
||||
<h4 class="app-footer__title">联系我们</h4>
|
||||
<span class="app-footer__contact"
|
||||
>商务合作:{{ siteStore.siteInfo.businessEmail }}</span
|
||||
>
|
||||
<span class="app-footer__contact"
|
||||
>技术支持:{{ siteStore.siteInfo.supportEmail }}</span
|
||||
>
|
||||
<span class="app-footer__contact"
|
||||
>客服热线:{{ siteStore.siteInfo.hotline }}</span
|
||||
>
|
||||
<span class="app-footer__contact"
|
||||
>工作时间:{{ siteStore.siteInfo.workTime }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-footer__col">
|
||||
<h4 class="app-footer__title">
|
||||
联系我们
|
||||
</h4>
|
||||
<span class="app-footer__contact">商务合作:{{ siteStore.siteInfo.businessEmail }}</span>
|
||||
<span class="app-footer__contact">技术支持:{{ siteStore.siteInfo.supportEmail }}</span>
|
||||
<span class="app-footer__contact">客服热线:{{ siteStore.siteInfo.hotline }}</span>
|
||||
<span class="app-footer__contact">工作时间:{{ siteStore.siteInfo.workTime }}</span>
|
||||
</div>
|
||||
<div class="app-footer__bottom">
|
||||
<p class="app-footer__legal">
|
||||
©2026 虫子智造 研发智造一体化平台
|
||||
版权所有|自营研发生产平台,所有项目源码、图纸受平台知识产权保护,禁止盗用、复制、二次分发
|
||||
</p>
|
||||
<p class="app-footer__legal app-footer__legal--icp">
|
||||
{{ siteStore.siteInfo.icp }} | {{ siteStore.siteInfo.police }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.app-footer {
|
||||
background: @heading-color;
|
||||
color: @text-color-secondary;
|
||||
background: @footer-bg;
|
||||
color: @white;
|
||||
padding: @spacing-xl 0 @spacing-base;
|
||||
|
||||
&__inner {
|
||||
.flex(row, space-between, flex-start);
|
||||
flex-wrap: wrap;
|
||||
gap: @spacing-xl;
|
||||
padding-top: @spacing-xxl;
|
||||
padding-bottom: @spacing-xxl;
|
||||
width: 100%;
|
||||
max-width: @page-content;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 @page-gutter;
|
||||
}
|
||||
|
||||
&__about {
|
||||
max-width: 480px;
|
||||
flex: 0 1 360px;
|
||||
max-width: 420px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__brand {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
margin-bottom: @spacing-base;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: @spacing-sm;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&__desc,
|
||||
&__legal {
|
||||
margin-top: @spacing-sm;
|
||||
&__desc {
|
||||
margin: 0;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: fade(@white, 72%);
|
||||
}
|
||||
|
||||
&__col {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-sm;
|
||||
.flex(column, flex-start, flex-start);
|
||||
gap: 2px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: @spacing-sm;
|
||||
margin: 0 0 @spacing-sm;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 400;
|
||||
line-height: 1.7;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
color: fade(@white, 72%);
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
color: @primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
&__contact {
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMessageStore } from '@/stores/modules/message'
|
||||
|
||||
@@ -18,36 +19,64 @@ const navItems: NavItem[] = [
|
||||
{ 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 { unreadCount } = storeToRefs(messageStore)
|
||||
|
||||
onMounted(() => void messageStore.fetchUnreadCount())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="app-header__inner container">
|
||||
<RouterLink to="/" class="app-header__logo">
|
||||
<!-- TODO(蓝湖): 替换 logo 切图 -->
|
||||
<img src="@/assets/vue.svg" class="app-header__logo-icon" alt="虫子智造 logo">
|
||||
<span class="app-header__logo-text">虫子智造</span>
|
||||
</RouterLink>
|
||||
|
||||
<div class="app-header__inner">
|
||||
<nav class="app-header__nav">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="app-header__nav-item"
|
||||
:class="{ 'app-header__nav-item--active': isActive(item) }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<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 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 to="/login" class="app-header__login link">
|
||||
登录
|
||||
@@ -63,76 +92,113 @@ onMounted(() => void messageStore.fetchUnreadCount())
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: @header-height;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
background: @header-bg;
|
||||
border-bottom: 1px solid @header-border;
|
||||
|
||||
&__inner {
|
||||
.flex(row, space-between);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
|
||||
&-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: @font-size-xl;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
}
|
||||
height: 100%;
|
||||
// 设计稿 1920 基准:导航首项左 275px,操作区右边界 1800px(右侧留白 120px)。
|
||||
// 视口窄于 1920 时按留白收缩,避免两端顶死。
|
||||
padding-left: clamp(@spacing-base, calc((100% - 1370px) / 2), 275px);
|
||||
padding-right: clamp(@spacing-base, calc((100% - 1680px) / 2), 120px);
|
||||
}
|
||||
|
||||
&__nav {
|
||||
.flex(row, center);
|
||||
gap: @spacing-lg;
|
||||
gap: clamp(@spacing-lg, 4vw, 56px);
|
||||
height: 100%;
|
||||
|
||||
&-item {
|
||||
font-size: @font-size-base;
|
||||
color: @text-color;
|
||||
position: relative;
|
||||
.flex(row, center);
|
||||
height: 100%;
|
||||
font-size: @font-size-md;
|
||||
font-weight: 500;
|
||||
color: @heading-color;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.router-link-active {
|
||||
color: @primary-color;
|
||||
&--active {
|
||||
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 {
|
||||
.flex(row, flex-end);
|
||||
.flex(row, flex-end, center);
|
||||
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 {
|
||||
position: relative;
|
||||
.flex(row, center, center);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&-icon {
|
||||
font-size: @font-size-lg;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -8px;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: @radius-round;
|
||||
background: @danger-color;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__login {
|
||||
font-size: @font-size-base;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatBusinessType } from '@/utils/format'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
|
||||
@@ -6,36 +7,58 @@ interface Props {
|
||||
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 {
|
||||
emit('view', id)
|
||||
}
|
||||
const meta = computed<Array<{ label: string; value: string }>>(() => [
|
||||
{ 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>
|
||||
|
||||
<template>
|
||||
<article class="case-card" @click="onView(item.id)">
|
||||
<article class="case-card" @click="emit('view', item.id)">
|
||||
<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">
|
||||
<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 class="case-card__body">
|
||||
<h3 class="case-card__title">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
|
||||
<ul class="case-card__meta">
|
||||
<li>完成时间:{{ item.finishDate }}</li>
|
||||
<li>服务团队:{{ item.teamSize }}人</li>
|
||||
<li>规模:{{ item.scale }}</li>
|
||||
<li>所属行业:{{ item.industry }}</li>
|
||||
<li>项目周期:{{ item.duration }}</li>
|
||||
<li>客户类型:{{ item.clientType }}</li>
|
||||
<li v-for="row in meta" :key="row.label">
|
||||
<span class="case-card__label">{{ row.label }}:</span>{{ row.value }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span class="case-card__more">查看详情 ></span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -43,24 +66,22 @@ function onView(id: string): void {
|
||||
<style lang="less" scoped>
|
||||
.case-card {
|
||||
overflow: hidden;
|
||||
border-radius: @radius-lg;
|
||||
height: 267px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid @cases-card-border;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: @shadow-lg;
|
||||
transform: translateY(-2px);
|
||||
|
||||
.case-card__more {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__cover {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
height: 137px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -70,56 +91,115 @@ function onView(id: string): void {
|
||||
object-fit: cover;
|
||||
|
||||
&--placeholder {
|
||||
background: linear-gradient(135deg, @primary-light, @bg-color);
|
||||
background: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
&__tag {
|
||||
position: absolute;
|
||||
top: @spacing-sm;
|
||||
left: @spacing-sm;
|
||||
padding: 2px @spacing-sm;
|
||||
border-radius: @radius-round;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
height: 28px;
|
||||
padding: 0 @spacing-sm;
|
||||
border-radius: @radius-sm;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
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;
|
||||
right: @spacing-sm;
|
||||
bottom: @spacing-sm;
|
||||
padding: 2px @spacing-sm;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
.flex(row, center);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
font-size: @font-size-sm;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
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 {
|
||||
padding: @spacing-base;
|
||||
position: relative;
|
||||
padding: 18px 20px 0;
|
||||
height: 129px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: @spacing-sm;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
color: @heading-color;
|
||||
.multi-ellipsis(1);
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 21.5px;
|
||||
color: @cases-text;
|
||||
.ellipsis();
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px @spacing-sm;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 21.5px 12px;
|
||||
margin-top: 10px;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
color: @cases-text;
|
||||
|
||||
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>
|
||||
|
||||
592
src/components/CasesFilter.vue
Normal 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>
|
||||
@@ -4,23 +4,29 @@ import type { Case } from '@/types/business/cases'
|
||||
|
||||
interface Props {
|
||||
cases: Case[]
|
||||
total: number
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{ view: [id: string] }>()
|
||||
const emit = defineEmits<{
|
||||
view: [id: string]
|
||||
favorite: [id: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cases-grid">
|
||||
<template v-if="cases.length > 0">
|
||||
<div v-if="cases.length > 0" class="cases-grid__list">
|
||||
<CaseCard
|
||||
v-for="item in cases"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@view="emit('view', $event)"
|
||||
@favorite="emit('favorite', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-else class="cases-grid__empty text-secondary">
|
||||
暂无符合条件的案例,请调整筛选条件
|
||||
</p>
|
||||
@@ -29,15 +35,16 @@ const emit = defineEmits<{ view: [id: string] }>()
|
||||
|
||||
<style lang="less" scoped>
|
||||
.cases-grid {
|
||||
&__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: @spacing-base;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 23px 28px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: @spacing-xxl 0;
|
||||
text-align: center;
|
||||
font-size: @font-size-base;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
169
src/components/CasesSearchBar.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
keyword: string
|
||||
favoriteOnly: boolean
|
||||
/** 是否显示「收藏列表」切换按钮(案例库显示,收藏页本身不显示) */
|
||||
showFavoriteToggle?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { showFavoriteToggle: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:keyword': [keyword: string]
|
||||
search: []
|
||||
reset: []
|
||||
toggleFavoriteOnly: []
|
||||
}>()
|
||||
|
||||
const keyword = computed<string>({
|
||||
get: () => props.keyword,
|
||||
set: (value) => emit('update:keyword', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="case-search">
|
||||
<div class="case-search__field">
|
||||
<svg class="case-search__icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.7-3.7" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="case-search__input"
|
||||
placeholder="关键字搜索"
|
||||
@keyup.enter="emit('search')"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button class="case-search__btn" @click="emit('reset')">
|
||||
重置
|
||||
</button>
|
||||
<button class="case-search__btn case-search__btn--primary" @click="emit('search')">
|
||||
搜索
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showFavoriteToggle"
|
||||
class="case-search__fav"
|
||||
:class="{ 'is-active': favoriteOnly }"
|
||||
@click="emit('toggleFavoriteOnly')"
|
||||
>
|
||||
<svg class="case-search__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>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.case-search {
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-base;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 @spacing-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
|
||||
&__field {
|
||||
position: relative;
|
||||
flex: 0 1 360px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: @spacing-base;
|
||||
left: auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
transform: translateY(-50%);
|
||||
fill: none;
|
||||
stroke: @cases-icon;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 40px 0 @spacing-base;
|
||||
border: 1px solid @cases-input-border;
|
||||
border-radius: @radius-round;
|
||||
background: @input-bg;
|
||||
color: @cases-text;
|
||||
font-size: @font-size-base;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-disabled;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: @cases-accent;
|
||||
background: @white;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex-shrink: 0;
|
||||
min-width: 72px;
|
||||
height: 36px;
|
||||
padding: 0 @spacing-lg;
|
||||
border-radius: @radius-round;
|
||||
background: @pill-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
font-weight: 400;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: @cases-accent;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
color: @white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__fav {
|
||||
.flex(row, center);
|
||||
gap: @spacing-xs;
|
||||
margin-left: auto;
|
||||
color: @heading-color;
|
||||
font-size: @font-size-base;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-active .case-search__fav-icon {
|
||||
fill: @danger-color;
|
||||
stroke: @danger-color;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -63,67 +63,82 @@ function onCancel(): void {
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
.flex(row, center);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
|
||||
&__panel {
|
||||
width: 400px;
|
||||
width: 660px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding: @spacing-lg;
|
||||
padding: @spacing-base;
|
||||
border-radius: @radius-lg;
|
||||
background: @white;
|
||||
box-shadow: @shadow-lg;
|
||||
}
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
margin-bottom: @spacing-base;
|
||||
font-size: @font-size-lg;
|
||||
font-weight: 600;
|
||||
padding-left: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
margin-bottom: @spacing-lg;
|
||||
font-size: @font-size-base;
|
||||
font-size: 11px;
|
||||
color: @text-color;
|
||||
line-height: 1.6;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
.flex(row, flex-end);
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 34px;
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
border: none;
|
||||
border-radius: @profile-radius;
|
||||
background: @profile-quiet-btn;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
border: none;
|
||||
background: darken(@profile-quiet-btn, 4%);
|
||||
color: @text-color;
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
border-color: @primary-color;
|
||||
background: @primary-color;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
|
||||
&:hover {
|
||||
background: @primary-hover;
|
||||
background: darken(@primary-strong, 6%);
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
border-color: @danger-color;
|
||||
background: @danger-color;
|
||||
|
||||
&:hover {
|
||||
background: fade(@danger-color, 85%);
|
||||
background: darken(@danger-color, 6%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
src/components/EllipsisText.vue
Normal 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>
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileInfo } from '@/types/api'
|
||||
import { fileLabelOf, fileToneOf } from '@/utils/fileMeta'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
files: FileInfo[]
|
||||
showActions?: boolean
|
||||
/** 左侧文件类型徽标(设计稿 #28 附件行有,#29/#27 同款) */
|
||||
showBadge?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), { showActions: true })
|
||||
withDefaults(defineProps<Props>(), { showActions: true, showBadge: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
preview: [file: FileInfo]
|
||||
@@ -19,6 +22,13 @@ const emit = defineEmits<{
|
||||
<template>
|
||||
<ul v-if="files.length > 0" class="file-list">
|
||||
<li v-for="(file, index) in files" :key="`${file.name}-${index}`" class="file-list__item">
|
||||
<span
|
||||
v-if="showBadge"
|
||||
class="file-list__badge"
|
||||
:style="{ color: fileToneOf(file), backgroundColor: `${fileToneOf(file)}1a` }"
|
||||
>
|
||||
{{ fileLabelOf(file) }}
|
||||
</span>
|
||||
<div class="file-list__info">
|
||||
<span class="file-list__name" :title="file.name">{{ file.name }}</span>
|
||||
<span class="file-list__meta text-secondary">
|
||||
@@ -48,10 +58,22 @@ const emit = defineEmits<{
|
||||
&__item {
|
||||
.flex(row, space-between);
|
||||
gap: @spacing-base;
|
||||
padding: @spacing-sm @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
padding: @spacing-base;
|
||||
border: none;
|
||||
border-radius: @radius-sm;
|
||||
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 {
|
||||
|
||||
239
src/components/FilePreviewDialog.vue
Normal 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>
|
||||
@@ -71,49 +71,54 @@ const emit = defineEmits<{
|
||||
|
||||
<style lang="less" scoped>
|
||||
.filter-panel {
|
||||
padding: @spacing-base @spacing-lg;
|
||||
border-radius: @radius-lg;
|
||||
padding: @spacing-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
|
||||
&__bar {
|
||||
.flex(row, flex-start);
|
||||
gap: @spacing-sm;
|
||||
.flex(row, flex-start, center);
|
||||
gap: @spacing-base;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 240px;
|
||||
height: 34px;
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
margin-right: 0;
|
||||
height: 36px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
border-radius: @radius-round;
|
||||
background: @input-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-disabled;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
background: @white;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
height: 34px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
min-width: 72px;
|
||||
height: 36px;
|
||||
padding: 0 @spacing-lg;
|
||||
border-radius: @radius-round;
|
||||
background: @pill-bg;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
border-color: @primary-color;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
|
||||
@@ -129,27 +134,34 @@ const emit = defineEmits<{
|
||||
flex-wrap: wrap;
|
||||
gap: @spacing-sm;
|
||||
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 {
|
||||
height: 26px;
|
||||
height: 28px;
|
||||
padding: 0 @spacing-sm;
|
||||
border: 1px solid @primary-light;
|
||||
border-radius: @radius-round;
|
||||
background: @primary-light;
|
||||
background: @white;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
color: @primary-hover;
|
||||
}
|
||||
|
||||
&--clear {
|
||||
border-color: @border-color;
|
||||
background: @white;
|
||||
color: @text-color-secondary;
|
||||
background: transparent;
|
||||
color: @primary-strong;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
src/components/PageHead.vue
Normal 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>
|
||||
@@ -13,7 +13,6 @@ const emit = defineEmits<{ pageChange: [page: number] }>()
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||
|
||||
/** 页码序列:≤7 全部展示,否则首尾 + 当前页窗口(… 省略) */
|
||||
const pages = computed<Array<number | '...'>>(() => {
|
||||
const n = pageCount.value
|
||||
const { page } = props
|
||||
@@ -72,25 +71,26 @@ function go(page: number): void {
|
||||
|
||||
&__btn {
|
||||
.flex(row, center);
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 @spacing-xs;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-sm;
|
||||
background: @white;
|
||||
color: @text-color;
|
||||
font-size: @font-size-base;
|
||||
font-size: @font-size-sm;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled):not(.is-ellipsis) {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
border-color: @primary-strong;
|
||||
color: @primary-strong;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: @primary-color;
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
border-color: @primary-strong;
|
||||
background: @primary-light;
|
||||
color: @primary-strong;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.is-ellipsis {
|
||||
|
||||
@@ -4,9 +4,11 @@ import { getPasswordLevel } from '@/utils/validate'
|
||||
|
||||
interface Props {
|
||||
password: string
|
||||
/** 是否显示右侧「密码强度:X」文字(登录/注册页显示,个人中心设计稿不显示) */
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), { showLabel: true })
|
||||
|
||||
const level = computed(() => getPasswordLevel(props.password))
|
||||
|
||||
@@ -19,17 +21,17 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="password" class="pwd-strength">
|
||||
<div class="pwd-strength">
|
||||
<div class="pwd-strength__bars">
|
||||
<span
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="pwd-strength__bar"
|
||||
:class="{ 'is-on': i <= levelIndex, [`is-${level}`]: i <= levelIndex }"
|
||||
:class="{ 'is-on': password && i <= levelIndex, [`is-${level}`]: password && i <= levelIndex }"
|
||||
/>
|
||||
</div>
|
||||
<span class="pwd-strength__label" :class="`is-${level}`">密码强度:{{ levelText }}</span>
|
||||
<p v-if="level === 'weak'" class="pwd-strength__tip">
|
||||
<span v-if="showLabel" class="pwd-strength__label" :class="`is-${level}`">{{ password ? `密码强度:${levelText}` : '' }}</span>
|
||||
<p v-if="password && level === 'weak'" class="pwd-strength__tip">
|
||||
密码等级过低,请重新输入(建议字母+数字+符号组合)
|
||||
</p>
|
||||
</div>
|
||||
@@ -37,19 +39,25 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
|
||||
|
||||
<style lang="less" scoped>
|
||||
.pwd-strength {
|
||||
.flex(column, flex-start);
|
||||
gap: @spacing-xs;
|
||||
display: grid;
|
||||
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 {
|
||||
.flex(row, center);
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
border-radius: @radius-round;
|
||||
background: @border-color;
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
border-radius: 2px;
|
||||
background: @auth-track;
|
||||
|
||||
&.is-on.is-weak {
|
||||
background: @danger-color;
|
||||
@@ -65,23 +73,17 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-sm;
|
||||
|
||||
&.is-weak {
|
||||
color: @danger-color;
|
||||
}
|
||||
|
||||
&.is-medium {
|
||||
color: @warning-color;
|
||||
}
|
||||
|
||||
&.is-strong {
|
||||
color: @success-color;
|
||||
}
|
||||
justify-self: end;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
color: @text-color-disabled;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
font-size: @font-size-sm;
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
color: @danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useCountDown } from '@/composables/useCountDown'
|
||||
|
||||
interface Props {
|
||||
/** 二维码有效期(秒),默认 4:32 */
|
||||
expiresIn?: number
|
||||
size?: number
|
||||
}
|
||||
@@ -57,7 +56,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<p class="qr-panel__status text-secondary">
|
||||
<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 v-else>
|
||||
二维码已过期,请点击刷新
|
||||
@@ -69,23 +68,19 @@ onMounted(() => {
|
||||
<style lang="less" scoped>
|
||||
.qr-panel {
|
||||
.flex(column, center);
|
||||
gap: @spacing-sm;
|
||||
gap: 17px;
|
||||
|
||||
&__box {
|
||||
.flex(column, center);
|
||||
gap: @spacing-xs;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
border: none;
|
||||
border-radius: @radius-sm;
|
||||
background: @contract-qr-bg;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: @primary-color;
|
||||
}
|
||||
transition: background 0.2s;
|
||||
|
||||
&.is-expired {
|
||||
background: @bg-color;
|
||||
background: @bg-color-light;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
@@ -98,12 +93,27 @@ onMounted(() => {
|
||||
|
||||
&__tip {
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
color: @text-color;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__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 {
|
||||
|
||||
@@ -21,12 +21,8 @@ function onGetCode(): void {
|
||||
class="sms-code__input"
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
>
|
||||
<button
|
||||
class="sms-code__btn"
|
||||
:disabled="isCounting"
|
||||
@click="onGetCode"
|
||||
>
|
||||
/>
|
||||
<button class="sms-code__btn" :disabled="isCounting" @click="onGetCode">
|
||||
{{ isCounting ? `${seconds}秒后重新获取` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -36,42 +32,57 @@ function onGetCode(): void {
|
||||
.sms-code {
|
||||
.flex(row, stretch);
|
||||
gap: @spacing-sm;
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @border-color;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
font-size: @font-size-base;
|
||||
color: @heading-color;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: @primary-color;
|
||||
border-color: @primary-strong;
|
||||
background: @white;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex-shrink: 0;
|
||||
width: 132px;
|
||||
height: 40px;
|
||||
border: 1px solid @primary-color;
|
||||
min-width: 120px;
|
||||
height: 44px;
|
||||
padding: 0 @spacing-base;
|
||||
border: 1px solid @primary-strong;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
color: @primary-color;
|
||||
color: @primary-strong;
|
||||
font-size: @font-size-sm;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: @primary-light;
|
||||
background: @primary-strong;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
border-color: @border-color-dark;
|
||||
color: @text-color-disabled;
|
||||
border-color: @border-color;
|
||||
background: @bg-color-light;
|
||||
color: @text-color-secondary;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ withDefaults(defineProps<Props>(), { variant: 'info' })
|
||||
<style lang="less" scoped>
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px @spacing-sm;
|
||||
padding: 3px 10px;
|
||||
border-radius: @radius-sm;
|
||||
font-size: @font-size-sm;
|
||||
line-height: 1.5;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
|
||||
&--primary {
|
||||
@@ -28,13 +28,13 @@ withDefaults(defineProps<Props>(), { variant: 'info' })
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: fade(@success-color, 12%);
|
||||
background: @tag-success-bg;
|
||||
color: @success-color;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: fade(@warning-color, 12%);
|
||||
color: @warning-color;
|
||||
background: @tag-warning-bg;
|
||||
color: @tag-warning-text;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
@@ -43,7 +43,7 @@ withDefaults(defineProps<Props>(), { variant: 'info' })
|
||||
}
|
||||
|
||||
&--info {
|
||||
background: @bg-color;
|
||||
background: @tag-info-bg;
|
||||
color: @text-color-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,57 +30,48 @@ defineProps<Props>()
|
||||
<style lang="less" scoped>
|
||||
.step-form {
|
||||
padding: @spacing-lg @spacing-xl;
|
||||
border-radius: @radius-lg;
|
||||
border-radius: @radius-base;
|
||||
background: @white;
|
||||
box-shadow: @shadow-base;
|
||||
box-shadow: none;
|
||||
|
||||
&__header {
|
||||
.flex(row, center);
|
||||
gap: @spacing-sm;
|
||||
.flex(column, flex-start, baseline);
|
||||
gap: @spacing-base;
|
||||
margin-bottom: @spacing-lg;
|
||||
padding-bottom: @spacing-base;
|
||||
border-bottom: 1px solid @border-color;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__step {
|
||||
.flex(row, center);
|
||||
.flex(row, baseline);
|
||||
gap: @spacing-xs;
|
||||
|
||||
&.is-active .step-form__dot {
|
||||
background: @primary-color;
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&.is-done .step-form__dot {
|
||||
border-color: @primary-color;
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.is-active .step-form__label {
|
||||
color: @primary-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__dot {
|
||||
.flex(row, center);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid @border-color-dark;
|
||||
border-radius: @radius-round;
|
||||
font-size: @font-size-sm;
|
||||
color: @text-color-secondary;
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-size: 37px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: @demand-accent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: @font-size-base;
|
||||
color: @text-color;
|
||||
font-size: @font-size-title;
|
||||
font-weight: 500;
|
||||
color: @demand-heading;
|
||||
}
|
||||
|
||||
&__line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: @border-color;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-left: 46px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -66,17 +66,17 @@ function onDrop(event: DragEvent): void {
|
||||
.upload-area {
|
||||
.flex(column, center);
|
||||
gap: @spacing-xs;
|
||||
padding: @spacing-lg;
|
||||
border: 1px dashed @border-color-dark;
|
||||
border-radius: @radius-base;
|
||||
background: @bg-color-light;
|
||||
min-height: 69px;
|
||||
padding: @spacing-base;
|
||||
border: none;
|
||||
border-radius: @radius-sm;
|
||||
background: @demand-upload-bg;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover,
|
||||
&.is-dragging {
|
||||
border-color: @primary-color;
|
||||
background: @primary-light;
|
||||
background: darken(@demand-upload-bg, 5%);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
@@ -85,8 +85,8 @@ function onDrop(event: DragEvent): void {
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: @font-size-xl;
|
||||
color: @primary-color;
|
||||
font-size: @font-size-lg;
|
||||
color: @text-color-disabled;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default as AppHeader } from './AppHeader.vue'
|
||||
export { default as AppFooter } from './AppFooter.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 FilterPanel } 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 FileList } from './FileList.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 SmsCodeInput } from './SmsCodeInput.vue'
|
||||
export { default as PasswordStrength } from './PasswordStrength.vue'
|
||||
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
||||
export { default as QrCodePanel } from './QrCodePanel.vue'
|
||||
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'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { computed, onScopeDispose, ref } from 'vue'
|
||||
|
||||
/** 倒计时(验证码 60s、二维码失效 4:32 等通用) */
|
||||
export function useCountDown(defaultSeconds = 60) {
|
||||
const seconds = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { onMounted, onUnmounted, ref, type Ref } from 'vue'
|
||||
|
||||
/** 元素可见性(配合图片懒加载/曝光统计) */
|
||||
export function useIntersection(
|
||||
target: Ref<Element | null | undefined>,
|
||||
options?: IntersectionObserverInit,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
/** 请求 loading 状态管理 */
|
||||
export function useLoading(initial = false): {
|
||||
loading: Ref<boolean>
|
||||
withLoading: <T>(fn: () => Promise<T>) => Promise<T>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { computed, onMounted, onUnmounted, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
/** 滚动监听/吸顶/回到顶部 */
|
||||
export function useScroll(threshold = 200): {
|
||||
scrollY: Ref<number>
|
||||
isAtTop: ComputedRef<boolean>
|
||||
|
||||
@@ -13,7 +13,6 @@ function normalize(value: DebounceValue): DebounceBinding {
|
||||
return typeof value === 'function' ? { handler: value } : value
|
||||
}
|
||||
|
||||
/** v-debounce 防抖点击:v-debounce="fn" 或 v-debounce="{ handler: fn, delay: 500 }" */
|
||||
const debounce: Directive<Element, DebounceValue> = {
|
||||
mounted(el, binding) {
|
||||
const { handler, delay = 300 } = normalize(binding.value)
|
||||
|
||||
@@ -3,7 +3,6 @@ import lazy from './lazy'
|
||||
import debounce from './debounce'
|
||||
import throttle from './throttle'
|
||||
|
||||
/** main.ts 调用:统一注册自定义指令 */
|
||||
export function setupDirectives(app: App): void {
|
||||
app
|
||||
.directive('lazy', lazy)
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Directive } from 'vue'
|
||||
|
||||
const observerMap = new WeakMap<Element, IntersectionObserver>()
|
||||
|
||||
/** v-lazy 图片懒加载:v-lazy="图片地址" */
|
||||
const lazy: Directive<Element, string> = {
|
||||
mounted(el, binding) {
|
||||
if (!(el instanceof HTMLImageElement)) return
|
||||
|
||||
@@ -13,7 +13,6 @@ function normalize(value: ThrottleValue): ThrottleBinding {
|
||||
return typeof value === 'function' ? { handler: value } : value
|
||||
}
|
||||
|
||||
/** v-throttle 节流点击:v-throttle="fn" 或 v-throttle="{ handler: fn, interval: 500 }" */
|
||||
const throttle: Directive<Element, ThrottleValue> = {
|
||||
mounted(el, binding) {
|
||||
const { handler, interval = 500 } = normalize(binding.value)
|
||||
|
||||
1
src/env.d.ts
vendored
@@ -1,7 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** API 基础地址 */
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { AppFooter, AppHeader } from '@/components'
|
||||
import FloatingActions from './components/FloatingActions.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { AppFooter, AppHeader, FilePreviewDialog } from '@/components'
|
||||
|
||||
const route = useRoute()
|
||||
const showFooter = computed(() => !route.meta.noFooter)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,15 +13,18 @@ import FloatingActions from './components/FloatingActions.vue'
|
||||
<main class="layout__main">
|
||||
<router-view />
|
||||
</main>
|
||||
<AppFooter />
|
||||
<FloatingActions />
|
||||
<AppFooter v-if="showFooter" />
|
||||
<FilePreviewDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.layout {
|
||||
.layout__main {
|
||||
min-height: calc(100vh - @header-height - @footer-height);
|
||||
.flex(column, flex-start, stretch);
|
||||
min-height: 100vh;
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
/** 路由守卫:登录态校验 + document.title */
|
||||
export function setupGuard(router: Router): void {
|
||||
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')) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
@@ -1,40 +1,34 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 公共布局(AppHeader + router-view + AppFooter + FloatingActions)下的路由
|
||||
* 完整规划见 docs/page-architecture.md 第 2 节
|
||||
* M3–M10 视图由子代理并行实现中,交付后本表即为终态
|
||||
*/
|
||||
const layoutRoutes: RouteRecordRaw = {
|
||||
path: '/',
|
||||
component: () => import('@/layout/index.vue'),
|
||||
children: [
|
||||
// ── 公开 ──
|
||||
{ path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页' } },
|
||||
{ path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页', footer: true } },
|
||||
{ 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: '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/: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', 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/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/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/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'),
|
||||
@@ -42,8 +36,11 @@ const layoutRoutes: RouteRecordRaw = {
|
||||
children: [
|
||||
{ path: '', redirect: '/profile/security' },
|
||||
{ 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: '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 } },
|
||||
@@ -54,7 +51,6 @@ const layoutRoutes: RouteRecordRaw = {
|
||||
],
|
||||
}
|
||||
|
||||
/** 无全局布局的路由(登录注册独立品牌页 + 404) */
|
||||
const plainRoutes: RouteRecordRaw[] = [
|
||||
{ path: '/login', name: 'Login', component: () => import('@/views/login'), meta: { title: '登录' } },
|
||||
{ path: '/register', name: 'Register', component: () => import('@/views/login/register.vue'), meta: { title: '注册' } },
|
||||
|
||||
@@ -1,50 +1,147 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { BusinessType, PageResult } from '@/types/api'
|
||||
import type { Case, CaseQuery } from '@/types/business/cases'
|
||||
import { getCaseCounts, getCaseDetail, getCaseList, toggleFavorite as toggleFavoriteApi } from '@/api/cases'
|
||||
import type {
|
||||
BusinessIndustryOption,
|
||||
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 {
|
||||
return {
|
||||
category: 'all',
|
||||
categories: [],
|
||||
industries: [],
|
||||
scales: [],
|
||||
dateRange: null,
|
||||
favoriteOnly: false,
|
||||
keyword: '',
|
||||
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', () => {
|
||||
const query = ref<CaseQuery>(createDefaultQuery())
|
||||
const list = ref<Case[]>([])
|
||||
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)
|
||||
|
||||
async function fetchCases(): Promise<void> {
|
||||
const result: PageResult<Case> = await getCaseList(query.value)
|
||||
list.value = result.list
|
||||
total.value = result.total
|
||||
let probeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function buildQuery(input: CaseQuery): {
|
||||
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> {
|
||||
counts.value = await getCaseCounts()
|
||||
async function fetchCategories(): Promise<void> {
|
||||
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 {
|
||||
query.value = next
|
||||
scheduleProbe()
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchCases()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchCases()
|
||||
@@ -55,29 +152,53 @@ export const useCasesStore = defineStore('cases', () => {
|
||||
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> {
|
||||
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> {
|
||||
const updated = await toggleFavoriteApi(id)
|
||||
await collectCase(id)
|
||||
const index = list.value.findIndex((item) => item.id === id)
|
||||
if (index >= 0) list.value[index] = updated
|
||||
if (currentCase.value?.id === id) currentCase.value = updated
|
||||
if (index >= 0) {
|
||||
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 {
|
||||
query,
|
||||
list,
|
||||
total,
|
||||
counts,
|
||||
previewTotal,
|
||||
typeOptions,
|
||||
typeNameById,
|
||||
industryOptions,
|
||||
scaleOptions,
|
||||
caseTotal,
|
||||
currentCase,
|
||||
fetchCases,
|
||||
fetchCounts,
|
||||
fetchCategories,
|
||||
ensureCategories,
|
||||
setQuery,
|
||||
search,
|
||||
resetQuery,
|
||||
changePage,
|
||||
toggleFavoriteOnly,
|
||||
fetchCaseDetail,
|
||||
toggleFavorite,
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ function createDefaultQuery(): ContractQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/** 合同中心数据(列表 / 筛选 / 分页 / 详情 / 签署二维码) */
|
||||
export const useContractStore = defineStore('contract', () => {
|
||||
const query = ref<ContractQuery>(createDefaultQuery())
|
||||
const list = ref<Contract[]>([])
|
||||
@@ -39,13 +38,11 @@ export const useContractStore = defineStore('contract', () => {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
@@ -56,7 +53,6 @@ export const useContractStore = defineStore('contract', () => {
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 获取/刷新签署二维码(refresh=true 时走刷新接口) */
|
||||
async function fetchSignQr(id: string, refresh = false): Promise<void> {
|
||||
const result = refresh ? await refreshSignQr(id) : await getSignQr(id)
|
||||
signExpires.value = result.expiresInSeconds
|
||||
|
||||
@@ -1,60 +1,211 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Demand } from '@/types/business/demand'
|
||||
import type { FileInfo } from '@/types/api'
|
||||
import type {
|
||||
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 { getDraftDemand, saveDraft as saveDraftApi, submitDemand } from '@/api/demand'
|
||||
|
||||
function createDefaultForm(): Demand {
|
||||
return {
|
||||
businessType: 'internet',
|
||||
id: null,
|
||||
bizType: '',
|
||||
projectName: '',
|
||||
attachments: [],
|
||||
coreDescription: '',
|
||||
expectedDuration: '',
|
||||
requirementDesc: '',
|
||||
budgetRange: '',
|
||||
userName: '王永志',
|
||||
userPhone: '1866****9618',
|
||||
remark: '',
|
||||
status: 'draft',
|
||||
budgetManual: false,
|
||||
developmentCycle: '',
|
||||
supplementaryRemarks: '',
|
||||
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', () => {
|
||||
const form = ref<Demand>(createDefaultForm())
|
||||
const step = ref(1)
|
||||
const bizTypeOptions = ref<BizTypeOption[]>([])
|
||||
const budgetDict = ref<DictOption[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
function setStep(next: number): void {
|
||||
step.value = next
|
||||
const budgetOptions = computed<DictOption[]>(() => {
|
||||
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 {
|
||||
form.value = { ...form.value, ...partial }
|
||||
}
|
||||
|
||||
/** File → FileInfo(mock:url 为空,uploadTime 取当前时间) */
|
||||
function addAttachment(file: File): void {
|
||||
const info: FileInfo = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
uploadTime: formatDate(new Date()),
|
||||
url: '',
|
||||
async function init(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const [bizTypes, budgetDictRes, profile, draft] = await Promise.all([
|
||||
loadBizTypeOptions(),
|
||||
getBudgetRangeDict(),
|
||||
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 {
|
||||
form.value.attachments = form.value.attachments.filter(
|
||||
(item) => !(item.name === target.name && item.uploadTime === target.uploadTime),
|
||||
async function addAttachment(file: File): Promise<void> {
|
||||
if (uploading.value) return
|
||||
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> {
|
||||
if (!form.value.businessType) {
|
||||
if (!form.value.bizType) {
|
||||
ElMessage.warning('请选择业务类型')
|
||||
return false
|
||||
}
|
||||
@@ -62,15 +213,22 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
ElMessage.warning('请填写项目名称')
|
||||
return false
|
||||
}
|
||||
if (!form.value.coreDescription.trim()) {
|
||||
if (!form.value.requirementDesc.trim()) {
|
||||
ElMessage.warning('请填写核心需求描述')
|
||||
return false
|
||||
}
|
||||
loading.value = true
|
||||
if (form.value.budgetManual && !form.value.budgetRange.trim()) {
|
||||
ElMessage.warning('请填写预算金额,或改选一个预设档位')
|
||||
return false
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await submitDemand(form.value)
|
||||
form.value.status = 'submitted'
|
||||
ElMessage.success('提交后我们的专业顾问将在一个工作日内与您联系')
|
||||
const base = createDefaultForm()
|
||||
base.userName = form.value.userName
|
||||
base.userPhone = form.value.userPhone
|
||||
form.value = base
|
||||
ElMessage.success('提交成功,专业顾问将在一个工作日内与您联系')
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
@@ -78,49 +236,24 @@ export const useDemandStore = defineStore('demand', () => {
|
||||
return false
|
||||
}
|
||||
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 {
|
||||
form,
|
||||
step,
|
||||
bizTypeOptions,
|
||||
budgetOptions,
|
||||
loading,
|
||||
setStep,
|
||||
saving,
|
||||
submitting,
|
||||
uploading,
|
||||
init,
|
||||
patchForm,
|
||||
addAttachment,
|
||||
removeAttachment,
|
||||
submit,
|
||||
saveDraft,
|
||||
loadDraft,
|
||||
reset,
|
||||
submit,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,20 +10,25 @@ import type {
|
||||
InvoiceTitle,
|
||||
InvoiceTitlePayload,
|
||||
PayMethod,
|
||||
PayOrderResponse,
|
||||
Payment,
|
||||
PaymentResult,
|
||||
} from '@/types/business/finance'
|
||||
import {
|
||||
applyInvoice as applyInvoiceApi,
|
||||
createAlipayPageOrder as createAlipayPageOrderApi,
|
||||
createInvoiceTitle,
|
||||
createPayment as createPaymentApi,
|
||||
createWechatNativeOrder as createWechatNativeOrderApi,
|
||||
deleteInvoiceTitle,
|
||||
exportReport as exportReportApi,
|
||||
getBillDetail,
|
||||
getBillList,
|
||||
getCheckoutBills,
|
||||
getFinanceStats,
|
||||
getInvoiceList,
|
||||
getInvoiceTitles,
|
||||
getPlanPayResult,
|
||||
getPayResult,
|
||||
setDefaultTitle as setDefaultTitleApi,
|
||||
switchPaymentMethod as switchPaymentMethodApi,
|
||||
@@ -38,35 +43,31 @@ function createDefaultInvoiceQuery(): InvoiceQuery {
|
||||
return { keyword: '', status: 'all', page: 1, pageSize: 6 }
|
||||
}
|
||||
|
||||
/** 财务中心数据(统计/账单/支付/发票/抬头) */
|
||||
export const useFinanceStore = defineStore('finance', () => {
|
||||
// —— 统计
|
||||
const stats = ref<FinanceStats | null>(null)
|
||||
|
||||
// —— 账单列表
|
||||
const list = ref<Bill[]>([])
|
||||
const total = ref(0)
|
||||
const query = ref<BillQuery>(createDefaultBillQuery())
|
||||
const currentBill = ref<Bill | null>(null)
|
||||
|
||||
// —— 支付
|
||||
const payment = ref<Payment | null>(null)
|
||||
const result = ref<PaymentResult | null>(null)
|
||||
|
||||
// —— 发票
|
||||
const checkoutBills = ref<Bill[]>([])
|
||||
const checkoutLoading = ref(false)
|
||||
const payOrder = ref<PayOrderResponse | null>(null)
|
||||
|
||||
const invoices = ref<Invoice[]>([])
|
||||
const invoiceTotal = ref(0)
|
||||
const invoiceQuery = ref<InvoiceQuery>(createDefaultInvoiceQuery())
|
||||
|
||||
// —— 发票抬头
|
||||
const titles = ref<InvoiceTitle[]>([])
|
||||
|
||||
// —— 统计
|
||||
async function fetchStats(period?: string): Promise<void> {
|
||||
stats.value = await getFinanceStats(period)
|
||||
}
|
||||
|
||||
// —— 账单
|
||||
async function fetchList(): Promise<void> {
|
||||
const pageData: PageResult<Bill> = await getBillList(query.value)
|
||||
list.value = pageData.list
|
||||
@@ -77,13 +78,11 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultBillQuery()
|
||||
await fetchList()
|
||||
@@ -98,21 +97,41 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
currentBill.value = await getBillDetail(id)
|
||||
}
|
||||
|
||||
// —— 支付
|
||||
async function createPayment(billId: string, method: PayMethod): Promise<void> {
|
||||
payment.value = await createPaymentApi(billId, method)
|
||||
}
|
||||
|
||||
/** 切换支付方式(刷新二维码) */
|
||||
async function switchPaymentMethod(billId: string, method: PayMethod): Promise<void> {
|
||||
payment.value = await switchPaymentMethodApi(billId, method)
|
||||
}
|
||||
|
||||
async function fetchResult(billId: string): Promise<void> {
|
||||
result.value = await getPayResult(billId)
|
||||
async function fetchResult(billId: string, requirementId = ''): Promise<void> {
|
||||
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> {
|
||||
const pageData: PageResult<Invoice> = await getInvoiceList(invoiceQuery.value)
|
||||
invoices.value = pageData.list
|
||||
@@ -123,7 +142,6 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
invoiceQuery.value = next
|
||||
}
|
||||
|
||||
/** 发票确定筛选(重置页码后取数) */
|
||||
async function searchInvoices(): Promise<void> {
|
||||
invoiceQuery.value = { ...invoiceQuery.value, page: 1 }
|
||||
await fetchInvoices()
|
||||
@@ -134,14 +152,12 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
await fetchInvoices()
|
||||
}
|
||||
|
||||
/** 申请开票(状态 → 开票中) */
|
||||
async function applyInvoice(id: string): Promise<void> {
|
||||
const updated = await applyInvoiceApi(id)
|
||||
const index = invoices.value.findIndex((item) => item.id === id)
|
||||
if (index >= 0) invoices.value[index] = updated
|
||||
}
|
||||
|
||||
// —— 发票抬头
|
||||
async function fetchTitles(): Promise<void> {
|
||||
titles.value = await getInvoiceTitles()
|
||||
}
|
||||
@@ -165,7 +181,6 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
titles.value = await setDefaultTitleApi(id)
|
||||
}
|
||||
|
||||
// —— 报表导出
|
||||
async function exportReport(): Promise<void> {
|
||||
await exportReportApi()
|
||||
}
|
||||
@@ -178,6 +193,9 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
currentBill,
|
||||
payment,
|
||||
result,
|
||||
checkoutBills,
|
||||
checkoutLoading,
|
||||
payOrder,
|
||||
invoices,
|
||||
invoiceTotal,
|
||||
invoiceQuery,
|
||||
@@ -192,6 +210,10 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
createPayment,
|
||||
switchPaymentMethod,
|
||||
fetchResult,
|
||||
loadCheckout,
|
||||
payByWechatNative,
|
||||
payByAlipayPage,
|
||||
clearPayOrder,
|
||||
fetchInvoices,
|
||||
setInvoiceQuery,
|
||||
searchInvoices,
|
||||
|
||||
@@ -1,46 +1,224 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { BusinessType } from '@/types/api'
|
||||
import type { Case } from '@/types/business/cases'
|
||||
import type { BusinessLine, HomeStats } from '@/types/business/home'
|
||||
import { getBusinessDetail, getBusinessLines, getFeaturedCases, getHomeStats } from '@/api/home'
|
||||
import type {
|
||||
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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/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', () => {
|
||||
const casesStore = useCasesStore()
|
||||
|
||||
const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 })
|
||||
const banners = ref<Banner[]>([])
|
||||
const businessLines = ref<BusinessLine[]>([])
|
||||
const featuredCases = ref<Case[]>([])
|
||||
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> {
|
||||
const [homeStats, lines, cases] = await Promise.all([
|
||||
getHomeStats(),
|
||||
getBusinessLines(),
|
||||
getFeaturedCases(),
|
||||
const [statusCount, submissionList, cases, carouselList] = await Promise.all([
|
||||
getProjectStatusCount(),
|
||||
getServiceTypeList(),
|
||||
getFeaturedCasesApi(),
|
||||
getCarouselList().catch(() => [] as BannerVo[]),
|
||||
casesStore.ensureCategories(),
|
||||
])
|
||||
stats.value = homeStats
|
||||
businessLines.value = lines
|
||||
featuredCases.value = cases
|
||||
const status = statusCount ?? EMPTY_STATUS
|
||||
stats.value = {
|
||||
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> {
|
||||
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> {
|
||||
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 {
|
||||
stats,
|
||||
banners,
|
||||
businessLines,
|
||||
featuredCases,
|
||||
currentBusiness,
|
||||
portalEntries,
|
||||
portalEntriesLoaded,
|
||||
quickEntries,
|
||||
otherEntries,
|
||||
fetchHomeData,
|
||||
fetchFeaturedCases,
|
||||
fetchBusinessDetail,
|
||||
fetchPortalEntries,
|
||||
switchEntryGroup,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
markRead as markReadApi,
|
||||
} from '@/api/message'
|
||||
|
||||
/** tab 计数结构 */
|
||||
export interface MessageTabCounts {
|
||||
all: number
|
||||
在线客服: number
|
||||
@@ -29,7 +28,6 @@ function createDefaultQuery(): MessageQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/** 消息中心数据(列表 / 筛选 / 分页 / 已读) */
|
||||
export const useMessageStore = defineStore('message', () => {
|
||||
const query = ref<MessageQuery>(createDefaultQuery())
|
||||
const list = ref<Message[]>([])
|
||||
@@ -59,13 +57,11 @@ export const useMessageStore = defineStore('message', () => {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定搜索(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 重置全部条件 */
|
||||
async function resetQuery(): Promise<void> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
@@ -76,7 +72,6 @@ export const useMessageStore = defineStore('message', () => {
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
/** 全部标记已读 */
|
||||
async function markAllRead(): Promise<void> {
|
||||
await markAllReadApi()
|
||||
ElMessage.success('已全部标记为已读')
|
||||
|
||||
100
src/stores/modules/oss.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -51,9 +51,7 @@ function createDefaultFavoritesQuery(): FavoritesQuery {
|
||||
return { page: 1, pageSize: 8, keyword: '', category: 'all' }
|
||||
}
|
||||
|
||||
/** 个人中心数据(资料/安全/地址/收藏/资质/反馈/帮助) */
|
||||
export const useProfileStore = defineStore('profile', () => {
|
||||
// ── 账号资料 ──
|
||||
const profile = ref<Profile | null>(null)
|
||||
|
||||
async function fetchProfile(): Promise<void> {
|
||||
@@ -65,7 +63,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
ElMessage.success('昵称修改成功')
|
||||
}
|
||||
|
||||
// ── 账号安全 ──
|
||||
async function changePasswordAction(params: ChangePasswordParams): Promise<void> {
|
||||
await changePassword(params)
|
||||
profile.value = await getProfile()
|
||||
@@ -97,7 +94,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
ElMessage.success('注销成功')
|
||||
}
|
||||
|
||||
// ── 收货地址 ──
|
||||
const addresses = ref<Address[]>([])
|
||||
const addressTotal = ref(0)
|
||||
const addressQuery = ref<ListQuery>(createDefaultAddressQuery())
|
||||
@@ -138,7 +134,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
// ── 我的收藏 ──
|
||||
const favorites = ref<Case[]>([])
|
||||
const favoritesTotal = ref(0)
|
||||
const favoritesCounts = ref<Partial<Record<BusinessType, number>>>({})
|
||||
@@ -169,7 +164,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
await fetchFavorites()
|
||||
}
|
||||
|
||||
// ── 资质管理 ──
|
||||
const qualifications = ref<Qualification[]>([])
|
||||
const qualificationTotal = ref(0)
|
||||
const qualificationQuery = ref<ListQuery>(createDefaultQualificationQuery())
|
||||
@@ -204,7 +198,6 @@ export const useProfileStore = defineStore('profile', () => {
|
||||
await fetchQualifications()
|
||||
}
|
||||
|
||||
// ── 意见反馈 / 帮助中心 ──
|
||||
async function submitFeedbackAction(feedback: Feedback): Promise<void> {
|
||||
await submitFeedbackApi(feedback)
|
||||
}
|
||||
|
||||
@@ -1,90 +1,202 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { PageResult } from '@/types/api'
|
||||
import type { AfterSalesPayload, Project, ProjectQuery } from '@/types/business/project'
|
||||
import type {
|
||||
AfterSalesAttachment,
|
||||
AfterSalesPayload,
|
||||
Project,
|
||||
ProjectQuery,
|
||||
ProjectStatistics,
|
||||
ProjectTypeCount,
|
||||
RdProjectVo,
|
||||
RequirementDetailBundle,
|
||||
} from '@/types/business/project'
|
||||
import {
|
||||
cancelAfterSales as cancelAfterSalesApi,
|
||||
deleteProjectFile,
|
||||
getProjectDetail,
|
||||
getCompletedProjects,
|
||||
getProjectDetailBundle,
|
||||
getProjectDetailTab,
|
||||
getProjectFilterConditions,
|
||||
getProjectList,
|
||||
submitAfterSales as submitAfterSalesApi,
|
||||
uploadProjectFile,
|
||||
uploadAfterSalesFile as uploadAfterSalesFileApi,
|
||||
} from '@/api/project'
|
||||
import type { ProjectDetailTab } from '@/api/project'
|
||||
import { toProjectDetail } from '@/utils/projectVo'
|
||||
|
||||
function createDefaultQuery(): ProjectQuery {
|
||||
return {
|
||||
keyword: '',
|
||||
businessType: 'all',
|
||||
status: 'all',
|
||||
businessTypes: [],
|
||||
status: '',
|
||||
timeShortcut: 'all',
|
||||
page: 1,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
pageNum: 1,
|
||||
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', () => {
|
||||
const query = ref<ProjectQuery>(createDefaultQuery())
|
||||
const list = ref<Project[]>([])
|
||||
const list = ref<RdProjectVo[]>([])
|
||||
const total = ref(0)
|
||||
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> {
|
||||
const result: PageResult<Project> = await getProjectList(query.value)
|
||||
list.value = result.list
|
||||
total.value = result.total
|
||||
const result = await getProjectList(query.value)
|
||||
list.value = result?.rows ?? []
|
||||
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> {
|
||||
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 {
|
||||
query.value = next
|
||||
}
|
||||
|
||||
/** 确定筛选(重置页码后取数) */
|
||||
async function search(): Promise<void> {
|
||||
query.value = { ...query.value, page: 1 }
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
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> {
|
||||
query.value = createDefaultQuery()
|
||||
await fetchList()
|
||||
await load()
|
||||
}
|
||||
|
||||
async function changePage(page: number): Promise<void> {
|
||||
query.value = { ...query.value, page }
|
||||
await fetchList()
|
||||
async function changePage(pageNum: number): Promise<void> {
|
||||
query.value = { ...query.value, pageNum }
|
||||
await load()
|
||||
}
|
||||
|
||||
/** 上传项目文件(追加到当前详情) */
|
||||
async function uploadFile(file: File): Promise<void> {
|
||||
const info = await uploadProjectFile(file)
|
||||
current.value?.files.push(info)
|
||||
async function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
|
||||
return uploadAfterSalesFileApi(file)
|
||||
}
|
||||
|
||||
/** 删除当前详情中的项目文件 */
|
||||
async function deleteFile(index: number): Promise<void> {
|
||||
await deleteProjectFile(index)
|
||||
current.value?.files.splice(index, 1)
|
||||
async function submitAfterSales(id: string, payload: AfterSalesPayload): Promise<void> {
|
||||
await submitAfterSalesApi(id, payload)
|
||||
ElMessage.success('售后申请已提交')
|
||||
if (current.value?.id === id) await refreshTab('afterSales')
|
||||
}
|
||||
|
||||
/** 提交售后工单 */
|
||||
async function submitAfterSales(payload: AfterSalesPayload): Promise<void> {
|
||||
if (!current.value) return
|
||||
current.value.afterSales = await submitAfterSalesApi(current.value.id, payload)
|
||||
ElMessage.success('已提交售后工单')
|
||||
}
|
||||
|
||||
/** 取消售后工单 */
|
||||
async function cancelAfterSales(): Promise<void> {
|
||||
if (!current.value) return
|
||||
current.value.afterSales = await cancelAfterSalesApi(current.value.id)
|
||||
ElMessage.success('已取消售后工单')
|
||||
async function cancelAfterSales(id: string): Promise<void> {
|
||||
await cancelAfterSalesApi(id)
|
||||
ElMessage.success('售后申请已撤回')
|
||||
if (current.value?.id === id) await refreshTab('afterSales')
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -92,14 +204,20 @@ export const useProjectStore = defineStore('project', () => {
|
||||
list,
|
||||
total,
|
||||
current,
|
||||
typeCounts,
|
||||
ensureFilterConditions,
|
||||
fetchList,
|
||||
completedProjects,
|
||||
fetchCompletedProjects,
|
||||
fetchDetail,
|
||||
refreshTab,
|
||||
loadingTab,
|
||||
setQuery,
|
||||
load,
|
||||
search,
|
||||
resetQuery,
|
||||
changePage,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
uploadAfterSalesFile,
|
||||
submitAfterSales,
|
||||
cancelAfterSales,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
uploadChatFile,
|
||||
} from '@/api/service'
|
||||
|
||||
/** 在线客服会话数据(消息列表 / 发送状态) */
|
||||
export const useServiceStore = defineStore('service', () => {
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const sending = ref(false)
|
||||
@@ -17,7 +16,6 @@ export const useServiceStore = defineStore('service', () => {
|
||||
messages.value = await getChatHistory()
|
||||
}
|
||||
|
||||
/** 发送文本:先推入用户消息,600ms 后推入客服自动回复 */
|
||||
async function send(content: string): Promise<void> {
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed || sending.value) return
|
||||
@@ -37,7 +35,6 @@ export const useServiceStore = defineStore('service', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送文件卡片消息 */
|
||||
async function sendFile(file: File): Promise<void> {
|
||||
if (sending.value) return
|
||||
sending.value = true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** 站点公共数据(站点配置、全局 loading),非页面级 */
|
||||
export const useSiteStore = defineStore('site', () => {
|
||||
const globalLoading = ref(false)
|
||||
|
||||
|
||||
@@ -4,21 +4,20 @@ import { ElMessage } from 'element-plus'
|
||||
import type {
|
||||
LoginByAccountParams,
|
||||
LoginBySmsParams,
|
||||
LoginResult,
|
||||
RegisterParams,
|
||||
ResetPasswordParams,
|
||||
User,
|
||||
} from '@/types/business/user'
|
||||
import {
|
||||
loginByAccount as loginByAccountApi,
|
||||
loginBySms as loginBySmsApi,
|
||||
register as registerApi,
|
||||
resetPassword as resetPasswordApi,
|
||||
sendSmsCode as sendSmsCodeApi,
|
||||
} from '@/api/auth'
|
||||
import {
|
||||
loginByAccountApi,
|
||||
registerByAccountApi,
|
||||
} from '@/api/auth'
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
/** 用户登录态(M4:token 持久化 key 'token',路由守卫读取同一 key) */
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string | null>(storage.get<string>('token'))
|
||||
const user = ref<User | null>(null)
|
||||
@@ -26,7 +25,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
const isLoggedIn = computed(() => Boolean(token.value))
|
||||
|
||||
/** 发送短信验证码 */
|
||||
async function sendSmsCode(phone: string): Promise<void> {
|
||||
try {
|
||||
await sendSmsCodeApi(phone)
|
||||
@@ -38,19 +36,17 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录成功统一落库(token 持久化) */
|
||||
function applyLogin(result: LoginResult): void {
|
||||
token.value = result.token
|
||||
user.value = result.user
|
||||
storage.set('token', result.token)
|
||||
function applyLogin(payload: { token: string; user: User | null }): void {
|
||||
token.value = payload.token
|
||||
user.value = payload.user
|
||||
storage.set('token', payload.token)
|
||||
}
|
||||
|
||||
/** 账号密码登录 */
|
||||
async function loginByAccount(params: LoginByAccountParams): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await loginByAccountApi(params)
|
||||
applyLogin(result)
|
||||
const result = await loginByAccountApi({ username: params.account, password: params.password })
|
||||
applyLogin({ token: result.access_token, user: null })
|
||||
ElMessage.success('登录成功')
|
||||
return true
|
||||
}
|
||||
@@ -63,12 +59,11 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 手机验证码登录 */
|
||||
async function loginBySms(params: LoginBySmsParams): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await loginBySmsApi(params)
|
||||
applyLogin(result)
|
||||
applyLogin({ token: result.token, user: result.user })
|
||||
ElMessage.success('登录成功')
|
||||
return true
|
||||
}
|
||||
@@ -81,11 +76,10 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册 */
|
||||
async function register(params: RegisterParams): Promise<boolean> {
|
||||
async function register(params: { username: string; password: string; confirmPassword: string }): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
await registerApi(params)
|
||||
await registerByAccountApi({ username: params.username, password: params.password })
|
||||
ElMessage.success('注册成功,请登录')
|
||||
return true
|
||||
}
|
||||
@@ -98,7 +92,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置密码 */
|
||||
async function resetPassword(params: ResetPasswordParams): Promise<boolean> {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -115,7 +108,6 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出登录(清除持久化 token) */
|
||||
function logout(): void {
|
||||
token.value = null
|
||||
user.value = null
|
||||
|
||||
@@ -1,10 +1,133 @@
|
||||
// 通用工具类
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: @container-width;
|
||||
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 {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// 样式入口(main.ts 引入;variables/mixins 由 vite additionalData 自动注入)
|
||||
@import './reset.less';
|
||||
@import './common.less';
|
||||
|
||||
// 视觉基准:蓝湖设计稿 1920px,1:1 还原,不做整体缩放。
|
||||
// 如需适配窄屏,请在各页面内部用媒体查询处理,不要在根元素缩放。
|
||||
html {
|
||||
zoom: 1;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// 混合宏(flex、文本省略、滚动条)
|
||||
|
||||
.flex(@direction: row; @justify: flex-start; @align: center) {
|
||||
display: flex;
|
||||
@@ -35,3 +34,46 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// 样式重置
|
||||
|
||||
*,
|
||||
*::before,
|
||||
@@ -14,8 +13,8 @@ body {
|
||||
|
||||
body {
|
||||
color: @text-color;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: @font-size-base;
|
||||
font-family: 'PingFang SC', 'Source Han Sans SC', 'Microsoft YaHei', -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
background-color: @bg-color;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||