Compare commits

..

40 Commits

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

View File

@@ -0,0 +1,240 @@
---
name: auto-dev
description: zc-web 自动化开发全流程 skill。覆盖从原型图/PRD → 蓝湖 MCP 设计稿读取 → API 接口文档 → 页面代码生成 → 组件拆分 → store/api 生成 → 自动验证修正的完整链路。当用户提到"自动化开发"、"从原型生成代码"、"蓝湖设计稿生成页面"、"PRD生成页面"、"虫子开发"、"自动拆分组件"、"生成store"时使用。
version: 1.0.0
---
# zc-web 自动化开发全流程
> 本 skill 驱动从设计稿/PRD 到可运行代码的完整自动化管线。所有生成的代码必须严格遵循 [vue-project-structure](vue-project-structure) skill 的规范。
## 前置条件
1. **项目结构确认**:用 `codegraph_files` 确认 `src/` 目录现有结构
2. **加载 vue-project-structure skill**:所有代码生成必须遵循其分层架构和数据流规范
3. **蓝湖 MCP 就绪**:确认 `lanhu` MCP 服务已启动(`curl http://localhost:8000/mcp`)
---
## 阶段一:PRD 生成
### 从原型图生成 PRD 文档
使用项目根目录的 `read_prototype.js` 读取原型文件:
```bash
node read_prototype.js [原型文件路径]
```
然后根据原型中的布局和交互逻辑,生成 PRD 文档放入 `doc/` 文件夹。
**PRD 内容要求**:
- 页面清单及功能描述
- 每个页面的交互逻辑
- 数据字段定义
- 组件拆分建议
**提示词模板**:
```
运行 ./read_prototype.js [原型文件路径] 读取原型代码,然后根据其中的布局和交互逻辑,为我生成一份 PRD 文档。放在doc文件夹中
```
---
## 阶段二:蓝湖 MCP 设计稿读取
### 连接验证
```javascript
请帮我用 MCP 看看这个蓝湖设计稿:[蓝湖链接]
```
### 批量读取设计稿数据
```javascript
请通过蓝湖 MCP 读取以下设计稿的结构化数据,提取每个页面的:
- 组件尺寸、间距、色值、字体等标注参数
- 全局设计规范(颜色变量、字号体系)
- 切图资源(SVG/PNG),按图层语义化命名
- 布局嵌套结构和循环体检测
蓝湖链接:[蓝湖项目链接]
```
**输出要求**:
- 设计参数表(色值、字号、间距、圆角、阴影)
- 组件结构树(嵌套关系、循环体标记)
- 资源清单(图片/图标路径及语义化命名)
---
## 阶段三:PRD → 页面结构规划
```javascript
以下是我的产品需求文档,请帮我:
- 梳理出所有需要的页面清单,按功能模块分组
- 规划 uni-app / Vue3 项目的页面路由结构
- 识别哪些模块可以复用,哪些是页面独有
- 输出每个页面的核心功能点和数据字段
PRD 内容:[PRD文件路径或内容]
```
**输出物**:
- 页面清单表(模块 → 页面 → 功能点 → 数据字段)
- 路由规划(`pages.json` 或 `router/routes.ts` 结构)
- 复用性分析(哪些组件/逻辑可抽取为公共)
---
## 阶段四:AI 生成页面代码并自动拆分组件
基于 PRD 页面规划和蓝湖 MCP 设计稿数据,生成完整页面代码。
### 目录结构规范(zc-web)
```
views/<page>/
├── index.vue # 页面主组件(路由入口)
├── index.ts # 页面出口 barrel
└── cpns/ # 页面独有组件
├── <ComponentName>.vue
└── ...
```
配套文件:
```
api/<page>.ts # 接口层
stores/modules/<page>.ts # 数据层(Pinia)
types/business/<page>.d.ts # 实体类型
```
### 组件拆分原则
1. **单一职责**:一个组件只做一件事,超过 300 行必须拆分
2. **三层架构**:
- 页面容器(`index.vue`):只负责调度和数据分发,不写业务逻辑
- 业务子组件(`cpns/`):当前页面私有,只接收 props + emit 事件
- 公共基础组件(`components/`):全局复用
3. **按数据维度拆分**:同一批接口数据渲染的区域放一个组件,不同数据源必须拆分
4. **弹窗/抽屉/复杂表单**:一律单独抽离为子组件
### 生成要求
- 父页面只做组件引入、props 分发、事件中转
- 子组件只接收 props 和通过 emit 向上抛事件,不直接调接口
- 样式使用 `<style lang="less" scoped>`,色值从蓝湖数据中精确提取
- 图片和图标使用蓝湖 MCP 导出的本地资源路径
- 组件统一 `<script setup lang="ts">`,props 用 `defineProps<Props>()` + `withDefaults`,emits 用 `defineEmits<{事件名: [参数类型]}>()`
### 提示词模板
```javascript
基于蓝湖 MCP 返回的设计稿数据和 PRD 页面规划,帮我生成 uni-app + Vue3 + TypeScript 的完整页面代码,并按以下原则自动拆分组件:
拆分原则:
1.单一职责:一个组件只做一件事,超过 300 行必须拆分
2.三层架构:页面容器(只负责调度和数据分发)→ 业务子组件(当前页面私有)→ 公共基础组件(全局复用)
3.按数据维度拆分:同一批接口数据渲染的区域放一个组件,不同数据源的模块必须拆分
4.弹窗、抽屉、复杂表单一律单独抽离为子组件
目录结构规范:
pages/
└── <page>/
├── index.vue # 父页面(控制器层,零业务逻辑)
└── components/
├── <Page>Search.vue # 搜索筛选组件(纯 UI,只抛回调)
├── <Page>List.vue # 列表组件(纯 UI,只渲染+抛事件)
├── <Page>Card.vue # 卡片组件(可复用)
└── <Page>Detail.vue # 详情弹窗组件
生成要求:
1.父页面只做组件引入、props 分发、事件中转,不写业务逻辑
2.子组件只接收 props 和通过 emit 向上抛事件,不直接调接口
3.样式使用vue支持的写法,色值从蓝湖数据中精确提取
4.图片和图标使用蓝湖 MCP 导出的本地资源路径
```
---
## 阶段五:生成 API + Store(逻辑层)
为每个页面生成对应的 api 和 store 文件,遵循 zc-web 三层数据流规范(页面 → store → api)。
### 规范
| 层级 | 文件 | 职责 |
| ----- | -------------------------- | ------------------------------------------------------------------ |
| API | `api/<page>.ts` | 封装 axios 接口,定义路径/方法/参数类型/响应类型,不写业务逻辑 |
| Store | `stores/modules/<page>.ts` | 管理状态 + 业务逻辑(请求、loading、分页、数据格式化),不操作 DOM |
| Views | `views/<page>/` | 纯 UI 展示,调用 Store 获取数据,用户交互调用 Store 方法 |
### 生成要求
- **store 使用 Pinia setup 写法**:`ref` 状态 + `computed` getters + 普通函数 actions
- **api 使用 `request<T>`**:统一 axios 实例,不直接 import axios
- **页面禁止直接 import api**:获取数据必须调对应 store 的 action
- **类型定义**:`types/business/<page>.d.ts` 定义实体类型
### 提示词模板
```javascript
为每个页面生成对应的 api, store 文件,放在 api/ ,stores/modules/ 目录下,遵循以下规范:
- store文件统一管理状态 + 业务逻辑(请求、loading、分页、数据格式化),不直接操作 DOM、不写 UI 代码
- api文件封装 axios,定义接口路径、方法、参数类型、响应类型,不写业务逻辑、不操作状态
- views文件纯 UI 展示,调用 Store 获取数据,用户交互调用 Store 方法,不调用 API、尽量不写业务逻辑
- api, store与 UI 组件完全解耦,只通过返回值暴露数据和方法
- store 使用 Pinia setup 写法,api 使用 request<T>
```
---
## 阶段六:自动识别并提取公共组件
```javascript
请扫描整个项目的 pages 目录,识别跨多个页面重复出现的 UI 模块(如导航栏、底部 Tab、空状态、加载动画、通用弹窗等),将它们提取到 components/common/ 目录下作为全局公共组件,并替换所有页面中的引用。
```
**提取判定规则**(与 vue-project-structure skill 一致):
- 被 ≥2 个页面使用 → 放 `components/`
- 仅 1 个页面使用 → 放 `views/<page>/cpns/`
---
## 阶段七:自动化验证与修正
逐页对比蓝湖设计稿和生成的代码,自动检查并修复:
1. **间距、色值、字号**是否与设计稿一致
2. **布局**是否正确(flex 方向、对齐方式)
3. **圆角、阴影、渐变**是否还原
4. **图片资源路径**是否正确
5. **组件拆分**是否合理(是否有超过 300 行的组件需要进一步拆分)
6. **规范合规**:是否有 `as any`、`@ts-ignore`、直接 import api 等违规
**验证命令**:
```bash
pnpm build # 含 vue-tsc 类型检查,零错误才算通过
```
---
## 完整工作流调用顺序
```
阶段一(PRD) → 阶段二(蓝湖MCP) → 阶段三(页面规划) → 阶段四(代码生成) → 阶段五(API+Store) → 阶段六(公共组件) → 阶段七(验证修正)
```
每个阶段可独立调用,也支持一键全流程。
---
## 注意事项
1. **始终先加载 vue-project-structure skill**:所有代码必须符合项目规范
2. **每次动手前先读取项目目录**:确认目标文件存在与现状
3. **命名一致性**:页面 `home` → `api/home.ts` + `stores/modules/home.ts` + `types/business/home.d.ts` + `views/home/` 四者同名
4. **不创建新顶层目录**:在既有职责目录中创建文件
5. **蓝湖 MCP 不可用时**:提示用户检查 Docker 容器和 Cookie 配置,降级为手动提供设计参数

View File

@@ -0,0 +1,77 @@
---
name: karpathy-guidelines
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
license: MIT
---
# Karpathy Guidelines
Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
Test file placement:
- Put test files in the dedicated `src/tests/` directory, never next to the source files they test.
- Mirror the source structure inside `src/tests/` (e.g. `src/utils/format.ts` → `src/tests/utils/format.test.ts`).
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.

View File

@@ -0,0 +1,382 @@
---
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
---
# cz-web 项目目录结构规范(强制 · 网页端)
> 完整版调试文档见 `docs/vue-project-guide.md`(其中中后台相关部分已废弃),**本 skill 为唯一现行规范,以其为准**;未覆盖场景参照 Vue 3 官方目录约定。
## 第一步:读取项目目录(每次加载必做)
动手写任何代码前,先用工具映射当前项目结构,确认正在操作的正确层级:
1. 用 `codegraph_files` 或 `glob` 列出 `src/` 下的现行目录结构(如某目录尚不存在,应在对应职责目录创建,而不是自创新顶层目录)。
2. 读取目标文件及其同级文件,确认现有命名风格与组织方式(页面名、store 名、api 名必须一致)。
3. 只有在目录树确认完毕后,才允许创建/修改文件。
## 核心原则
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>`。
## 数据流架构(强约束)
```text
页面组件 (views/home/index.vue)
│ ① 页面只调用 store 的 action / 读取 store 的 state(模板渲染数据)
▼
Pinia store (stores/modules/home.ts) —— 数据层
│ ② store 的 action 内部调用 api 接口函数 → await → 写入 state / 返回结果
▼
API (api/home.ts) —— 接口层
│ ③ 经 utils/request.ts 发 HTTP 请求
▼
后端服务器
```
铁律:
- **页面禁止 `import` api 层文件**;获取数据必须调对应页面 store 的方法。
- **api 层是纯接口层**:只发请求、返回数据(带类型),不做业务状态处理、不缓存。
- **store 承担取数 + 数据状态 + 页面间共享 + 缓存**;与页面同名的 store 责任边界即该页面。
- 调用链必须是 `页面 → store → api` 单向,禁止跳过 store 或反向依赖。
## 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/ # 登录(仅存在会员体系时)
```
**命名一致性(可分层互查)**:页面 `home` → `api/home.ts` + `stores/modules/home.ts`(`useHomeStore`) + `types/business/home.d.ts` + `views/home/`,四者同名。
## src/ 每个文件的作用(新建/修改文件前先对照本节定位)
### 入口与根文件
| 文件 | 作用 |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `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/`
### 样式规范(less)
- 全项目使用 **less**,禁止新增 `.scss/.css` 业务样式
- 全局变量统一在 `styles/variables.less`;组件内禁止魔法色值
- vite `css.preprocessorOptions.less` 配置 `additionalData` 自动注入 variables
- 依赖:`pnpm add -D less`(vite 内置 less 编译支持)
### 工程化硬性约定
- **导入一律 `@/` 别名**;禁止跨目录相对路径(`../` 超过一层即违规)
- utils 与 composables 判据:**需要响应式状态或生命周期 → composables;否则 → utils**
- 类型归位:实体类型 → `types/business/<page>.d.ts`(与 api 同名);接口结构 → `types/api.d.ts`
- **禁止 `as any` / `@ts-ignore` / `@ts-expect-error`**
## 后续使用方式(工作流)
### 场景速查:想做什么 → 去找哪个文件
| 想做的事 | 动哪个文件 | 注意 |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| 给首页加一块新数据 | `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"为例)
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/、数据在 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 类型检查)零错误
> 本规范为项目强约束:**新增与修改的代码必须严格遵循**。发现不符合结构约定的代码,应指出问题并要求按规范调整,而不是放任通过。

7
.gitignore vendored
View File

@@ -11,13 +11,14 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
docs
.claude
read_prototype.js
RTK.md RTK.md
.playwright-mcp .playwright-mcp
.omo .omo
.opencode .opencode
.workbuddy
specs2
.shots
docs
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

31
.mock/afterSales.json Normal file
View File

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

102
.mock/bills.json Normal file
View File

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

19
.mock/files.json Normal file
View File

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

27
.mock/finance.json Normal file
View File

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

42
.mock/progress.json Normal file
View File

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

65
AGENTS.md Normal file
View File

@@ -0,0 +1,65 @@
# AGENTS.md — cz-web 代码开发流程
本项目所有 AI 编码任务统一走以下流程。三个 skill 各司其职,**冲突时以 auto-dev 流程为主线、vue-project-structure 为结构裁决、karpathy-guidelines 为编码底线**。
## 一、三个 skill 的分工
| 角色 | Skill | 职责 | 何时加载 |
| ---- | ----- | ---- | -------- |
| **主流程** | [auto-dev](.claude/skills/auto-dev/SKILL.md) | 从原型/PRD/设计稿到可运行代码的完整管线(7 个阶段) | 提到"自动化开发、从原型生成代码、蓝湖生成页面、PRD 生成页面"时 |
| **项目规则** | [vue-project-structure](.claude/skills/vue-project-structure/SKILL.md) | 目录归属、三层数据流(页面 → store → api)、命名与文件作用 | **任何**新建/移动/修改 `src/` 下文件之前,必读 |
| **编码规则** | [karpathy-guidelines](.claude/skills/karpathy-guidelines/SKILL.md) | 少代码、外科手术式修改、可验证的成功标准 | 写代码、review、重构时始终生效 |
优先级:**项目规则(结构归属)> 编码规则(写法底线)**;auto-dev 只决定"做什么、按什么顺序做",不覆盖前两者的规范。
## 二、标准代码流程
```
① 读取现状
└─ 用 codegraph_files/glob 映射 src/ 结构,读目标文件及同级文件
→ verify: 能说出目标文件该放哪个目录、同名文件是否已存在
② 明确需求(karpathy §1)
└─ 陈述假设;多种解释时先列出并确认;不清楚就停下来问
→ verify: 无隐含假设,成功标准可验证
③ 规划文件落位(vue-project-structure)
└─ 对照"src/ 每个文件的作用"定位:
接口 api/<page>.ts、数据 stores/modules/<page>.ts、
页面 views/<page>/、类型 types/business/<page>.d.ts 四者同名
→ verify: 目录归属正确,不新建顶层目录
④ 编写/修改代码(auto-dev 阶段四~五 + karpathy §2/§3)
└─ 数据流铁律:页面 → store → api 单向,页面禁止 import api
组件两级归属:≥2 页面复用 → components/;单页独有 → views/<page>/cpns/
最小改动:只改与需求相关的行,不顺手重构
→ verify: 每一行改动都能追溯到用户需求
⑤ 测试(karpathy §4)
└─ 测试文件统一放 src/tests/,镜像源码结构
(src/utils/format.ts → src/tests/utils/format.test.ts)
→ verify: 测试通过
⑥ 验证与修正(auto-dev 阶段七)
└─ pnpm build(含 vue-tsc 类型检查)零错误
逐条核对 vue-project-structure 的"修改代码强制检查清单"
→ verify: build 退出码 0、检查清单全过
```
## 三、硬性约束(任一违反即返工)
1. 页面禁止 `import` api 层;数据必须经 store。
2. 禁止 `as any` / `@ts-ignore` / `@ts-expect-error`。
3. 导入一律 `@/` 别名,禁止跨目录相对路径(`../` 超过一层即违规)。
4. 样式只用 less,色值取自 `styles/variables.less`,禁止魔法值。
5. 不顺手"改进"无关代码;与本次需求无关的死代码只报告、不删除。
6. 测试文件只放 `src/tests/`,不与源码混放。
7. 收尾必须跑 `pnpm build`,零错误才算完成。
## 四、触发速查
| 用户说 | 加载 |
| ------ | ---- |
| 自动化开发 / 原型生成代码 / 蓝湖 / PRD 生成页面 | auto-dev(主)+ vue-project-structure |
| 新增/修改 `src/` 下任何文件、新文件放哪 | vue-project-structure |
| 写代码 / 改代码 / 重构 / review | karpathy-guidelines |

View File

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

8
pnpm-lock.yaml generated
View File

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

View File

@@ -1,44 +1,51 @@
import { request, CLIENT_ID } from '@/utils/request'
import type { LoginResult as AuthLoginResult, RegisterResult } from '@/types/business/auth'
import type { import type {
LoginByAccountParams,
LoginBySmsParams, LoginBySmsParams,
LoginResult, LoginResult,
RegisterParams,
ResetPasswordParams, ResetPasswordParams,
User, User,
} from '@/types/business/user' } from '@/types/business/user'
import { isPhone, isValidPassword } from '@/utils/validate' import { isPhone, isValidPassword } from '@/utils/validate'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变) export const PC_CLIENT_ID = CLIENT_ID
export function loginByAccountApi(data: { username: string; password: string }): Promise<AuthLoginResult> {
return request<AuthLoginResult>({
url: '/auth/login',
method: 'post',
data: {
username: data.username,
password: data.password,
clientId: PC_CLIENT_ID,
grantType: 'password',
},
})
}
export function registerByAccountApi(data: { username: string; password: string }): Promise<RegisterResult> {
return request<RegisterResult>({
url: '/auth/register',
method: 'post',
data: {
username: data.username,
password: data.password,
userType: 'sys_user',
clientId: PC_CLIENT_ID,
grantType: 'password',
},
})
}
// TODO(api): 以下为原 mock 逻辑(保留,待后端对应接口就绪后替换)
const SIX_DIGIT_CODE = /^\d{6}$/ const SIX_DIGIT_CODE = /^\d{6}$/
/** 发送短信验证码 */
export function sendSmsCode(phone: string): Promise<void> { export function sendSmsCode(phone: string): Promise<void> {
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确')) if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
return Promise.resolve() return Promise.resolve()
} }
/** 账号密码登录(mock:demo 账号可登录,其余提示先注册) */
export function loginByAccount(params: LoginByAccountParams): Promise<LoginResult> {
const { account, password } = params
if (!account.trim() || !password) {
return Promise.reject(new Error('请输入账号和密码'))
}
if (account === 'demo') {
const user: User = {
id: 'u1',
nickname: '王永志',
avatar: '',
phone: '186****618',
email: 'yongzh******4@163.com',
passwordModifiedAt: '',
}
return Promise.resolve({ token: `mock-token-${Date.now()}`, user })
}
return Promise.reject(new Error('登录失败,请先注册账号'))
}
/** 手机验证码登录(mock:验证码须为 6 位数字) */
export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> { export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> {
const { phone, code } = params const { phone, code } = params
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确')) if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
@@ -69,20 +76,6 @@ function validateAuthForm(
return null return null
} }
/** 注册 */
export function register(params: RegisterParams): Promise<void> {
const message = validateAuthForm(
params.phone,
params.code,
params.password,
params.confirmPassword,
params.agree,
)
if (message) return Promise.reject(new Error(message))
return Promise.resolve()
}
/** 重置密码 */
export function resetPassword(params: ResetPasswordParams): Promise<void> { export function resetPassword(params: ResetPasswordParams): Promise<void> {
const message = validateAuthForm( const message = validateAuthForm(
params.phone, params.phone,

View File

@@ -1,227 +1,51 @@
import type { BusinessType, PageResult } from '@/types/api' //
import type { Case, CaseQuery, ScaleRange } from '@/types/business/cases' import { request, serializeQuery } from '@/utils/request'
import type {
CaseSelectionModel,
ProjectCaseAndCaseCountVo,
ProjectCaseUserBo,
ProjectCaseVo,
} from '@/types/business/cases'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用 export function getCaseList(
bo: ProjectCaseUserBo,
/** 案例 mock(可变,收藏状态在前端内存中翻转演示) */ pageNum: number,
const MOCK_CASES: Case[] = [ pageSize: number,
{ ): Promise<ProjectCaseAndCaseCountVo> {
id: 'case-001', return request<ProjectCaseAndCaseCountVo>({
title: '自动化生产线升级改造项目', url: '/web/projectCase/list',
category: 'manufacturing', method: 'get',
cover: '', params: { ...bo, pageNum, pageSize },
finishDate: '2026/8/10', paramsSerializer: { serialize: serializeQuery },
teamSize: 50,
scale: '300万',
industry: '自动化',
duration: '150个工作日',
clientType: '企业',
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
video: '',
isFavorite: false,
},
{
id: 'case-002',
title: '智能工厂物联网整体解决方案',
category: 'iot',
cover: '',
finishDate: '2026/8/10',
teamSize: 30,
scale: '500万',
industry: '工厂服务',
duration: '200个工作日',
clientType: '企业',
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
video: '',
isFavorite: false,
},
{
id: 'case-003',
title: '智能穿戴设备PCB设计与量产',
category: 'hardware',
cover: '',
finishDate: '2026/8/10',
teamSize: 50,
scale: '10-50万',
industry: '智能设备',
duration: '30个工作日',
clientType: '企业',
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
video: '',
isFavorite: false,
},
{
id: 'case-004',
title: '智能园区综合管理平台开发',
category: 'internet',
cover: '',
finishDate: '2026/8/10',
teamSize: 20,
scale: '200万',
industry: '园区服务',
duration: '200个工作日',
clientType: '企业',
background: '传统园区管理普遍"数据孤岛",安防、能耗、物业、招商等系统独立运行,缺乏联动,管理手段依赖人工巡检和纸质流程。',
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车、能耗等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒,实现全场景数字化覆盖。',
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
video: '',
isFavorite: false,
},
{
id: 'case-005',
title: '电商中台系统定制开发',
category: 'internet',
cover: '',
finishDate: '2026/7/20',
teamSize: 15,
scale: '150万',
industry: '电商零售',
duration: '120个工作日',
clientType: '企业',
background: '品牌方多渠道订单分散,库存与会员数据不通,需要统一电商中台。',
painPoints: '1、多平台库存不同步;2、会员体系割裂;3、营销活动上线周期长。',
requirements: '建设商品/库存/订单/会员中台,开放 API 对接主流电商平台。',
deliverables: '1、中台系统与管理后台;2、开放 API 与对接文档;3、数据迁移方案;4、上线陪跑服务。',
video: '',
isFavorite: false,
},
{
id: 'case-006',
title: '教培机构在线学习平台',
category: 'internet',
cover: '',
finishDate: '2026/6/30',
teamSize: 12,
scale: '80万',
industry: '教育培训',
duration: '90个工作日',
clientType: '企业',
background: '教培机构需要线上授课、作业批改与学员管理一体化能力。',
painPoints: '1、授课依赖第三方工具,数据不沉淀;2、续费转化缺少数据支撑。',
requirements: '交付直播授课、题库作业、班级管理、数据分析完整平台(Web + 小程序)。',
deliverables: '1、Web 学习平台;2、学员端小程序;3、题库与直播模块;4、运维部署手册。',
video: '',
isFavorite: false,
},
{
id: 'case-007',
title: '医院智能随访系统开发',
category: 'internet',
cover: '',
finishDate: '2026/5/18',
teamSize: 18,
scale: '250万',
industry: '医疗健康',
duration: '180个工作日',
clientType: '企业',
background: '三甲医院需要对出院患者进行结构化随访,提升复诊率与科研数据质量。',
painPoints: '1、电话随访效率低;2、随访数据非结构化,科研难复用。',
requirements: '建设随访计划引擎、患者端触达与随访数据仓,对接 HIS 系统。',
deliverables: '1、随访管理平台;2、患者端小程序;3、HIS 对接中间件;4、随访报表与数据字典。',
video: '',
isFavorite: false,
},
{
id: 'case-008',
title: '园区光伏能耗管理系统',
category: 'iot',
cover: '',
finishDate: '2026/4/25',
teamSize: 10,
scale: '120万',
industry: '能源环保',
duration: '60个工作日',
clientType: '企业',
background: '园区部署屋顶光伏后需要实时监控发电与用电,实现削峰填谷。',
painPoints: '1、发电数据分散在多品牌逆变器;2、能耗缺少分项计量。',
requirements: '接入逆变器/电表数据,建设发电与能耗看板,输出节能策略。',
deliverables: '1、能耗采集网关适配;2、监控看板系统;3、告警与报表;4、验收测试报告。',
video: '',
isFavorite: false,
},
]
const SCALE_BOUNDS: Record<ScaleRange, [number, number]> = {
lt50w: [0, 50],
'50to100w': [50, 100],
'100to300w': [100, 300],
'300to500w': [300, 500],
gt500w: [500, Number.POSITIVE_INFINITY],
}
/** 从展示值解析规模数值(万),如 '300万' → 300,'10-50万' → 10 */
function parseScaleValue(scale: string): number {
const value = Number.parseFloat(scale)
return Number.isNaN(value) ? 0 : value
}
function matchScale(item: Case, selected: ScaleRange[]): boolean {
if (selected.length === 0) return true
const value = parseScaleValue(item.scale)
return selected.some((key) => {
const [min, max] = SCALE_BOUNDS[key]
return value >= min && value < max
}) })
} }
function matchDate(item: Case, range: [string, string] | null): boolean { export function getCaseDetail(id: string): Promise<ProjectCaseVo | null> {
if (!range) return true return request<ProjectCaseVo | null>({
const time = new Date(item.finishDate).getTime() url: `/web/projectCase/${id}`,
const start = new Date(range[0]).getTime() method: 'get',
const end = new Date(range[1]).getTime()
return time >= start && time <= end
}
function applyFilters(query: CaseQuery): Case[] {
return MOCK_CASES.filter((item) => {
if (query.category !== 'all' && item.category !== query.category) return false
if (query.industries.length > 0 && !query.industries.includes(item.industry)) return false
if (!matchScale(item, query.scales)) return false
if (!matchDate(item, query.dateRange)) return false
if (query.keyword && !item.title.includes(query.keyword)) return false
return true
}) })
} }
/** 案例列表(筛选 + 分页) */ export function collectCase(id: string): Promise<void> {
export function getCaseList(query: CaseQuery): Promise<PageResult<Case>> { return request<void>({
const filtered = applyFilters(query) url: '/web/projectCase/collect',
const start = (query.page - 1) * query.pageSize method: 'post',
return Promise.resolve({ data: { id },
list: filtered.slice(start, start + query.pageSize),
total: filtered.length,
}) })
} }
/** 案例详情(不存在返回 null) */ export function getCaseCategories(): Promise<CaseSelectionModel> {
export function getCaseDetail(id: string): Promise<Case | null> { return request<CaseSelectionModel>({
const found = MOCK_CASES.find((item) => item.id === id) ?? null url: '/web/projectCase/categories',
return Promise.resolve(found) method: 'get',
})
} }
/** 收藏/取消收藏,返回最新案例数据 */ export function getFeaturedCases(type?: string): Promise<ProjectCaseVo[]> {
export function toggleFavorite(id: string): Promise<Case> { return request<ProjectCaseVo[]>({
const found = MOCK_CASES.find((item) => item.id === id) url: '/web/projectCase/mostViewed',
if (!found) return Promise.reject(new Error('案例不存在')) method: 'get',
found.isFavorite = !found.isFavorite params: type ? { type } : undefined,
return Promise.resolve({ ...found }) })
}
/** 业务类型计数(筛选面板角标) */
export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> {
const counts: Partial<Record<BusinessType, number>> = {}
for (const item of MOCK_CASES) {
counts[item.category] = (counts[item.category] ?? 0) + 1
}
return Promise.resolve(counts)
} }

View File

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

View File

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

View File

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

View File

@@ -1,180 +1,73 @@
import type { BusinessType } from '@/types/api' //
import type { Case } from '@/types/business/cases' import { request } from '@/utils/request'
import type { BusinessLine, HomeStats, ServiceType } from '@/types/business/home' import type {
AgreementFormVo,
BannerVo,
PortalEntryGroup,
PortalEntryVo,
ProjectStatusCount,
ProjectSubmissionInfo,
ProjectSubmissionVo,
} from '@/types/business/home'
// TODO(api): 待接口文档到位后,将以下 mock 返回替换为 request<T> 调用(页面 → store → api 数据流不变) export function getProjectStatusCount(): Promise<ProjectStatusCount> {
return request<ProjectStatusCount>({
const SERVICE_TYPES: ServiceType[] = [ url: '/web/home-page/project-status-count',
{ label: 'Web应用', desc: '响应式 Web 与后台管理系统定制开发' }, method: 'get',
{ label: '小程序', desc: '微信/支付宝小程序开发与上架' }, })
{ label: 'APP', desc: 'iOS/Android 移动应用定制开发' },
{ label: '二次开发', desc: '基于现有系统的功能扩展与重构' },
{ label: '桌面工具', desc: '跨平台桌面端工具与自动化软件' },
]
const SERVICE_CONTENT = `一、产品咨询与规划服务
1、业务诊断与梳理:深入调研客户现有业务流程,识别数字化痛点与改进机会。
2、竞品分析与市场调研:分析行业标杆产品,提炼差异化竞争优势。
3、产品定位与MVP规划:明确核心用户群体,规划最小可行性产品(MVP)功能集,制定迭代路线图。
4、技术可行性评估:评估所选技术栈的成熟度、扩展性及与现有系统的兼容性。
二、UI/UX 设计服务
1、用户研究与体验地图:构建用户画像,梳理用户旅程,优化关键触点体验。
2、交互原型设计:制作高保真可交互原型,直观展示产品逻辑与操作流程,支持早期验证。
3、视觉UI设计:遵循最新设计趋势与品牌规范,输出图标、界面、动效等视觉元素。
4、设计规范与组件库:建立统一的设计系统(Design System),确保多端体验一致性,提升后续迭代效率。
三、技术开发服务
5、前端开发:基于 React/Vue/Flutter 等框架,实现响应式Web、H5、小程序及跨平台移动端应用。
6、后端开发:采用 Spring Cloud、Node.js、Go 等微服务架构,构建高并发、高可用的业务接口与逻辑。
7、数据库设计:根据业务特性设计关系型(MySQL)与非关系型(Redis、MongoDB)数据库集群方案。
8、DevOps 与云服务部署:搭建 CI/CD 自动化流水线,基于 Docker/K8s 进行容器化部署,保障系统弹性伸缩。
四、质量保障(QA)服务
1、功能测试:覆盖单元测试、集成测试、系统测试及UAT(用户验收测试)支持。
2、性能测试:进行压力测试、负载测试,定位系统瓶颈,优化响应速度。
3、安全测试:实施渗透测试、漏洞扫描,保障数据安全与隐私合规。
4、兼容性测试:覆盖主流浏览器、操作系统及移动设备型号。
五、运维与持续运营服务
1、7x24小时监控:实时监控系统健康度、流量及异常日志。
2、数据运营分析:接入埋点系统,输出用户行为分析报告,指导产品优化。
3、迭代升级:基于用户反馈与业务增长需求,提供持续的功能迭代与版本更新。`
const BUSINESS_LINES: BusinessLine[] = [
{
id: 'internet',
name: '互联网软件',
desc: 'Web/小程序/APP/二次开发/桌面工具等',
slogan: '专业的软件定制开发服务,从需求到上线全流程保障',
icon: '',
serviceContent: SERVICE_CONTENT,
serviceTypes: SERVICE_TYPES,
video: '',
},
{
id: 'hardware',
name: '硬件电路',
desc: '原理图设计/PCB设计/电气效验等',
slogan: '专业的硬件电路设计服务,从方案到量产全流程保障',
icon: '',
serviceContent: SERVICE_CONTENT,
serviceTypes: SERVICE_TYPES,
video: '',
},
{
id: 'iot',
name: '物联网全案',
desc: '软硬件开发+云端一体化/定制化开发等',
slogan: '专业的物联网软硬一体化服务,从感知到云端全流程保障',
icon: '',
serviceContent: SERVICE_CONTENT,
serviceTypes: SERVICE_TYPES,
video: '',
},
{
id: 'manufacturing',
name: '生产制造',
desc: 'PCB制版/SMT/整机装配/定制生产等',
slogan: '专业的研发智造生产服务,从制版到交付全流程保障',
icon: '',
serviceContent: SERVICE_CONTENT,
serviceTypes: SERVICE_TYPES,
video: '',
},
]
const FEATURED_CASES: Case[] = [
{
id: 'case-001',
title: '自动化生产线升级改造项目',
category: 'manufacturing',
cover: '',
finishDate: '2026/8/10',
teamSize: 50,
scale: '300万',
industry: '自动化',
duration: '150个工作日',
clientType: '企业',
background: '传统产线效率低、人工依赖高,客户需要通过自动化升级提升产能与品控水平。',
painPoints: '1、产线依赖人工巡检,异常响应慢;2、设备数据孤岛,缺乏统一监控;3、工艺参数无法追溯。',
requirements: '建设产线数据采集与监控平台,完成关键工位自动化改造,打通 MES 系统接口。',
deliverables: '1、产线自动化改造方案;2、PLC 控制程序与上位机软件;3、数据看板与运维手册;4、培训与质保服务。',
video: '',
isFavorite: false,
},
{
id: 'case-002',
title: '智能工厂物联网整体解决方案',
category: 'iot',
cover: '',
finishDate: '2026/8/10',
teamSize: 30,
scale: '500万',
industry: '工厂服务',
duration: '200个工作日',
clientType: '企业',
background: '园区工厂多系统独立运行,能耗与设备管理粗放,需要整体数字化升级。',
painPoints: '1、门禁/监控/能耗子系统数据不互通;2、能源浪费缺乏精细监测;3、决策缺少实时数据支撑。',
requirements: '构建 IoT 平台接入设备数据,建设综合驾驶舱,实现能耗监测与智能告警。',
deliverables: '1、IoT 接入平台;2、综合驾驶舱大屏;3、能耗分析系统;4、集成联调与培训服务。',
video: '',
isFavorite: false,
},
{
id: 'case-003',
title: '智能穿戴设备PCB设计与量产',
category: 'hardware',
cover: '',
finishDate: '2026/8/10',
teamSize: 50,
scale: '10-50万',
industry: '智能设备',
duration: '30个工作日',
clientType: '企业',
background: '客户推出新一代智能穿戴产品,需要高集成度低功耗 PCB 设计并快速量产。',
painPoints: '1、板载空间受限,器件布局密集;2、功耗要求严格;3、量产良率需爬坡。',
requirements: '完成原理图与 PCB 设计、打样验证、SMT 贴片与整机装配量产。',
deliverables: '1、原理图与 PCB 设计文件;2、样板与测试报告;3、量产良率报告;4、BOM 与工艺文件。',
video: '',
isFavorite: false,
},
{
id: 'case-004',
title: '智能园区综合管理平台开发',
category: 'internet',
cover: '',
finishDate: '2026/8/10',
teamSize: 20,
scale: '200万',
industry: '园区服务',
duration: '200个工作日',
clientType: '企业',
background: '随着新型城镇化建设的加速和"数字中国"战略的深入推进,传统园区管理模式正面临严峻挑战,普遍存在"数据孤岛"现象。',
painPoints: '1、运营效率低下:安保依赖人工巡逻;2、系统孤岛严重:门禁、监控、停车等子系统独立建设;3、能源浪费突出;4、服务体验欠佳;5、决策缺乏依据;6、安全隐患犹存。',
requirements: '打造集"感知、预警、分析、决策、服务"于一体的智能园区综合管理平台,打通园区各子系统壁垒。',
deliverables: '1、Web 端管理平台(驾驶舱/安防/能源/物业);2、移动端服务门户;3、IoT 平台与数据大屏;4、全套技术文档;5、培训与不少于1年免费质保。',
video: '',
isFavorite: false,
},
]
/** 首页数据看板 */
export function getHomeStats(): Promise<HomeStats> {
return Promise.resolve({ pending: 3, inProgress: 3, total: 6 })
} }
/** 业务线列表(首页 4 卡片) */ export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
export function getBusinessLines(): Promise<BusinessLine[]> { return request<ProjectSubmissionVo[]>({
return Promise.resolve(BUSINESS_LINES) url: '/web/home-page/service-type-list',
method: 'get',
})
} }
/** 业务详情(业务详情页) */ export function getServiceTypeDetail(
export function getBusinessDetail(type: BusinessType): Promise<BusinessLine> { id: number | string,
const found = BUSINESS_LINES.find((line) => line.id === type) ): Promise<ProjectSubmissionInfo> {
return Promise.resolve(found ?? BUSINESS_LINES[0]!) return request<ProjectSubmissionInfo>({
url: `/web/home-page/service-type-detail/${id}`,
method: 'get',
})
} }
/** 首页案例精选(category=all 时返回全部) */ export function getCarouselList(): Promise<BannerVo[]> {
export function getFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<Case[]> { return request<BannerVo[]>({
const list = category === 'all' url: '/web/home-page/carousel-list',
? FEATURED_CASES method: 'get',
: FEATURED_CASES.filter((item) => item.category === category) })
return Promise.resolve(list) }
export function getPlatformIntro(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/platform-intro',
method: 'get',
})
}
export function getContactInfo(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/contact-info',
method: 'get',
})
}
export function getPortalEntryList(): Promise<PortalEntryVo[]> {
return request<PortalEntryVo[]>({
url: '/web/vermins/portalEntryUser/list',
method: 'get',
})
}
export function switchPortalEntryGroup(
id: number,
type: PortalEntryGroup,
): Promise<void> {
return request<void>({
url: '/web/vermins/portalEntryUser/add',
method: 'post',
params: { id, type },
})
} }

View File

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

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

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

View File

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

View File

@@ -1,270 +1,177 @@
import type { FileInfo, PageResult } from '@/types/api'
import type { import type {
AfterSales, AfterSalesAttachment,
AfterSalesPayload, AfterSalesPayload,
Project, AfterSalesUploadVo,
ProjectListResult,
ProjectQuery, ProjectQuery,
ProjectStatistics,
RdProjectVo,
RequirementDetailBundle,
} from '@/types/business/project' } from '@/types/business/project'
import { request, serializeQuery } from '@/utils/request'
import { formatDate } from '@/utils/format' import { formatDate } from '@/utils/format'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用 // ─────────────────────────────────────────────────────────────────────────────
// GET /web/requirementPool/list → 控制器 ProjectRequirementUserController#list
// → 服务层 ProjectRequirementServiceImpl#queryUserPageList
// 返回 { total, rows: RdProjectVo[] }
// key → 模糊匹配 projectName / projectNo / requirementDesc
// pageNum/pageSize → 分页参数 PageQuery
// ─────────────────────────────────────────────────────────────────────────────
/** 构造 mock 文件信息 */ // ─────────────────────────────────────────────────────────────────────────────
function mockFile(name: string, size: number, uploadTime: string): FileInfo { // GET /web/requirementPool/getFilterConditions
return { name, size, uploadTime, url: '' } // → 控制器 ProjectRequirementUserController#getFilterConditions
// → 服务层 ProjectRequirementServiceImpl#getFilterConditions
// 业务类型统计 businessTypeCounts: [{ businessTypeId, businessTypeLabel, count }]
// ─────────────────────────────────────────────────────────────────────────────
function pad(value: number): string {
return String(value).padStart(2, '0')
} }
/** 详情 mock 所属项目 id */ function toDateTime(date: Date, endOfDay: boolean): string {
const DETAIL_ID = 'project-004' const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
return `${ymd} ${endOfDay ? '23:59:59' : '00:00:00'}`
/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合) */
const MOCK_PROJECTS: Project[] = [
{
id: 'project-001',
projectNo: 'P8K2M202408001',
projectName: '某大型银行数字化转型方案',
businessType: 'internet',
progress: 64,
status: '待立项',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
},
{
id: 'project-002',
projectNo: 'P8K2M202408002',
projectName: '智慧城市大数据平台建设项目',
businessType: 'iot',
progress: 64,
status: '待验收',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
},
{
id: 'project-003',
projectNo: 'P8K2M202408003',
projectName: '三甲医院指挥医疗信息化系统',
businessType: 'internet',
progress: 64,
status: '已完成',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
},
{
id: DETAIL_ID,
projectNo: '20260813RJ251545',
projectName: '社区康养智能服务系统',
businessType: 'internet',
progress: 64,
status: '进行中',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
},
{
id: 'project-005',
projectNo: 'P8K2M202408005',
projectName: '智能仓储物流分拣系统',
businessType: 'iot',
progress: 64,
status: '售后中',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
afterSales: {
status: '处理中',
type: 'BUG问题',
description: '分拣识别偶发误判,需要排查',
remark: '',
attachments: [],
createTime: '2026-8-19 17:00',
},
},
{
id: 'project-006',
projectNo: 'P8K2M202408006',
projectName: '新能源电站运维监控平台',
businessType: 'internet',
progress: 64,
status: '已取消',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
},
]
/** 项目详情 mock(社区康养智能服务系统) */
const PROJECT_DETAIL: Project = {
...MOCK_PROJECTS[3],
progress: 65,
startTime: '2026-8-13 11:04:12',
endTime: '2026-10-30 18:00:00',
milestones: [
{ name: '需求确认', status: '已完成', time: '2026-8-13 11:04:12' },
{ name: '原型设计', status: '已完成', time: '2026-8-13 11:04:12' },
{ name: '开发实现', status: '进行中' },
{ name: '测试验收', status: '待开始' },
{ name: '项目交付', status: '待开始' },
],
team: [
{ role: '项目经理', name: '张三', avatarChar: '张' },
{ role: '项目经理', name: '张三', avatarChar: '张' },
],
files: [
mockFile('项目需求规格说明书.pdf', 2516582, '2026-08-19 17:00'),
mockFile('项目需求说明书.word', 1887437, '2026-08-19 17:00'),
],
paymentPlans: [
{
id: 'bill-001',
planName: '第一期·预付款(比例33%)',
billNo: '20260813ZZ251245',
amount: 20100,
status: '已支付',
tradeNo: 'T20260819153035678K2M001',
projectName: '社区康养智能服务系统',
},
{
id: 'bill-002',
planName: '第二期·付款(比例33%)',
billNo: '20260813ZZ251245',
amount: 20100,
status: '待支付',
projectName: '社区康养智能服务系统',
},
{
id: 'bill-003',
planName: '第三期·尾款(比例33%)',
billNo: '20260813ZZ251245',
amount: 20100,
status: '未到期',
projectName: '社区康养智能服务系统',
},
],
invoiceRecords: [
mockFile('增值税专用发票.pdf', 325120, '2026-08-19 17:00'),
mockFile('电子回单.jpg', 186368, '2026-08-19 17:00'),
],
contractFile: mockFile('社区康养智能服务系统开发合同.pdf', 1039360, '2026-08-19 17:00'),
afterSales: {
status: '处理中',
type: 'BUG问题',
description: '具体请看清单',
remark: '',
attachments: [],
createTime: '2026-8-19 17:00',
},
} }
/** 提交时间快捷区间匹配 */ function toTimeRange(query: ProjectQuery): { beginTime?: string; endTime?: string } {
function matchTimeShortcut(submitTime: string, shortcut: ProjectQuery['timeShortcut']): boolean { if (query.startDate || query.endDate) {
if (shortcut === 'all') return true return {
// mock 时间为 '2026-8-17 14:00',替换为 / 保证各环境可解析 beginTime: query.startDate ? `${query.startDate} 00:00:00` : undefined,
const time = new Date(submitTime.replaceAll('-', '/')).getTime() endTime: query.endDate ? `${query.endDate} 23:59:59` : undefined,
}
}
if (query.timeShortcut === 'all') return {}
const start = new Date() const start = new Date()
if (shortcut === '3m') start.setMonth(start.getMonth() - 3) if (query.timeShortcut === '3m') start.setMonth(start.getMonth() - 3)
if (shortcut === '6m') start.setMonth(start.getMonth() - 6) else if (query.timeShortcut === '6m') start.setMonth(start.getMonth() - 6)
if (shortcut === '1y') start.setFullYear(start.getFullYear() - 1) else start.setFullYear(start.getFullYear() - 1)
return time >= start.getTime() return { beginTime: toDateTime(start, false), endTime: toDateTime(new Date(), true) }
} }
/** 项目列表(筛选 + 分页,pageSize 固定 8) */ export function getProjectList(query: ProjectQuery): Promise<ProjectListResult> {
export function getProjectList(query: ProjectQuery): Promise<PageResult<Project>> { return request<ProjectListResult>({
const filtered = MOCK_PROJECTS.filter((item) => { url: '/web/requirementPool/list',
if ( method: 'get',
query.keyword paramsSerializer: { serialize: serializeQuery },
&& !item.projectName.includes(query.keyword) params: {
&& !item.projectNo.includes(query.keyword) pageNum: query.pageNum,
) { pageSize: query.pageSize,
return false key: query.keyword || undefined,
} businessTypes: query.businessTypes.length > 0 ? [...query.businessTypes] : undefined,
if (query.businessType !== 'all' && item.businessType !== query.businessType) return false status: query.status || undefined,
if (query.status !== 'all' && item.status !== query.status) return false ...toTimeRange(query),
if (!matchTimeShortcut(item.submitTime, query.timeShortcut)) return false },
return true
})
const pageSize = query.pageSize > 0 ? query.pageSize : 8
const start = (query.page - 1) * pageSize
return Promise.resolve({
list: filtered.slice(start, start + pageSize),
total: filtered.length,
}) })
} }
/** 项目详情(不存在返回 null;仅详情项目返回完整数据) */ export function getProjectFilterConditions(): Promise<ProjectStatistics> {
export function getProjectDetail(id: string): Promise<Project | null> { return request<ProjectStatistics>({
if (id === DETAIL_ID) return Promise.resolve({ ...PROJECT_DETAIL }) url: '/web/requirementPool/getFilterConditions',
const found = MOCK_PROJECTS.find((item) => item.id === id) ?? null method: 'get',
return Promise.resolve(found ? { ...found } : null) })
} }
/** 上传项目文件(追加进详情文件列表) */ export function getCompletedProjects(): Promise<RdProjectVo[]> {
export function uploadProjectFile(file: File): Promise<FileInfo> { return request<ProjectListResult>({
const info: FileInfo = { url: '/web/requirementPool/list',
name: file.name, method: 'get',
params: {
pageNum: 1,
pageSize: 200,
status: 'COMPLETED',
},
}).then((result) => result?.rows ?? [])
}
// ─────────────────────────────────────────────────────────────────────────────
// GET /web/requirementPool/get/progress/{id} → 进度详情 ProjectRequirementProgressVo
// GET /web/requirementPool/get/files/{id} → 文件资料 ProjectRequirementFilesVo
// GET /web/requirementPool/get/finance/{id} → 财务信息 ProjectRequirementFinanceVo
// GET /web/requirementPool/get/afterSales/{id} → 售后信息 ProjectRequirementAfterSalesVo
// → 控制器 ProjectRequirementUserController#getProgress / #getFiles / #getFinance / #getAfterSales
// → 服务层 ProjectRequirementServiceImpl#queryProgress / queryFiles / queryFinance / queryAfterSales
//
// ─────────────────────────────────────────────────────────────────────────────
export type ProjectDetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
const DETAIL_TAB_URL: Record<ProjectDetailTab, string> = {
progress: '/web/requirementPool/get/progress',
files: '/web/requirementPool/get/files',
finance: '/web/requirementPool/get/finance',
afterSales: '/web/requirementPool/get/afterSales',
}
export function getProjectDetailTab<T extends ProjectDetailTab>(
id: string,
tab: T,
): Promise<RequirementDetailBundle[T] | null> {
return request<RequirementDetailBundle[T] | null>({
url: `${DETAIL_TAB_URL[tab]}/${id}`,
method: 'get',
})
}
export function getProjectDetailBundle(id: string): Promise<RequirementDetailBundle | null> {
return Promise.all([
getProjectDetailTab(id, 'progress'),
getProjectDetailTab(id, 'files'),
getProjectDetailTab(id, 'finance'),
getProjectDetailTab(id, 'afterSales'),
]).then(([progress, files, finance, afterSales]) => {
if (!progress || !files || !finance || !afterSales) return null
return { progress, files, finance, afterSales }
})
}
// ─────────────────────────────────────────────────────────────────────────────
// → ProjectRequirementUserController#requestAfterSale / #withdrawAfterSale
// → ProjectRequirementServiceImpl#requestAfterSale / #withdrawAfterSale
//
//
// ─────────────────────────────────────────────────────────────────────────────
export function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
const data = new FormData()
data.append('file', file)
return request<AfterSalesUploadVo>({
url: '/web/workOrderDraft/upload',
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' },
}).then((vo) => ({
ossId: vo.ossId,
name: vo.originalName || file.name,
size: file.size, size: file.size,
url: vo.url,
uploadTime: formatDate(new Date()), uploadTime: formatDate(new Date()),
url: '', }))
}
PROJECT_DETAIL.files.push(info)
return Promise.resolve({ ...info })
} }
/** 删除项目文件(按索引从详情文件列表移除) */
export function deleteProjectFile(index: number): Promise<void> {
if (index >= 0 && index < PROJECT_DETAIL.files.length) {
PROJECT_DETAIL.files.splice(index, 1)
}
return Promise.resolve()
}
/** 提交售后工单 */
export function submitAfterSales( export function submitAfterSales(
projectId: string, id: string,
payload: AfterSalesPayload, payload: AfterSalesPayload,
): Promise<AfterSales> { ): Promise<void> {
const result: AfterSales = { return request<void>({
status: '处理中', url: '/web/requirementPool/afterSales/request',
method: 'post',
data: {
id,
type: payload.type, type: payload.type,
description: payload.description, reason: payload.reason,
remark: payload.remark, note: payload.note,
attachments: [...payload.attachments], attachmentOssIds: payload.attachmentOssIds,
createTime: formatDate(new Date()), },
} })
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
return Promise.resolve({ ...result, attachments: [...result.attachments] })
} }
/** 取消售后工单 */ export function cancelAfterSales(id: string, reason = ''): Promise<void> {
export function cancelAfterSales(projectId: string): Promise<AfterSales> { return request<void>({
const result: AfterSales = { url: '/web/requirementPool/afterSales/withdraw',
status: '无', method: 'post',
type: '', data: { id, reason },
description: '', })
remark: '',
attachments: [],
createTime: '',
}
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
return Promise.resolve({ ...result })
} }

View File

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

View File

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

View File

@@ -1,62 +0,0 @@
// 用户web端首页接口(真实后端绑定)
// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
// 前缀: /web/home-page 鉴权: 公开接口,无需 token
//
// 使用约定(与项目 SKILL.md 一致):
// 页面/组件禁止直接 import axios,一律经 store 调用本模块的 request<T>。
// 本文件只放「函数签名 + 请求路径」,不写业务逻辑。
import { request } from '@/utils/request'
import type {
ProjectStatusCount,
ProjectSubmissionVo,
BannerVo,
AgreementFormVo,
} from '@/types/web/home'
/** 项目状态统计 */
export function getProjectStatusCount(): Promise<ProjectStatusCount> {
return request<ProjectStatusCount>({
url: '/web/home-page/project-status-count',
method: 'get',
})
}
/** 业务服务类型列表 */
export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
return request<ProjectSubmissionVo[]>({
url: '/web/home-page/service-type-list',
method: 'get',
})
}
/** 业务类型详情 */
export function getServiceTypeDetail(id: number | string): Promise<ProjectSubmissionVo> {
return request<ProjectSubmissionVo>({
url: `/web/home-page/service-type-detail/${id}`,
method: 'get',
})
}
/** 轮播图列表 */
export function getCarouselList(): Promise<BannerVo[]> {
return request<BannerVo[]>({
url: '/web/home-page/carousel-list',
method: 'get',
})
}
/** 平台介绍(富文本 HTML) */
export function getPlatformIntro(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/platform-intro',
method: 'get',
})
}
/** 联系信息(富文本 HTML) */
export function getContactInfo(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/contact-info',
method: 'get',
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,12 +21,8 @@ function onGetCode(): void {
class="sms-code__input" class="sms-code__input"
type="text" type="text"
placeholder="请输入验证码" placeholder="请输入验证码"
> />
<button <button class="sms-code__btn" :disabled="isCounting" @click="onGetCode">
class="sms-code__btn"
:disabled="isCounting"
@click="onGetCode"
>
{{ isCounting ? `${seconds}秒后重新获取` : '获取验证码' }} {{ isCounting ? `${seconds}秒后重新获取` : '获取验证码' }}
</button> </button>
</div> </div>
@@ -41,36 +37,48 @@ function onGetCode(): void {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 40px; height: 40px;
padding: 0 @spacing-base; padding: 0 12px;
border: 1px solid @border-color; border: 1px solid @border-color;
border-radius: @radius-base; border-radius: @radius-sm;
background: @bg-color-light; background: @white;
font-size: @font-size-base; font-size: 13px;
color: #1f2937;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
&:focus { &:focus {
border-color: @primary-color; border-color: @primary-strong;
}
&::placeholder {
color: #1f2937;
} }
} }
// 设计稿 #38:「获取验证码」是带蓝色描边的独立按钮(62×16 文字居中),
// 不是纯文字链接。按键宽 88px,与输入框间距 @spacing-sm。
&__btn { &__btn {
flex-shrink: 0; flex-shrink: 0;
width: 132px; width: 88px;
height: 40px; height: 40px;
border: 1px solid @primary-color; padding: 0;
border-radius: @radius-base; border: 1px solid @primary-strong;
border-radius: @radius-sm;
background: @white; background: @white;
color: @primary-color; color: @primary-strong;
font-size: @font-size-sm; font-size: 12px;
font-weight: 400;
text-align: center;
cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: @primary-light; background: @primary-strong;
color: @white;
} }
&:disabled { &:disabled {
border-color: @border-color-dark; border-color: @text-color-disabled;
color: @text-color-disabled; color: @text-color-disabled;
cursor: not-allowed; cursor: not-allowed;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
src/env.d.ts vendored
View File

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

View File

@@ -1,58 +0,0 @@
<script setup lang="ts">
// 全局浮动入口:快捷提报 / 在线客服(PRD 1.2)
const entries = [
{ label: '快捷提报', icon: '📝', path: '/demand' },
{ label: '在线客服', icon: '💬', path: '/service' },
]
</script>
<template>
<div class="floating-actions">
<RouterLink
v-for="entry in entries"
:key="entry.path"
:to="entry.path"
class="floating-actions__item"
:title="entry.label"
>
<span class="floating-actions__icon">{{ entry.icon }}</span>
<span class="floating-actions__label">{{ entry.label }}</span>
</RouterLink>
</div>
</template>
<style lang="less" scoped>
.floating-actions {
position: fixed;
right: 24px;
bottom: 96px;
z-index: 90;
.flex(column, center);
gap: @spacing-sm;
&__item {
.flex(column, center);
gap: 2px;
width: 56px;
padding: @spacing-sm 0;
border-radius: @radius-lg;
background: @primary-color;
color: @white;
box-shadow: @shadow-lg;
transition: background 0.2s, transform 0.2s;
&:hover {
background: @primary-hover;
transform: translateY(-2px);
}
}
&__icon {
font-size: 18px;
}
&__label {
font-size: 10px;
}
}
</style>

View File

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

View File

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

View File

@@ -1,40 +1,34 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
/**
* 公共布局(AppHeader + router-view + AppFooter + FloatingActions)下的路由
* 完整规划见 docs/page-architecture.md 第 2 节
* M3–M10 视图由子代理并行实现中,交付后本表即为终态
*/
const layoutRoutes: RouteRecordRaw = { const layoutRoutes: RouteRecordRaw = {
path: '/', path: '/',
component: () => import('@/layout/index.vue'), component: () => import('@/layout/index.vue'),
children: [ children: [
// ── 公开 ── { path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页', footer: true } },
{ path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页' } },
{ path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } }, { path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } },
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } }, { path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } }, { path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } },
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报' } }, { path: 'favorites', name: 'Favorites', component: () => import('@/views/profile/favorites.vue'), meta: { title: '收藏列表', requiresAuth: true } },
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true, noFooter: true } },
// ── 登录区 ──
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } }, { path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } },
{ path: 'project/:id', name: 'ProjectDetail', component: () => import('@/views/project/detail.vue'), meta: { title: '项目详情', requiresAuth: true } }, { path: 'project/:id', name: 'ProjectDetail', component: () => import('@/views/project/detail.vue'), meta: { title: '项目详情', requiresAuth: true } },
{ path: 'project/:id/after-sales', name: 'AfterSales', component: () => import('@/views/project/after-sales.vue'), meta: { title: '申请售后', requiresAuth: true } }, { path: 'project/:id/after-sales', name: 'AfterSales', component: () => import('@/views/project/after-sales.vue'), meta: { title: '申请售后', requiresAuth: true } },
{ path: 'contract', name: 'Contract', component: () => import('@/views/contract'), meta: { title: '合同中心', requiresAuth: true } }, { path: 'contract', name: 'Contract', component: () => import('@/views/contract'), meta: { title: '合同中心', requiresAuth: true, noFooter: true } },
{ path: 'contract/:id', name: 'ContractDetail', component: () => import('@/views/contract/detail.vue'), meta: { title: '合同详情', requiresAuth: true } }, { path: 'contract/:id', name: 'ContractDetail', component: () => import('@/views/contract/detail.vue'), meta: { title: '合同详情', requiresAuth: true } },
{ path: 'contract/:id/sign', name: 'ContractSign', component: () => import('@/views/contract/sign.vue'), meta: { title: '签署合同', requiresAuth: true } }, { path: 'contract/:id/sign', name: 'ContractSign', component: () => import('@/views/contract/sign.vue'), meta: { title: '签署合同', requiresAuth: true } },
{ path: 'finance', name: 'Finance', component: () => import('@/views/finance'), meta: { title: '财务中心', requiresAuth: true } }, { path: 'finance', name: 'Finance', component: () => import('@/views/finance'), meta: { title: '财务中心', requiresAuth: true } },
{ path: 'finance/bills', name: 'Bills', component: () => import('@/views/finance/bills.vue'), meta: { title: '账单明细', requiresAuth: true } }, { path: 'finance/bills', name: 'Bills', component: () => import('@/views/finance/bills.vue'), meta: { title: '账单明细', requiresAuth: true } },
{ path: 'finance/bills/:id', name: 'BillDetail', component: () => import('@/views/finance/bill-detail.vue'), meta: { title: '账单详情', requiresAuth: true } }, { path: 'finance/bills/:id', name: 'BillDetail', component: () => import('@/views/finance/bill-detail.vue'), meta: { title: '账单详情', requiresAuth: true } },
{ path: 'finance/bills/:id/pay', name: 'BillPay', component: () => import('@/views/finance/pay.vue'), meta: { title: '扫码支付', requiresAuth: true } }, { path: 'finance/bills/:id/pay', name: 'BillPay', component: () => import('@/views/finance/pay.vue'), meta: { title: '扫码支付', requiresAuth: true } },
{ path: 'finance/bills/:id/pay/result', name: 'PayResult', component: () => import('@/views/finance/pay-result.vue'), meta: { title: '支付结果', requiresAuth: true } }, { path: 'finance/bills/:id/pay/result', name: 'PayResult', component: () => import('@/views/finance/pay-result.vue'), meta: { title: '支付成功', requiresAuth: true } },
{ path: 'finance/checkout', name: 'Checkout', component: () => import('@/views/finance/checkout.vue'), meta: { title: '确认支付', requiresAuth: true } },
{ path: 'finance/invoices', name: 'Invoices', component: () => import('@/views/finance/invoices.vue'), meta: { title: '发票申请', requiresAuth: true } }, { path: 'finance/invoices', name: 'Invoices', component: () => import('@/views/finance/invoices.vue'), meta: { title: '发票申请', requiresAuth: true } },
{ path: 'finance/invoice-titles', name: 'InvoiceTitles', component: () => import('@/views/finance/invoice-titles.vue'), meta: { title: '发票抬头', requiresAuth: true } }, { path: 'finance/invoice-titles', name: 'InvoiceTitles', component: () => import('@/views/finance/invoice-titles.vue'), meta: { title: '发票抬头', requiresAuth: true } },
{ path: 'message', name: 'Message', component: () => import('@/views/message'), meta: { title: '消息中心', requiresAuth: true } }, { path: 'message', name: 'Message', component: () => import('@/views/message'), meta: { title: '消息中心', requiresAuth: true } },
{ path: 'message/:id', name: 'MessageDetail', component: () => import('@/views/message/detail.vue'), meta: { title: '通知详情', requiresAuth: true } }, { path: 'message/:id', name: 'MessageDetail', component: () => import('@/views/message/detail.vue'), meta: { title: '通知详情', requiresAuth: true } },
{ path: 'service', name: 'Service', component: () => import('@/views/service'), meta: { title: '在线客服', requiresAuth: true } }, { path: 'service', name: 'Service', component: () => import('@/views/service'), meta: { title: '在线客服', requiresAuth: true } },
// ── 个人中心(左菜单嵌套)──
{ {
path: 'profile', path: 'profile',
component: () => import('@/views/profile'), component: () => import('@/views/profile'),
@@ -42,8 +36,11 @@ const layoutRoutes: RouteRecordRaw = {
children: [ children: [
{ path: '', redirect: '/profile/security' }, { path: '', redirect: '/profile/security' },
{ path: 'security', name: 'ProfileSecurity', component: () => import('@/views/profile/security.vue'), meta: { title: '账号安全', requiresAuth: true } }, { path: 'security', name: 'ProfileSecurity', component: () => import('@/views/profile/security.vue'), meta: { title: '账号安全', requiresAuth: true } },
{ path: 'security/change-password', name: 'ProfileChangePassword', component: () => import('@/views/profile/change-password.vue'), meta: { title: '修改密码', requiresAuth: true } },
{ path: 'security/bind-phone', name: 'ProfileBindPhone', component: () => import('@/views/profile/bind-phone.vue'), meta: { title: '绑定手机号', requiresAuth: true } },
{ path: 'security/bind-email', name: 'ProfileBindEmail', component: () => import('@/views/profile/bind-email.vue'), meta: { title: '绑定邮箱', requiresAuth: true } },
{ path: 'security/delete-account', name: 'ProfileDeleteAccount', component: () => import('@/views/profile/delete-account.vue'), meta: { title: '注销账号', requiresAuth: true } },
{ path: 'address', name: 'ProfileAddress', component: () => import('@/views/profile/address.vue'), meta: { title: '地址管理', requiresAuth: true } }, { path: 'address', name: 'ProfileAddress', component: () => import('@/views/profile/address.vue'), meta: { title: '地址管理', requiresAuth: true } },
{ path: 'favorites', name: 'ProfileFavorites', component: () => import('@/views/profile/favorites.vue'), meta: { title: '收藏列表', requiresAuth: true } },
{ path: 'qualifications', name: 'ProfileQualifications', component: () => import('@/views/profile/qualifications.vue'), meta: { title: '资质列表', requiresAuth: true } }, { path: 'qualifications', name: 'ProfileQualifications', component: () => import('@/views/profile/qualifications.vue'), meta: { title: '资质列表', requiresAuth: true } },
{ path: 'feedback', name: 'ProfileFeedback', component: () => import('@/views/profile/feedback.vue'), meta: { title: '意见反馈', requiresAuth: true } }, { path: 'feedback', name: 'ProfileFeedback', component: () => import('@/views/profile/feedback.vue'), meta: { title: '意见反馈', requiresAuth: true } },
{ path: 'help', name: 'ProfileHelp', component: () => import('@/views/profile/help.vue'), meta: { title: '帮助中心', requiresAuth: true } }, { path: 'help', name: 'ProfileHelp', component: () => import('@/views/profile/help.vue'), meta: { title: '帮助中心', requiresAuth: true } },
@@ -54,7 +51,6 @@ const layoutRoutes: RouteRecordRaw = {
], ],
} }
/** 无全局布局的路由(登录注册独立品牌页 + 404) */
const plainRoutes: RouteRecordRaw[] = [ const plainRoutes: RouteRecordRaw[] = [
{ path: '/login', name: 'Login', component: () => import('@/views/login'), meta: { title: '登录' } }, { path: '/login', name: 'Login', component: () => import('@/views/login'), meta: { title: '登录' } },
{ path: '/register', name: 'Register', component: () => import('@/views/login/register.vue'), meta: { title: '注册' } }, { path: '/register', name: 'Register', component: () => import('@/views/login/register.vue'), meta: { title: '注册' } },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,23 +1,12 @@
// 账号密码登录/注册真实接口类型(对接 vermis-maker /auth/*)
// 归位于 types/business/auth.d.ts,与 api/auth.ts 对应
/** 登录成功返回,对应后端 LoginVo(字段名以 @JsonProperty 为准) */
export interface LoginResult { export interface LoginResult {
/** 授权令牌(后端字段 access_token) */
access_token: string access_token: string
/** 刷新令牌 */
refresh_token?: string refresh_token?: string
/** access_token 有效期(秒) */
expire_in?: number expire_in?: number
/** refresh_token 有效期(秒) */
refresh_expire_in?: number refresh_expire_in?: number
/** 应用 id */
client_id?: string client_id?: string
/** 令牌权限 */
scope?: string scope?: string
/** 用户 openid */
openid?: string openid?: string
} }
/** 注册返回:后端 R<Void>,无 data */
export type RegisterResult = void export type RegisterResult = void

View File

@@ -1,11 +1,24 @@
// 案例实体(PRD 4.1,home/cases/profile.favorites 共享) //
import type { BusinessType } from '@/types/api' import type { BusinessType } from '@/types/api'
/** 项目规模区间 */ export interface CaseTypeOption {
export type ScaleRange = 'lt50w' | '50to100w' | '100to300w' | '300to500w' | 'gt500w' value: string
label: string
count: number
}
export interface CaseAttachment {
name: string
size: string
url: string
}
export interface CaseSection {
title: string
content: string
}
/** 案例 */
export interface Case { export interface Case {
id: string id: string
title: string title: string
@@ -13,26 +26,124 @@ export interface Case {
cover: string cover: string
finishDate: string finishDate: string
teamSize: number teamSize: number
/** 规模展示值(如 '300万'、'10-50万'),筛选区间见 CaseQuery.scales */
scale: string scale: string
industry: string industry: string
duration: string duration: string
clientType: string clientType: string
background: string sections: CaseSection[]
painPoints: string attachments?: CaseAttachment[]
requirements: string
deliverables: string
video: string video: string
isFavorite: boolean isFavorite: boolean
favoriteCount: number
} }
/** 案例筛选条件 */
export interface CaseQuery { export interface CaseQuery {
category: BusinessType | 'all' categories: string[]
industries: string[] industries: string[]
scales: ScaleRange[] scales: ScaleLimit[]
dateRange: [string, string] | null dateRange: [string, string] | null
favoriteOnly: boolean
keyword: string keyword: string
page: number page: number
pageSize: number pageSize: number
} }
export interface ProjectCaseUserBo {
businessTypes?: string[]
businessIndustries?: string[]
scales?: ScaleLimit[]
isFavorite?: number
key?: string
beginTime?: string
endTime?: string
}
export interface ScaleLimit {
lowerLimit?: number | null
upperLimit?: number | null
model?: string | null
label?: string | null
sort?: number | null
}
export interface BusinessIndustryOption {
name: string | null
sort: number | null
}
export interface CaseSelectionModel {
businessIndustry: BusinessIndustryOption[] | null
upperAndLowerLimits: ScaleLimit[] | null
projectCaseStatisticsVo: ProjectCaseStatisticsVo | null
}
export interface ProjectCaseStatisticsVo {
total: number | null
projectCaseCounts: ProjectCaseCountVo[] | null
}
export interface ProjectCaseCountVo {
id: string | null
businessType: string | null
caseCount: number | null
}
export interface ProjectCaseAndCaseCountVo {
projectCasePage: ProjectCasePageVo | null
projectCaseStatisticsVo: ProjectCaseStatisticsVo | null
}
export interface ProjectCasePageVo {
total: number
rows: ProjectCaseVo[]
}
export interface ProjectCaseVo {
id: string
caseNo: string | null
caseName: string | null
businessType: string | null
projectTypeName: string | null
businessIndustry: string | null
enabled: number | null
scale: number | null
previewCount: number | null
viewCount: number | null
favoriteCount: number | null
coverUrl: string | null
customerType: string | null
industry: string | null
projectCycle: string | null
serviceTeam: number | null
completedAt: string | null
isFavorite: number | null
backgrounds: ProjectCaseBackgroundVo[] | null
catalogs: ProjectCaseCatalogVo[] | null
attachments: ProjectCaseAttachmentVo[] | null
updateTime: string | null
}
export interface ProjectCaseBackgroundVo {
id: string | null
projectId: string | null
content: string | null
sortOrder: number | null
}
export interface ProjectCaseCatalogVo {
id: string | null
projectId: string | null
title: string | null
content: string | null
sortOrder: number | null
}
export interface ProjectCaseAttachmentVo {
id: string | null
projectId: string | null
ossId: string | null
originalName: string | null
fileSize: string | null
url: string | null
sortOrder: number | null
}

View File

@@ -1,11 +1,8 @@
// 合同中心实体(PRD M6)
import type { FileInfo } from '@/types/api' import type { FileInfo } from '@/types/api'
/** 签署状态 */
export type SignStatus = '履行中' | '已过期' | '待签署' export type SignStatus = '履行中' | '已过期' | '待签署'
/** 合同 */
export interface Contract { export interface Contract {
id: string id: string
contractNo: string contractNo: string
@@ -21,7 +18,6 @@ export interface Contract {
files: FileInfo[] files: FileInfo[]
} }
/** 合同列表查询条件 */
export interface ContractQuery { export interface ContractQuery {
keyword: string keyword: string
page: number page: number

View File

@@ -1,20 +1,86 @@
// 需求提报实体(M3,page-architecture)
import type { BusinessType, FileInfo } from '@/types/api' export type BizIconKey = 'code' | 'chip' | 'iot' | 'factory' | 'other'
/** 预算范围 */ export interface DictOption {
export type BudgetRange = 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | '' dictLabel: string
dictValue: string
dictSort: number
}
export interface SubmissionBizTypeVo {
id: string | number
businessType: string | null
title: string | null
subtitle: string | null
coverUrl: string | null
logoUrl: string | null
}
export interface BizTypeOption {
value: string
label: string
desc: string
icon: BizIconKey
iconUrl?: string
}
export interface DemandAttachment {
id?: string | number | null
draftId?: string | number | null
ossId?: string | number | null
originalName?: string | null
url?: string | null
fileSize?: number | null
type?: string | null
createTime?: string | null
}
/** 需求提报表单 */
export interface Demand { export interface Demand {
businessType: BusinessType id?: string | number | null
bizType: string
projectName: string projectName: string
attachments: FileInfo[] requirementDesc: string
coreDescription: string budgetRange: string
expectedDuration: number | '' budgetManual: boolean
budgetRange: 'lt1w' | '1to5w' | '5to10w' | 'gt10w' | '' developmentCycle: number | ''
supplementaryRemarks: string
userName: string userName: string
userPhone: string userPhone: string
remark: string attachmentInfo: DemandAttachment[]
status: 'draft' | 'submitted' }
export interface DemandSubmitBo {
businessType: string
projectName: string
requirementDesc: string
customerName: string
customerPhone: string
budgetRange: string | null
extraRemark: string | null
}
export interface DemandDraftVo {
id: string | number | null
bizType: string | null
projectName: string | null
requirementDesc: string | null
budgetRange: string | null
budgetCustom: string | null
developmentCycle: number | null
supplementaryRemarks: string | null
userName: string | null
userPhone: string | null
attachmentInfo: DemandAttachment[] | null
}
export interface OssUploadVo {
ossId: string | number
url: string
originalName: string
fileSuffix: string
}
export interface OssUrlVo {
ossId: string | number
url: string
} }

View File

@@ -1,18 +1,65 @@
// 财务中心实体(账单/支付/发票/抬头/统计)
/** 账单状态 */ export type BillStatus = '待支付' | '已支付' | '未到期' | '已取消'
export type BillStatus = '待支付' | '已支付' | '未到期'
export type BillStatusCode = 'UNSTARTED' | 'PENDING' | 'PAID' | 'CANCELLED'
export interface BillVo {
id: number | string
billNo?: string
projectId?: number | string
projectNo?: string
projectName?: string
userId?: number | string
amount?: number
bizType?: string
billType?: string
periodName?: string
periodRatio?: number
status?: BillStatusCode | string
payChannel?: string
transactionNo?: string
tradeName?: string
payTime?: string
dueDate?: string
createTime?: string
}
export interface BillPlanVo {
id: number | string
projectId?: number | string
projectNo?: string
projectName?: string
userId?: number | string
bizType?: string
totalAmount?: number | string
periods?: number
currentPeriod?: number
periodRule?: string
nextDate?: string
status?: string
orderNo?: string
transactionNo?: string
payChannel?: string
payAmount?: number | string
payStatus?: string
payTime?: string
}
/** 发票状态 */
export type InvoiceStatus = '已开票' | '开票中' | '未开票' export type InvoiceStatus = '已开票' | '开票中' | '未开票'
/** 支付方式 */
export type PayMethod = 'wechat' | 'alipay' export type PayMethod = 'wechat' | 'alipay'
/** 账单 */ export interface PayOrderResponse {
channel: string
scene: string
codeUrl?: string
formBody?: string
orderString?: string
h5Url?: string
}
export interface Bill { export interface Bill {
id: string id: string
/** 分期计划名,如 '第一期·预付款 比例33%' */
planName: string planName: string
billNo: string billNo: string
tradeNo?: string tradeNo?: string
@@ -20,9 +67,10 @@ export interface Bill {
amount: number amount: number
status: BillStatus status: BillStatus
createTime: string createTime: string
payChannel?: string
payTime?: string
} }
/** 账单查询条件 */
export interface BillQuery { export interface BillQuery {
keyword: string keyword: string
status: BillStatus | 'all' status: BillStatus | 'all'
@@ -30,7 +78,6 @@ export interface BillQuery {
pageSize: number pageSize: number
} }
/** 支付单(二维码) */
export interface Payment { export interface Payment {
billId: string billId: string
method: PayMethod method: PayMethod
@@ -38,7 +85,6 @@ export interface Payment {
qrExpiresInSeconds: number qrExpiresInSeconds: number
} }
/** 支付结果 */
export interface PaymentResult { export interface PaymentResult {
method: PayMethod method: PayMethod
amount: number amount: number
@@ -47,7 +93,6 @@ export interface PaymentResult {
bills: Bill[] bills: Bill[]
} }
/** 发票 */
export interface Invoice { export interface Invoice {
id: string id: string
planName: string planName: string
@@ -59,7 +104,6 @@ export interface Invoice {
applyTime: string applyTime: string
} }
/** 发票查询条件 */
export interface InvoiceQuery { export interface InvoiceQuery {
keyword: string keyword: string
status: InvoiceStatus | 'all' status: InvoiceStatus | 'all'
@@ -67,7 +111,6 @@ export interface InvoiceQuery {
pageSize: number pageSize: number
} }
/** 发票抬头 */
export interface InvoiceTitle { export interface InvoiceTitle {
id: string id: string
type: '企业单位' | '非企业单位' type: '企业单位' | '非企业单位'
@@ -76,14 +119,12 @@ export interface InvoiceTitle {
isDefault: boolean isDefault: boolean
} }
/** 新增/编辑发票抬头入参 */
export interface InvoiceTitlePayload { export interface InvoiceTitlePayload {
type: InvoiceTitle['type'] type: InvoiceTitle['type']
title: string title: string
taxNo: string taxNo: string
} }
/** 财务统计 */
export interface FinanceStats { export interface FinanceStats {
totalExpense: number totalExpense: number
month: string month: string

View File

@@ -1,34 +1,159 @@
// 首页实体(PRD 3.1/3.2,page-architecture M1) //
import type { BusinessType } from '@/types/api' import type { BusinessType } from '@/types/api'
/** 数据看板 */
export interface HomeStats { export interface HomeStats {
pending: number pending: number
inProgress: number inProgress: number
total: number total: number
} }
/** 快捷入口 */ export interface Banner {
id: number
imageUrl: string
}
export interface QuickLink { export interface QuickLink {
label: string label: string
path: string path: string
} }
/** 服务类型子项 */
export interface ServiceType { export interface ServiceType {
label: string label: string
desc: string desc: string
icon?: string
}
export interface ServiceContentBlock {
title: string
content: string
} }
/** 业务线(首页业务卡片 + 业务详情页共用) */
export interface BusinessLine { export interface BusinessLine {
id: BusinessType id: BusinessType
name: string name: string
desc: string desc: string
slogan: string slogan: string
icon: string icon: string
cover: string
serviceContent: string serviceContent: string
contents: ServiceContentBlock[]
serviceTypes: ServiceType[] serviceTypes: ServiceType[]
video: string video: string
} }
export interface ProjectStatusCount {
projectCount: number
ongoingProjectCount: number
pendingProjectCount: number
}
export interface ProjectServiceTypeVo {
id: number | null
projectId: number | null
serviceName: string | null
iconUrl: string | null
sortOrder: number | null
}
export interface ProjectBackgroundVo {
id: number | null
projectId: number | null
content: string | null
sortOrder: number | null
}
export interface ProjectSubmissionVo {
id: number
businessType: string | null
title: string | null
subtitle: string | null
enabled: number | null
coverUrl: string | null
logoUrl: string | null
serviceTypes: ProjectServiceTypeVo[]
backgrounds: ProjectBackgroundVo[]
/** yyyy-MM-dd HH:mm:ss */
updateTime: string | null
}
export interface ProjectSubmissionInfo {
projectSubmission: ProjectSubmissionFlat | null
projectServiceType: ProjectServiceTypeInfo[] | null
projectSubmissionAttachment: ProjectSubmissionAttachmentInfo[] | null
projectSubmissionCatalog: ProjectSubmissionCatalogInfo[] | null
}
export interface ProjectSubmissionFlat {
id: number | null
businessType: string | null
title: string | null
subtitle: string | null
enabled: number | null
coverUrl: string | null
logoUrl: string | null
}
export interface ProjectServiceTypeInfo {
id: number | null
projectId: number | null
serviceName: string | null
iconUrl: string | null
sortOrder: number | null
}
export interface ProjectSubmissionAttachmentInfo {
id: number | null
projectId: number | null
originalName: string | null
fileSize: string | null
url: string | null
sortOrder: number | null
}
export interface ProjectSubmissionCatalogInfo {
id: number | null
projectId: number | null
title: string | null
content: string | null
sortOrder: number | null
}
export interface BannerVo {
id: number | null
imageUrl: string | null
sort: number | null
enabled: boolean | null
}
export interface AgreementFormVo {
id: number | null
type: string | null
content: string | null
}
export interface PortalEntryVo {
id: number | null
entryId: string | null
name: string | null
icon: string | null
route: string | null
permission: string | null
groupType: string | null
sortOrder: number | null
status: boolean | null
}
export type PortalEntryGroup = '1' | '0'
export interface PortalEntryCard {
id: number
label: string
route: string
groupType: PortalEntryGroup
icon: string
isImage: boolean
tone: EntryTone
}
export type EntryTone = 'purple' | 'green' | 'blue' | 'amber' | 'red' | 'cyan'

View File

@@ -1,24 +1,19 @@
// 消息实体(PRD M8 消息中心)
import type { FileInfo } from '@/types/api' import type { FileInfo } from '@/types/api'
/** 消息类型 */
export type MessageType = '在线客服' | '状态通知' | '系统通知' export type MessageType = '在线客服' | '状态通知' | '系统通知'
/** 站内消息 */
export interface Message { export interface Message {
id: string id: string
type: MessageType type: MessageType
title: string title: string
summary: string summary: string
/** 正文纯文本,详情页 pre-wrap 渲染 */
content: string content: string
publishTime: string publishTime: string
isRead: boolean isRead: boolean
attachments: FileInfo[] attachments: FileInfo[]
} }
/** 消息查询条件 */
export interface MessageQuery { export interface MessageQuery {
keyword: string keyword: string
type: MessageType | 'all' type: MessageType | 'all'

View File

@@ -1,23 +1,18 @@
// 个人中心实体(M10:security/address/favorites/qualifications/feedback/help/about/hotline 共享)
import type { BusinessType, FileInfo } from '@/types/api' import type { BusinessType, FileInfo } from '@/types/api'
/** 账号资料 */
export interface Profile { export interface Profile {
nickname: string nickname: string
phone: string phone: string
email: string email: string
/** 密码上次修改时间(展示值,如 '15天前') */
passwordModifiedAt: string passwordModifiedAt: string
} }
/** 修改密码入参 */
export interface ChangePasswordParams { export interface ChangePasswordParams {
code: string code: string
password: string password: string
} }
/** 收货地址 */
export interface Address { export interface Address {
id: string id: string
receiverName: string receiverName: string
@@ -27,7 +22,6 @@ export interface Address {
isDefault: boolean isDefault: boolean
} }
/** 资质类型 */
export type QualificationType = export type QualificationType =
| '营业执照' | '营业执照'
| '生产许可证' | '生产许可证'
@@ -36,10 +30,8 @@ export type QualificationType =
| '授权书' | '授权书'
| '其他资质' | '其他资质'
/** 资质审核状态 */
export type QualificationStatus = '已认证' | '审核中' | '未通过' export type QualificationStatus = '已认证' | '审核中' | '未通过'
/** 资质 */
export interface Qualification { export interface Qualification {
id: string id: string
type: QualificationType type: QualificationType
@@ -52,35 +44,29 @@ export interface Qualification {
attachments: FileInfo[] attachments: FileInfo[]
} }
/** 资质新增/编辑提交载荷(id 存在为编辑) */
export type QualificationInput = Omit<Qualification, 'id' | 'status'> & { id?: string } export type QualificationInput = Omit<Qualification, 'id' | 'status'> & { id?: string }
/** 意见反馈 */
export interface Feedback { export interface Feedback {
title: string title: string
description: string description: string
attachments: FileInfo[] attachments: FileInfo[]
} }
/** 列表分页查询 */
export interface ListQuery { export interface ListQuery {
page: number page: number
pageSize: number pageSize: number
} }
/** 我的收藏查询(关键字 + 分类 + 分页) */
export interface FavoritesQuery extends ListQuery { export interface FavoritesQuery extends ListQuery {
keyword: string keyword: string
category: BusinessType | 'all' category: BusinessType | 'all'
} }
/** 帮助中心 FAQ 条目 */
export interface HelpFaqItem { export interface HelpFaqItem {
q: string q: string
a: string a: string
} }
/** 帮助中心 FAQ 分组 */
export interface HelpFaqGroup { export interface HelpFaqGroup {
title: string title: string
items: HelpFaqItem[] items: HelpFaqItem[]

View File

@@ -1,28 +1,85 @@
// 项目中心实体(PRD M5)
import type { BusinessType, FileInfo } from '@/types/api' import type { BusinessType, FileInfo } from '@/types/api'
/** 项目状态 */ export type ProjectStatus =
export type ProjectStatus = '待立项' | '进行中' | '待验收' | '已完成' | '售后中' | '已取消' | '待立项'
| '进行中'
| '待验收'
| '待内部验收'
| '已完成'
| '售后中'
| '已取消'
export type RdProjectStatus =
| 'INITIATING'
| 'IN_PROGRESS'
| 'PENDING_ACCEPTANCE'
| 'PENDING_INTERNAL_ACCEPTANCE'
| 'COMPLETED'
| 'AFTER_SALE'
| 'CANCELLED'
export interface RdProjectVo {
id: string
requirementId?: string | null
projectNo: string
/** EXTERNAL / INTERNAL */
sourceType?: string | null
businessType?: string | null
projectName: string
requirementDesc?: string | null
customerName?: string | null
customerPhone?: string | null
submitUserId?: string | null
submitTime?: string | null
/** HIGH / MEDIUM / LOW */
priority?: string | null
status?: string | null
progress?: number | null
planStartTime?: string | null
planEndTime?: string | null
actualStartTime?: string | null
actualEndTime?: string | null
customerServiceName?: string | null
technicalOwnerName?: string | null
createTime?: string | null
updateTime?: string | null
}
export interface ProjectBusinessTypeCount {
businessTypeId: string
businessTypeLabel: string
count: number
}
export interface ProjectStatistics {
total?: number | null
businessTypeCounts?: ProjectBusinessTypeCount[] | null
}
/** GET /web/requirementPool/list 的 data:TableDataInfo 形态(不再包 requirementPage) */
export interface ProjectListResult {
total: number
rows: RdProjectVo[]
}
/** 里程碑节点 */
export interface Milestone { export interface Milestone {
name: string name: string
status: '待开始' | '进行中' | '已完成' status: '待开始' | '进行中' | '已完成'
time?: string time?: string
} }
/** 团队成员 */
export interface TeamMember { export interface TeamMember {
role: string role: string
name: string name: string
avatarChar: string avatarChar: string
} }
/** 项目账单(付款计划) */
export interface ProjectBill { export interface ProjectBill {
id: string id: string
source: 'bill' | 'plan'
planName: string planName: string
periodRatio?: string
billNo: string billNo: string
amount: number amount: number
status: '待支付' | '已支付' | '未到期' status: '待支付' | '已支付' | '未到期'
@@ -30,7 +87,6 @@ export interface ProjectBill {
projectName: string projectName: string
} }
/** 售后工单 */
export interface AfterSales { export interface AfterSales {
status: '无' | '处理中' status: '无' | '处理中'
type: string type: string
@@ -40,20 +96,30 @@ export interface AfterSales {
createTime: string createTime: string
} }
/** 售后提交入参 */ export interface AfterSalesAttachment extends FileInfo {
export interface AfterSalesPayload { ossId: string | number
type: string }
description: string
remark: string export interface AfterSalesUploadVo {
attachments: FileInfo[] ossId: string | number
url: string
originalName: string
fileSuffix?: string
}
export interface AfterSalesPayload {
type: string
reason: string
note: string
attachmentOssIds: Array<string | number>
} }
/** 项目 */
export interface Project { export interface Project {
id: string id: string
projectNo: string projectNo: string
projectName: string projectName: string
businessType: BusinessType businessType: BusinessType
businessTypeName: string
progress: number progress: number
status: ProjectStatus status: ProjectStatus
submitTime: string submitTime: string
@@ -68,12 +134,199 @@ export interface Project {
afterSales?: AfterSales afterSales?: AfterSales
} }
/** 项目列表查询条件 */ export interface ProjectTypeCount {
value: string
label: string
count: number
}
export interface ProjectQuery { export interface ProjectQuery {
keyword: string keyword: string
businessType: BusinessType | 'all' businessTypes: string[]
status: ProjectStatus | 'all' status: string
timeShortcut: 'all' | '3m' | '6m' | '1y' timeShortcut: 'all' | '3m' | '6m' | '1y'
page: number startDate: string
endDate: string
pageNum: number
pageSize: number pageSize: number
} }
// ─────────────────────────────────────────────────────────────────────────────
// 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
//
//
// ─────────────────────────────────────────────────────────────────────────────
export interface RequirementProjectStage {
id: string
projectId: string | null
requirementId: string | null
stageName: string | null
status: string | null
startTime: string | null
planTime: string | null
finishTime: string | null
developmentProgress: number | null
sort: number | null
}
export interface RequirementMilestone {
id: string
projectId: string | null
milestoneName: string | null
sortOrder: number | null
status: string | null
startTime: string | null
endTime: string | null
parallelFlag: boolean | null
}
export interface RequirementAttachment {
id: string
requirementId: string | null
phase: string | null
ossId: string | null
originalName: string | null
url: string | null
fileSize: number | null
sortOrder: number | null
createTime?: string | null
}
export interface RequirementMember {
id: string
projectId: string | null
userId: string | null
userName: string | null
roleKey: string | null
roleName: string | null
isOwner: boolean | null
}
export interface RequirementBill {
id: string
billNo: string | null
projectId: string | null
projectNo: string | null
projectName: string | null
userId: string | null
amount: string | number | null
contractId: string | null
/** ADVANCE / PROGRESS / FINAL / OTHER */
billType: string | null
periodName: string | null
periodRatio: string | number | null
/** UNSTARTED / PENDING / PAID / CANCELLED */
status: string | null
payChannel: string | null
transactionNo: string | null
tradeName: string | null
payTime: string | null
dueDate: string | null
createTime?: string | null
}
export interface RequirementBillPlan {
id: string
projectId: string | null
projectNo: string | null
projectName: string | null
totalAmount: string | number | null
periods: number | null
currentPeriod: number | null
periodRule: string | null
nextDate: string | null
status: string | null
orderNo: string | null
transactionNo: string | null
payChannel: string | null
payAmount: string | number | null
payStatus: string | null
payTime: string | null
}
export interface RequirementInvoice {
id: string
billId: string | null
invoiceTitle: string | null
taxNo: string | null
invoiceType: string | null
amount: string | number | null
status: string | null
invoiceNo: string | null
invoiceFile: string | null
applyTime: string | null
invoiceDate: string | null
createTime?: string | null
}
export interface RequirementContract {
id: string
contractNo: string | null
projectId: string | null
title: string | null
amount: string | number | null
signStatus: string | null
status: string | null
signDate: string | null
file: string | null
createTime?: string | null
}
export interface RequirementAfterSale {
id: string
requirementId: string | null
type: string | null
reason: string | null
remark: string | null
status: string | null
requestUserName: string | null
requestTime: string | null
processUserName: string | null
processNote: string | null
processTime: string | null
withdrawReason: string | null
}
export interface RequirementBaseVo {
id: string
projectNo: string | null
projectName: string | null
businessType: string | null
status: string | null
createTime: string | null
submitUserName: string | null
}
export interface RequirementProgressVo extends RequirementBaseVo {
projectStage: RequirementProjectStage | null
developmentProgress?: number | null
projectMilestones: RequirementMilestone[]
members: RequirementMember[]
}
export interface RequirementFilesVo extends RequirementBaseVo {
requirementAttachments: RequirementAttachment[]
}
export interface RequirementFinanceVo extends RequirementBaseVo {
bills?: RequirementBill[]
billPlans: RequirementBillPlan[]
invoices: RequirementInvoice[]
contracts: RequirementContract[]
}
export interface RequirementAfterSalesVo extends RequirementBaseVo {
afterSale: RequirementAfterSale | null
afterSaleAttachments: RequirementAttachment[]
}
export interface RequirementDetailBundle {
progress: RequirementProgressVo
files: RequirementFilesVo
finance: RequirementFinanceVo
afterSales: RequirementAfterSalesVo
}

View File

@@ -1,14 +1,10 @@
// 在线客服会话实体(PRD M9 在线客服)
/** 会话角色 */
export type ChatRole = 'user' | 'assistant' export type ChatRole = 'user' | 'assistant'
/** 会话消息 */
export interface ChatMessage { export interface ChatMessage {
id: string id: string
role: ChatRole role: ChatRole
content: string content: string
/** 文件消息卡片(有值时按文件渲染) */
file?: { file?: {
name: string name: string
size: number size: number

View File

@@ -1,6 +1,4 @@
// 用户/认证实体(M4,page-architecture)
/** 用户 */
export interface User { export interface User {
id: string id: string
nickname: string nickname: string
@@ -10,25 +8,31 @@ export interface User {
passwordModifiedAt: string passwordModifiedAt: string
} }
/** 账号密码登录参数 */ export interface UserProfileVo {
userId: string
userName: string
nickName: string
phoneNumber: string
email: string
avatarUrl: string
deptName: string
}
export interface LoginByAccountParams { export interface LoginByAccountParams {
account: string account: string
password: string password: string
} }
/** 短信验证码登录参数 */
export interface LoginBySmsParams { export interface LoginBySmsParams {
phone: string phone: string
code: string code: string
} }
/** 登录结果 */
export interface LoginResult { export interface LoginResult {
token: string token: string
user: User user: User
} }
/** 注册参数 */
export interface RegisterParams { export interface RegisterParams {
phone: string phone: string
code: string code: string
@@ -37,7 +41,6 @@ export interface RegisterParams {
agree: boolean agree: boolean
} }
/** 找回/重置密码参数 */
export interface ResetPasswordParams { export interface ResetPasswordParams {
phone: string phone: string
code: string code: string

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